From 7e0fcbfdfd8fed9cef3ec2873041fb54d953907a Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 01:15:00 -0700 Subject: [PATCH 01/10] refactor(files): unify mutation accounting and retention --- apps/sim/background/cleanup-file-versions.ts | 195 +------ .../background/cleanup-soft-deletes.test.ts | 157 +----- apps/sim/background/cleanup-soft-deletes.ts | 491 +----------------- apps/sim/lib/billing/storage/accounting.ts | 59 +++ apps/sim/lib/billing/storage/index.ts | 1 + .../sim/lib/billing/storage/payer-transfer.ts | 2 +- .../storage/project-accounting.integration.ts | 178 ++++++- apps/sim/lib/billing/storage/tracking.ts | 74 +-- apps/sim/lib/file-retention/folders.ts | 157 ++++++ apps/sim/lib/file-retention/index.ts | 1 + apps/sim/lib/file-retention/legacy-archive.ts | 385 ++++++++++++++ apps/sim/lib/file-retention/service.ts | 147 ++++++ apps/sim/lib/file-retention/types.ts | 24 + apps/sim/lib/file-retention/versions.ts | 72 +++ .../lib/file-retention/workspace-versions.ts | 100 ++++ .../private-purge.integration.ts | 4 +- .../__integration__/retention.integration.ts | 446 ++++++++++++++-- apps/sim/lib/projects/files/retention.ts | 162 ++---- .../file-versions.integration.ts | 2 +- .../workspace-file-folder-manager.ts | 2 +- .../workspace/workspace-file-manager.ts | 364 +++++++------ .../workspace-file-storage-accounting.test.ts | 77 ++- .../workspace-file-storage-billing.test.ts | 108 ---- .../uploads/server/content-effects.test.ts | 36 ++ .../sim/lib/uploads/server/content-effects.ts | 32 ++ apps/sim/lib/workspace-files/ownership.ts | 5 + .../testing/src/mocks/billing-storage.mock.ts | 19 +- .../src/mocks/workspace-file-folders.mock.ts | 4 + packages/utils/src/errors.ts | 24 + scripts/check-file-names.baseline.json | 1 - 30 files changed, 1957 insertions(+), 1372 deletions(-) create mode 100644 apps/sim/lib/billing/storage/accounting.ts create mode 100644 apps/sim/lib/file-retention/folders.ts create mode 100644 apps/sim/lib/file-retention/index.ts create mode 100644 apps/sim/lib/file-retention/legacy-archive.ts create mode 100644 apps/sim/lib/file-retention/service.ts create mode 100644 apps/sim/lib/file-retention/types.ts create mode 100644 apps/sim/lib/file-retention/versions.ts create mode 100644 apps/sim/lib/file-retention/workspace-versions.ts delete mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts create mode 100644 apps/sim/lib/uploads/server/content-effects.test.ts create mode 100644 apps/sim/lib/uploads/server/content-effects.ts diff --git a/apps/sim/background/cleanup-file-versions.ts b/apps/sim/background/cleanup-file-versions.ts index 76f1c321b4c..593b3ac42d6 100644 --- a/apps/sim/background/cleanup-file-versions.ts +++ b/apps/sim/background/cleanup-file-versions.ts @@ -1,198 +1,27 @@ -import { dbFor } from '@sim/db' -import { workspaceFileVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { chunkArray } from '@sim/utils/helpers' import { task } from '@trigger.dev/sdk' -import { and, count, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm' import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher' -import { - DEFAULT_BATCH_SIZE, - DEFAULT_DELETE_CHUNK_SIZE, - DEFAULT_MAX_BATCHES_PER_TABLE, - DEFAULT_WORKSPACE_CHUNK_SIZE, -} from '@/lib/cleanup/batch-delete' +import { DEFAULT_BATCH_SIZE, DEFAULT_MAX_BATCHES_PER_TABLE } from '@/lib/cleanup/batch-delete' import { retentionCleanupQueue } from '@/lib/cleanup/queue' -import { cleanupProjectFileVersions } from '@/lib/projects/files/retention' -import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' -import { MAX_SUPERSEDED_FILE_VERSIONS } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { cleanupFileVersions, fileRetentionOwners } from '@/lib/file-retention' const logger = createLogger('CleanupFileVersions') - -/** All cleanup queries run on the dedicated cleanup pool. */ -const cleanupDb = dbFor('cleanup') - -/** Candidate files whose histories are ranked in one query. */ -const FILES_PER_QUERY = 500 - -/** - * Bounds one run like the other cleanup jobs: {@link DEFAULT_MAX_BATCHES_PER_TABLE} batches per - * workspace chunk and this many versions overall. The next run resumes where this one stopped. - */ const MAX_VERSIONS_PER_RUN = DEFAULT_BATCH_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE -/** - * Superseded versions a free file keeps (its newest 100 with the current one); versions beyond it - * are pruned whatever their age. Paid plans are bounded only by the inline write-time ceiling. - */ -const FREE_MAX_SUPERSEDED_VERSIONS = 99 - -/** - * Newest superseded versions retention never prunes, whatever their age, so a file always keeps its - * newest ten versions with the current one. - */ -const KEEP_SUPERSEDED = 9 - -/** - * Files in the group that can lose any version: more superseded versions than the keep-latest floor, - * and either one older than the cutoff or more than the plan allows. Ranking only these keeps the - * sort to the histories that need pruning instead of every superseded row in the group. - */ -async function selectCandidateFileIds( - workspaceIds: string[], - cutoff: Date, - maxSuperseded: number -): Promise { - const rows = await cleanupDb - .select({ fileId: workspaceFileVersion.fileId }) - .from(workspaceFileVersion) - .where( - and( - inArray(workspaceFileVersion.workspaceId, workspaceIds), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .groupBy(workspaceFileVersion.fileId) - .having( - and( - gt(count(), KEEP_SUPERSEDED), - or( - lt( - min(workspaceFileVersion.supersededAt), - sql.param(cutoff, workspaceFileVersion.supersededAt) - ), - gt(count(), maxSuperseded) - ) - ) - ) - return rows.map((row) => row.fileId) -} - -/** - * Superseded versions of the given files past retention: older than the cutoff or beyond the plan's - * count, but never among the newest {@link KEEP_SUPERSEDED} superseded versions of a file. - */ -function selectExpiredVersions( - fileIds: string[], - cutoff: Date, - maxSuperseded: number, - batchSize: number -) { - const ranked = cleanupDb - .select({ - id: workspaceFileVersion.id, - supersededAt: workspaceFileVersion.supersededAt, - rank: sql`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as( - 'rank' - ), - }) - .from(workspaceFileVersion) - .where( - and( - inArray(workspaceFileVersion.fileId, fileIds), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .as('ranked') - - return cleanupDb - .select({ id: ranked.id }) - .from(ranked) - .where( - and( - gt(ranked.rank, KEEP_SUPERSEDED), - or(lt(ranked.supersededAt, cutoff), gt(ranked.rank, maxSuperseded)) - ) - ) - .limit(batchSize) -} - -/** - * Deletes the expired version rows and enqueues their stored objects on the storage-cleanup outbox - * in the same transaction, so a row never outlives its release and every released object is - * deleted durably — the outbox retries failures and treats an already-missing object as done. - */ -function deleteVersions(rows: Array<{ id: string }>): Promise { - return cleanupDb.transaction(async (tx) => { - const removed = await tx - .delete(workspaceFileVersion) - .where( - and( - inArray( - workspaceFileVersion.id, - rows.map((row) => row.id) - ), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .returning({ key: workspaceFileVersion.key }) - await enqueueWorkspaceFileStorageCleanups( - tx, - removed.map((row) => row.key) - ) - return removed.length - }) -} - export async function runCleanupFileVersions(payload: CleanupJobPayload): Promise { const startTime = Date.now() - const { workspaceIds, retentionHours, label, plan } = payload - let projectDeleted = 0 - for (const projectId of [...new Set(payload.projectIds ?? [])].sort()) { - if (projectDeleted >= MAX_VERSIONS_PER_RUN) break - projectDeleted += await cleanupProjectFileVersions( - projectId, - MAX_VERSIONS_PER_RUN - projectDeleted - ) - } - if (workspaceIds.length === 0) { - logger.info(`[${label}] No workspaces to process`) - return - } - - const cutoff = new Date(Date.now() - retentionHours * 60 * 60 * 1000) - const maxSuperseded = - plan === 'free' ? FREE_MAX_SUPERSEDED_VERSIONS : MAX_SUPERSEDED_FILE_VERSIONS - logger.info( - `[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${cutoff.toISOString()}` + const owners = fileRetentionOwners(payload) + const deleted = await cleanupFileVersions( + owners, + { + plan: payload.plan, + cutoff: new Date(Date.now() - payload.retentionHours * 60 * 60 * 1000), + label: payload.label, + }, + MAX_VERSIONS_PER_RUN ) - - let deleted = projectDeleted - let attempted = projectDeleted - for (const group of chunkArray(workspaceIds, DEFAULT_WORKSPACE_CHUNK_SIZE)) { - if (attempted >= MAX_VERSIONS_PER_RUN) break - const candidates = await selectCandidateFileIds(group, cutoff, maxSuperseded) - let batches = 0 - for (const fileIds of chunkArray(candidates, FILES_PER_QUERY)) { - let exhausted = false - while ( - !exhausted && - batches < DEFAULT_MAX_BATCHES_PER_TABLE && - attempted < MAX_VERSIONS_PER_RUN - ) { - batches++ - const batchSize = Math.min(DEFAULT_DELETE_CHUNK_SIZE, MAX_VERSIONS_PER_RUN - attempted) - const expired = await selectExpiredVersions(fileIds, cutoff, maxSuperseded, batchSize) - attempted += expired.length - const removed = expired.length > 0 ? await deleteVersions(expired) : 0 - deleted += removed - exhausted = expired.length < batchSize || removed === 0 - } - if (!exhausted) break - } - } - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2) - logger.info(`[${label}] File version cleanup: ${deleted} released in ${elapsed}s`) + logger.info(`[${payload.label}] File version cleanup: ${deleted} released in ${elapsed}s`) } export const cleanupFileVersionsTask = task({ diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index ef07a1a9cb7..22169f396ea 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -1,5 +1,4 @@ import { - dbChainMock, dbChainMockFns, hasMockCondition, queueTableRows, @@ -18,10 +17,6 @@ import { uploadsMetadataMockFns, } from '@sim/testing/mocks/uploads-metadata.mock' import { workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing/mocks/workflows-utils.mock' -import { - workspaceFileManagerMock, - workspaceFileManagerMockFns, -} from '@sim/testing/mocks/workspace-file-manager.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -77,19 +72,14 @@ vi.mock('@/lib/uploads', () => uploadsMock) vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) -const { mockReleaseWorkspaceFileVersionsForPurgeInTx } = vi.hoisted(() => ({ - mockReleaseWorkspaceFileVersionsForPurgeInTx: vi.fn(), -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-versions', () => ({ - releaseWorkspaceFileVersionsForPurgeInTx: mockReleaseWorkspaceFileVersionsForPurgeInTx, +vi.mock('@/lib/file-retention/folders', () => ({ + cleanupArchivedWorkspaceFileFolders: vi.fn(async () => 0), })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => workspaceFileManagerMock) - import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes' const { mockDecrementStorageUsageForBillingContextInTx, mockResolveStorageBillingContext } = @@ -102,10 +92,8 @@ const { mockDeleteFiles } = storageServiceMockFns const { mockDeleteFileMetadata } = uploadsMetadataMockFns const { mockIsUsingCloudStorage } = uploadsMockFns const { mockDeduplicateWorkflowName } = workflowsUtilsMockFns -const { mockAllocateUniqueWorkspaceFileName } = workspaceFileManagerMockFns mockDeleteFileMetadata.mockImplementation(async () => true) mockDeduplicateWorkflowName.mockImplementation(async (name: string) => name) -mockAllocateUniqueWorkspaceFileName.mockImplementation(async (_ws: string, name: string) => name) const basePayload = { label: 'free/1', @@ -134,67 +122,22 @@ describe('cleanup soft deletes', () => { }) }) - it('releases version history inside the transaction that purges the file rows', async () => { - mockSelectRowsByIdChunks - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - id: 'file-purged', - key: 'workspace/ws-1/file-purged', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 5, - }, - { - id: 'file-failed', - key: 'workspace/ws-1/file-failed', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 4, - }, - ]) - mockDeleteFiles.mockResolvedValueOnce({ - deleted: 1, - failed: [{ key: 'workspace/ws-1/file-failed', error: 'storage unavailable' }], - }) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'file-purged', sizeBytes: 5 }]) - - await runCleanupSoftDeletes(basePayload) - - expect(mockReleaseWorkspaceFileVersionsForPurgeInTx).toHaveBeenCalledOnce() - expect(mockReleaseWorkspaceFileVersionsForPurgeInTx).toHaveBeenCalledWith( - dbChainMock.db, - ['file-purged'], - expect.any(Date) - ) - expect(dbChainMockFns.transaction.mock.invocationCallOrder[0]).toBeLessThan( - mockReleaseWorkspaceFileVersionsForPurgeInTx.mock.invocationCallOrder[0] - ) - const fileDelete = dbChainMockFns.delete.mock.calls.findIndex( - ([table]) => table === schemaMock.workspaceFiles - ) - expect(mockReleaseWorkspaceFileVersionsForPurgeInTx.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.delete.mock.invocationCallOrder[fileDelete] - ) - }) - - it('keeps metadata rows whose object deletion failed', async () => { + it('keeps unbilled metadata rows whose object deletion failed', async () => { mockSelectRowsByIdChunks .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'file-failed', - key: 'workspace/ws-1/file-failed', + key: 'knowledge-base/file-failed', workspaceId: 'ws-1', - context: 'workspace', + context: 'knowledge-base', sizeBytes: 11, }, ]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 0, - failed: [{ key: 'workspace/ws-1/file-failed', error: 'storage unavailable' }], + failed: [{ key: 'knowledge-base/file-failed', error: 'storage unavailable' }], }) await runCleanupSoftDeletes(basePayload) @@ -206,42 +149,6 @@ describe('cleanup soft deletes', () => { ).toBe(false) }) - it('decrements the current workspace payer only for rows conditionally deleted', async () => { - mockSelectRowsByIdChunks - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - id: 'file-deleted', - key: 'workspace/ws-1/file-deleted', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 7, - }, - { - id: 'file-restored', - key: 'workspace/ws-1/file-restored', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 13, - }, - ]) - mockDeleteFiles.mockResolvedValueOnce({ deleted: 2, failed: [] }) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'file-deleted', sizeBytes: 7 }]) - - await runCleanupSoftDeletes(basePayload) - - expect(mockResolveStorageBillingContext).toHaveBeenCalledOnce() - expect(mockDecrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - dbChainMock.db, - expect.objectContaining({ workspaceId: 'ws-1' }), - 7 - ) - expect(mockDeleteFiles.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.transaction.mock.invocationCallOrder[0] - ) - }) - it('hard-deletes mothership metadata without touching stored-byte counters', async () => { mockSelectRowsByIdChunks .mockResolvedValueOnce([]) @@ -423,12 +330,7 @@ describe('folder cleanup target', () => { expect(target?.additionalPredicate).toBeDefined() expect(target?.additionalPredicate?.type).toBe('inArray') expect(target?.additionalPredicate?.column).toBe(schemaMock.folder.resourceType) - expect(target?.additionalPredicate?.values).toEqual([ - 'workflow', - 'file', - 'knowledge_base', - 'table', - ]) + expect(target?.additionalPredicate?.values).toEqual(['workflow', 'knowledge_base', 'table']) }) /** @@ -465,24 +367,6 @@ describe('folder cleanup target', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ folderId: null, name: 'Report (2)' }) }) - it('falls back to an id-suffixed name when the copy-suffix range is exhausted', async () => { - // Letting the allocator throw would abort the sweep — the exact stall this guards against. - const onBatch = await getFolderOnBatch() - queueTableRows(schemaMock.folder, [{ id: 'folder-1' }]) - queueTableRows(schemaMock.workflow, []) - queueTableRows(schemaMock.workspaceFiles, [ - { id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' }, - ]) - mockAllocateUniqueWorkspaceFileName.mockRejectedValueOnce(new Error('conflict')) - - await expect(onBatch([{ id: 'folder-1' }])).resolves.toBeUndefined() - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - folderId: null, - originalName: 'report.pdf (f1)', - }) - }) - it('re-roots an active SUBFOLDER, which hits the same unique index', async () => { /** * `folder.parentId` is also ON DELETE SET NULL and @@ -518,32 +402,6 @@ describe('folder cleanup target', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ parentId: null, name: 'Reports (1)' }) }) - it('recovers when the allocator RETURNS a colliding name and the update raises', async () => { - /** - * `allocateUniqueWorkspaceFileName` fails open — `fileExistsInWorkspace` swallows query - * errors and returns false — so it can hand back a name already taken at the root. Only - * the UPDATE discovers that, and an uncaught 23505 aborts the batch: the exact stall this - * hook prevents. Guarding the name lookup alone is not enough. - */ - const onBatch = await getFolderOnBatch() - queueTableRows(schemaMock.folder, [{ id: 'folder-1' }]) - queueTableRows(schemaMock.workflow, []) - queueTableRows(schemaMock.workspaceFiles, [ - { id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' }, - ]) - mockAllocateUniqueWorkspaceFileName.mockResolvedValueOnce('taken.pdf') - dbChainMockFns.update.mockImplementationOnce(() => ({ - set: () => ({ where: () => Promise.reject(new Error('duplicate key value (23505)')) }), - })) - - await expect(onBatch([{ id: 'folder-1' }])).resolves.toBeUndefined() - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - folderId: null, - originalName: 'report.pdf (f1)', - }) - }) - it('leaves children alone when the folder was restored between select and onBatch', async () => { /** * The DELETE re-asserts eligibility and so correctly skips a restored folder. Without the @@ -564,7 +422,6 @@ describe('folder cleanup target', () => { await onBatch([{ id: 'folder-1' }]) expect(mockDeduplicateWorkflowName).not.toHaveBeenCalled() - expect(mockAllocateUniqueWorkspaceFileName).not.toHaveBeenCalled() expect(dbChainMockFns.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index cc7bc97c020..49e82024b83 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -1,6 +1,5 @@ -import { db, dbFor } from '@sim/db' +import { dbFor } from '@sim/db' import { - changelogRelease, copilotChats, document, folder as folderTable, @@ -10,8 +9,6 @@ import { userTableDefinitions, workflow, workflowMcpServer, - workflowTest, - workspaceFile, workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -19,18 +16,12 @@ import { chunkArray } from '@sim/utils/helpers' import { task } from '@trigger.dev/sdk' import { and, asc, eq, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm' import { type CleanupJobPayload, runCleanupWithLimits } from '@/lib/billing/cleanup-dispatcher' -import { - decrementStorageUsageForBillingContextInTx, - lockWorkspaceStorageForMutationInTx, - resolveStorageBillingContext, -} from '@/lib/billing/storage' import { batchDeleteByWorkspaceAndTimestamp, chunkedBatchDelete, chunkedBatchDeleteByScope, consumeRowBudget, DEFAULT_DELETE_CHUNK_SIZE, - DEFAULT_MAX_BATCHES_PER_TABLE, type RowBudget, selectRowsByIdChunks, } from '@/lib/cleanup/batch-delete' @@ -42,30 +33,18 @@ import { cleanupOwnerCondition, resolveCleanupOwnerScope, } from '@/lib/cleanup/resource-scope' +import { beginFileArchiveCleanup, fileRetentionOwners } from '@/lib/file-retention' import { deduplicateFolderName } from '@/lib/folders/naming' import { requireWorkspaceFolder } from '@/lib/folders/scope' import { settleDetachedConnectorReservations } from '@/lib/knowledge/connectors/detachment' import { hardDeleteDocuments } from '@/lib/knowledge/documents/service' -import { - cleanupArchivedProjectFileFolders, - cleanupArchivedProjectFiles, -} from '@/lib/projects/files/retention' -import { lockWorkspaceProject } from '@/lib/projects/membership' -import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' -import { allocateUniqueWorkspaceFileName } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { releaseWorkspaceFileVersionsForPurgeInTx } from '@/lib/uploads/contexts/workspace/workspace-file-versions' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' -import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('CleanupSoftDeletes') -/** - * Cleanup queries run on the dedicated cleanup pool. The one exception is the - * billable-file transaction below, which couples row deletion with a storage - * billing decrement — billing writes stay on the default client. - */ +/** Resource cleanup queries run on the dedicated cleanup pool. */ const cleanupDb = dbFor('cleanup') const KB_ORPHAN_BINDING_BATCH_SIZE = 500 @@ -81,324 +60,6 @@ const KB_RETENTION_BATCH_SIZE = 100 const KB_DOCUMENT_DELETE_BATCH_SIZE = 500 const KB_DOCUMENT_DELETE_MAX_BATCHES = 50 -interface WorkspaceFileScope { - /** Rows from `workspace_file` (singular, legacy workspace-context only). */ - legacyRows: Array<{ id: string; key: string; workspaceId: string }> - /** Rows from `workspace_files` (plural, multi-context). */ - multiContextRows: Array<{ - id: string - key: string - workspaceId: string | null - context: StorageContext - size: number - }> -} - -interface WorkspaceFileStorageCleanupResult { - filesDeleted: number - filesFailed: number - legacyRows: WorkspaceFileScope['legacyRows'] - multiContextRows: WorkspaceFileScope['multiContextRows'] -} - -/** - * Select every soft-deleted file row that's eligible for permanent removal. - * Returned once and reused for both S3 deletion and DB deletion so the external - * cleanup cannot drift from the row-level cleanup. - */ -async function selectExpiredWorkspaceFiles( - scope: CleanupOwnerScope, - retentionDate: Date, - budgets?: CleanupBudgets -): Promise { - const [legacyRows, multiContextRows] = await Promise.all([ - selectRowsByIdChunks( - scope.kind === 'workspace' ? scope.ids : [], - (chunkIds, chunkLimit) => - cleanupDb - .select({ - id: workspaceFile.id, - key: workspaceFile.key, - workspaceId: workspaceFile.workspaceId, - }) - .from(workspaceFile) - .where( - and( - inArray(workspaceFile.workspaceId, chunkIds), - isNotNull(workspaceFile.deletedAt), - lt(workspaceFile.deletedAt, retentionDate) - ) - ) - .limit(chunkLimit), - { budget: budgets?.legacyFiles } - ), - selectRowsByIdChunks( - scope.ids, - (chunkIds, chunkLimit) => - cleanupDb - .select({ - id: workspaceFiles.id, - key: workspaceFiles.key, - workspaceId: workspaceFiles.workspaceId, - context: workspaceFiles.context, - sizeBytes: workspaceFiles.sizeBytes, - }) - .from(workspaceFiles) - .where( - and( - cleanupOwnerCondition(workspaceFiles, scope, chunkIds), - scope.kind === 'organization' - ? eq(workspaceFiles.context, 'knowledge-base') - : undefined, - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - .limit(chunkLimit), - { budget: budgets?.files } - ), - ]) - - return { - legacyRows, - multiContextRows: multiContextRows.map((r) => ({ - id: r.id, - key: r.key, - workspaceId: r.workspaceId, - context: r.context as StorageContext, - size: getWorkspaceFileSize(r), - })), - } -} - -async function cleanupWorkspaceFileStorage( - scope: WorkspaceFileScope -): Promise { - type Candidate = - | { source: 'legacy'; context: StorageContext; row: WorkspaceFileScope['legacyRows'][number] } - | { - source: 'multiContext' - context: StorageContext - row: WorkspaceFileScope['multiContextRows'][number] - } - - const result: WorkspaceFileStorageCleanupResult = { - filesDeleted: 0, - filesFailed: 0, - legacyRows: [], - multiContextRows: [], - } - if (!isUsingCloudStorage()) { - return { - ...result, - legacyRows: scope.legacyRows, - multiContextRows: scope.multiContextRows, - } - } - - const candidatesByContext = new Map() - const addCandidate = (candidate: Candidate) => { - const bucket = candidatesByContext.get(candidate.context) - if (bucket) bucket.push(candidate) - else candidatesByContext.set(candidate.context, [candidate]) - } - for (const row of scope.legacyRows) { - addCandidate({ source: 'legacy', context: 'workspace', row }) - } - for (const row of scope.multiContextRows) { - addCandidate({ source: 'multiContext', context: row.context, row }) - } - - for (const [context, candidates] of candidatesByContext) { - for (const batch of chunkArray(candidates, DEFAULT_DELETE_CHUNK_SIZE)) { - const deletion = await StorageService.deleteFiles( - batch.map(({ row }) => row.key), - context - ) - const failedKeys = new Set(deletion.failed.map(({ key }) => key)) - result.filesDeleted += batch.filter(({ row }) => !failedKeys.has(row.key)).length - result.filesFailed += deletion.failed.length - - for (const candidate of batch) { - if (failedKeys.has(candidate.row.key)) continue - if (candidate.source === 'legacy') result.legacyRows.push(candidate.row) - else result.multiContextRows.push(candidate.row) - } - for (const { key, error } of deletion.failed) { - logger.error(`Failed to delete storage file ${key} (context: ${context}):`, { error }) - } - } - } - - return result -} - -async function deleteExpiredLegacyWorkspaceFileRows( - rows: WorkspaceFileScope['legacyRows'], - retentionDate: Date, - label: string -): Promise<{ deleted: number; failed: number }> { - const result = { deleted: 0, failed: 0 } - for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) { - try { - const deleted = await cleanupDb - .delete(workspaceFile) - .where( - and( - inArray( - workspaceFile.id, - batch.map(({ id }) => id) - ), - isNotNull(workspaceFile.deletedAt), - lt(workspaceFile.deletedAt, retentionDate) - ) - ) - .returning({ id: workspaceFile.id }) - result.deleted += deleted.length - result.failed += batch.length - deleted.length - } catch (error) { - result.failed += batch.length - logger.error(`[${label}/workspaceFile] Exact-row delete failed`, { error }) - } - } - return result -} - -/** Contexts whose bytes count toward the workspace's billed storage: owned bodies are billed too. */ -const BILLED_FILE_CONTEXTS: readonly StorageContext[] = ['workspace', 'test', 'changelog'] - -async function deleteExpiredUnbilledWorkspaceFileRows( - rows: WorkspaceFileScope['multiContextRows'], - retentionDate: Date, - label: string -): Promise<{ deleted: number; failed: number }> { - const result = { deleted: 0, failed: 0 } - const rowsByContext = new Map() - for (const row of rows) { - if (BILLED_FILE_CONTEXTS.includes(row.context)) continue - const bucket = rowsByContext.get(row.context) - if (bucket) bucket.push(row) - else rowsByContext.set(row.context, [row]) - } - - for (const [context, contextRows] of rowsByContext) { - for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) { - try { - const deleted = await cleanupDb - .delete(workspaceFiles) - .where( - and( - inArray( - workspaceFiles.id, - batch.map(({ id }) => id) - ), - eq(workspaceFiles.context, context), - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - .returning({ id: workspaceFiles.id }) - result.deleted += deleted.length - result.failed += batch.length - deleted.length - } catch (error) { - result.failed += batch.length - logger.error(`[${label}/workspaceFiles] Exact-row ${context} delete failed`, { error }) - } - } - } - return result -} - -async function deleteExpiredBillableWorkspaceFileRows( - rows: WorkspaceFileScope['multiContextRows'], - retentionDate: Date, - label: string -): Promise<{ deleted: number; failed: number }> { - const result = { deleted: 0, failed: 0 } - const rowsByWorkspace = new Map() - for (const row of rows) { - if (!BILLED_FILE_CONTEXTS.includes(row.context)) continue - if (!row.workspaceId) { - result.failed++ - logger.error(`[${label}/workspaceFiles] Billable row has no workspace attribution`, { - fileId: row.id, - }) - continue - } - const bucket = rowsByWorkspace.get(row.workspaceId) - if (bucket) bucket.push(row) - else rowsByWorkspace.set(row.workspaceId, [row]) - } - - for (const [workspaceId, workspaceRows] of rowsByWorkspace) { - for (const batch of chunkArray(workspaceRows, DEFAULT_DELETE_CHUNK_SIZE)) { - try { - const deletedCount = await db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - await lockWorkspaceStorageForMutationInTx(tx, workspaceId) - const billingContext = await resolveStorageBillingContext(workspaceId, tx) - const fileIds = batch.map(({ id }) => id) - await tx - .delete(workflowTest) - .where( - and( - inArray(workflowTest.bodyFileId, fileIds), - isNotNull(workflowTest.deletedAt), - lt(workflowTest.deletedAt, retentionDate) - ) - ) - await tx.delete(changelogRelease).where( - inArray( - changelogRelease.bodyFileId, - tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - inArray(workspaceFiles.id, fileIds), - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - ) - ) - await releaseWorkspaceFileVersionsForPurgeInTx(tx, fileIds, retentionDate) - const deletedRows = await tx - .delete(workspaceFiles) - .where( - and( - inArray(workspaceFiles.id, fileIds), - eq(workspaceFiles.workspaceId, workspaceId), - inArray(workspaceFiles.context, BILLED_FILE_CONTEXTS), - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - .returning({ - id: workspaceFiles.id, - sizeBytes: workspaceFiles.sizeBytes, - }) - const deletedBytes = deletedRows.reduce( - (total, row) => total + getWorkspaceFileSize(row), - 0 - ) - await decrementStorageUsageForBillingContextInTx(tx, billingContext, deletedBytes) - return deletedRows.length - }) - result.deleted += deletedCount - result.failed += batch.length - deletedCount - } catch (error) { - result.failed += batch.length - logger.error(`[${label}/workspaceFiles] Atomic delete and decrement failed`, { - error, - workspaceId, - }) - } - } - } - return result -} - async function hardDeleteKnowledgeBaseDocuments( knowledgeBaseIds: string[], label: string @@ -496,20 +157,7 @@ interface CleanupBatchContext { label: string } -/** - * Applies one re-root, treating the deduplicated name as a HINT rather than a guarantee. - * - * Both name allocators can hand back a name that is already taken: `fileExistsInWorkspace` - * swallows query errors and returns `false`, so `allocateUniqueWorkspaceFileName` fails OPEN, - * and `deduplicateWorkflowName`'s lookups can throw outright. Either way the UPDATE raises - * 23505, and an uncaught 23505 here aborts the batch — precisely the permanent retention stall - * this whole hook exists to prevent. So any failure retries with the row id, which is unique by - * construction and cannot collide. - * - * A failure of that retry is swallowed too: one unfixable row must not stop the other children - * from being made safe. It is logged at error level because the folder's DELETE can then still - * stall on that row via the FK's SET NULL. - */ +/** Retry a colliding name without letting one child permanently stall unrelated folder cleanup. */ async function reRootOne( preferred: () => Promise, withUniqueName: () => Promise, @@ -532,20 +180,7 @@ async function reRootOne( } } -/** - * Re-roots any still-active workflow or workspace file filed under a folder that is about to be - * hard-deleted, giving it a collision-free name first. - * - * The `folder_id` FKs are `ON DELETE SET NULL`, so Postgres already re-roots these rows on its - * own. The problem is the name: both tables carry a partial unique index keyed on - * `coalesce(folder_id, '')`, so an implicit SET NULL can land a row on a name the workspace root - * already holds and abort the whole DELETE with a 23505. `chunkedBatchDelete` counts that as a - * failed batch and stops, and the same poison row re-fails on every later run — folder retention - * would stall permanently for that workspace chunk. Renaming here leaves the SET NULL a no-op. - * - * An active child inside a soft-deleted folder is already an anomaly — the delete cascade - * archives children — so this normally selects nothing, which is why the per-row loop is fine. - */ +/** Surviving workflow children need collision-free root names before their folder FK clears. */ async function reRootActiveFolderChildren( folderIds: string[], retentionDate: Date, @@ -621,52 +256,11 @@ async function reRootActiveFolderChildrenUnguarded( ) } - const files = await cleanupDb - .select({ - id: workspaceFiles.id, - originalName: workspaceFiles.originalName, - workspaceId: workspaceFiles.workspaceId, - }) - .from(workspaceFiles) - .where( - and( - inArray(workspaceFiles.folderId, expiredIds), - isNull(workspaceFiles.deletedAt), - eq(workspaceFiles.context, 'workspace') - ) - ) - - for (const row of files) { - const workspaceId = row.workspaceId - if (!workspaceId) continue - await reRootOne( - async () => { - const originalName = await allocateUniqueWorkspaceFileName( - workspaceId, - row.originalName, - null - ) - await cleanupDb - .update(workspaceFiles) - .set({ folderId: null, originalName }) - .where(eq(workspaceFiles.id, row.id)) - }, - () => - cleanupDb - .update(workspaceFiles) - .set({ folderId: null, originalName: `${row.originalName} (${row.id})` }) - .where(eq(workspaceFiles.id, row.id)), - `workspace file ${row.id}`, - label - ) - } - /** * Subfolders are exposed to exactly the same failure. `folder.parentId` is itself * `ON DELETE SET NULL`, and `folder_workspace_resource_parent_name_active_unique` keys on * `coalesce(parent_id, '')`, so purging a parent re-roots a surviving active child into a - * namespace where its name may already be taken — the identical 23505 stall. Covering only - * workflows and files would leave the class half-closed. + * namespace where its name may already be taken — the identical 23505 stall. Subfolder names therefore need the same collision handling. */ const childFolders = await cleanupDb .select({ @@ -705,10 +299,8 @@ async function reRootActiveFolderChildrenUnguarded( ) } - if (workflows.length > 0 || files.length > 0) { - logger.warn( - `[${label}] Re-rooted ${workflows.length} workflow(s) and ${files.length} file(s) out of folders being purged` - ) + if (workflows.length > 0) { + logger.warn(`[${label}] Re-rooted ${workflows.length} workflow(s) out of folders being purged`) } } @@ -717,19 +309,7 @@ const CLEANUP_TARGETS = [ table: folderTable, softDeleteCol: folderTable.deletedAt, wsCol: folderTable.workspaceId, - /** - * `folder` is shared by all four resource types, every one of which now writes here. - * The predicate is kept (rather than dropped) so a resource type added to the enum - * before its cutover lands is not silently hard-deleted by this pass. One widened - * predicate rather than a target per type: same table, same soft-delete column, same - * workspace scoping — splitting it would only multiply the batched scans. - */ - additionalPredicate: inArray(folderTable.resourceType, [ - 'workflow', - 'file', - 'knowledge_base', - 'table', - ]), + additionalPredicate: inArray(folderTable.resourceType, ['workflow', 'knowledge_base', 'table']), onBatch: (rows: { id: string }[], ctx: CleanupBatchContext) => reRootActiveFolderChildren( rows.map(({ id }) => id), @@ -863,19 +443,8 @@ export async function runCleanupSoftDeletes( const startTime = Date.now() const { workspaceIds, retentionHours, label } = payload const scope = resolveCleanupOwnerScope(payload) - const projectFileBudget = budgets?.files ?? { - remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, - } - const projectFolderBudget = budgets?.folders ?? { - remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, - } - for (const projectId of [...new Set(payload.projectIds ?? [])].sort()) { - if (projectFileBudget.remaining <= 0 && projectFolderBudget.remaining <= 0) break - await cleanupArchivedProjectFiles(projectId, projectFileBudget) - await cleanupArchivedProjectFileFolders(projectId, projectFolderBudget) - } - - if (scope.ids.length === 0) { + const fileOwners = fileRetentionOwners(payload) + if (scope.ids.length === 0 && fileOwners.length === 0) { logger.info(`[${label}] No resource owners to process`) return } @@ -889,7 +458,7 @@ export async function runCleanupSoftDeletes( // external cleanup (chats + S3) AND the DB deletes below — selecting twice // could return different subsets above the LIMIT cap and orphan or // prematurely purge data. - const [doomedWorkflows, fileScope, expiredSoftDeletedChats] = await Promise.all([ + const [doomedWorkflows, fileArchiveCleanup, expiredSoftDeletedChats] = await Promise.all([ selectRowsByIdChunks( workspaceIds, (chunkIds, chunkLimit) => @@ -906,7 +475,12 @@ export async function runCleanupSoftDeletes( .limit(chunkLimit), { budget: budgets?.workflows } ), - selectExpiredWorkspaceFiles(scope, retentionDate, budgets), + beginFileArchiveCleanup(fileOwners, { + cutoff: retentionDate, + plan: payload.plan, + label, + budgets, + }), selectRowsByIdChunks( scope.ids, (chunkIds, chunkLimit) => @@ -946,8 +520,7 @@ export async function runCleanupSoftDeletes( chatCleanup = await prepareChatCleanup([...doomedChatIds], label) } - const fileCleanup = await cleanupWorkspaceFileStorage(fileScope) - if (budgets && fileCleanup.filesFailed) throw new Error('File storage cleanup failed') + const fileCleanup = await fileArchiveCleanup.cleanupStorage() let totalDeleted = 0 @@ -1000,31 +573,7 @@ export async function runCleanupSoftDeletes( } } - const legacyFileResult = await deleteExpiredLegacyWorkspaceFileRows( - fileCleanup.legacyRows, - retentionDate, - label - ) - totalDeleted += legacyFileResult.deleted - - const billableFileResult = await deleteExpiredBillableWorkspaceFileRows( - fileCleanup.multiContextRows, - retentionDate, - label - ) - totalDeleted += billableFileResult.deleted - - const unbilledFileResult = await deleteExpiredUnbilledWorkspaceFileRows( - fileCleanup.multiContextRows, - retentionDate, - label - ) - totalDeleted += unbilledFileResult.deleted - if ( - budgets && - (legacyFileResult.failed || billableFileResult.failed || unbilledFileResult.failed) - ) - throw new Error('File row cleanup failed') + totalDeleted += await fileCleanup.deleteRows() const knowledgeBaseResult = await cleanupExpiredKnowledgeBases( scope, diff --git a/apps/sim/lib/billing/storage/accounting.ts b/apps/sim/lib/billing/storage/accounting.ts new file mode 100644 index 00000000000..5e9b4846f90 --- /dev/null +++ b/apps/sim/lib/billing/storage/accounting.ts @@ -0,0 +1,59 @@ +import { project } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { + type ProjectStorageBillingContext, + resolveProjectStorageBillingContext, + resolveStorageBillingContext, + type StorageBillingContext, +} from '@/lib/billing/storage/context' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' +import type { DbTransaction } from '@/lib/db/types' +import { lockProject, lockWorkspaceProject } from '@/lib/projects/membership' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +interface PreparedFileAccounting { + billing: Billing + mutation: { applyDelta(deltaBytes: number): Promise } +} + +export function prepareFileAccountingInTx( + tx: DbTransaction, + owner: { entityType: 'workspace'; entityId: string } +): Promise> +export function prepareFileAccountingInTx( + tx: DbTransaction, + owner: { entityType: 'project'; entityId: string } +): Promise> +export function prepareFileAccountingInTx( + tx: DbTransaction, + owner: EditableFileOwner +): Promise +/** + * Prepares retained-head accounting in the caller's transaction, after authorization and before + * directory, file, history or cleanup locks. Locks lifecycle, owner row, then payer; multi-owner + * callers must acquire all lifecycle locks first. Resolve from the canonical owner, never actor + * or creator. Apply one signed delta at finalization and notify only after the caller commits. + */ +export async function prepareFileAccountingInTx( + tx: DbTransaction, + owner: EditableFileOwner +): Promise { + let billing: StorageBillingContext | ProjectStorageBillingContext + if (owner.entityType === 'workspace') { + await lockWorkspaceProject(tx, owner.entityId) + billing = await resolveStorageBillingContext(owner.entityId, tx) + } else { + await lockProject(tx, owner.entityId) + const [canonical] = await tx + .select({ ownerId: project.ownerId, organizationId: project.organizationId }) + .from(project) + .where(eq(project.id, owner.entityId)) + .limit(1) + if (!canonical) throw new Error(`Project ${owner.entityId} not found for storage accounting`) + billing = await resolveProjectStorageBillingContext( + { projectId: owner.entityId, ...canonical }, + tx + ) + } + return { billing, mutation: await prepareFileStorageMutationInTx(tx, billing) } +} diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 9e4ad54898e..315bb0a7dd4 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -1,3 +1,4 @@ +export { prepareFileAccountingInTx } from './accounting' export { resolveStorageBillingContext, type StorageBillingContext, diff --git a/apps/sim/lib/billing/storage/payer-transfer.ts b/apps/sim/lib/billing/storage/payer-transfer.ts index 61d004b21d3..ca7bd062535 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.ts @@ -49,7 +49,7 @@ export interface ChangeProjectStoragePayerParams extends ProjectStorageOwnerSnap expectedCurrentOwner: Pick } -interface ChangeProjectStoragePayerResult { +export interface ChangeProjectStoragePayerResult { projectId: string billableBytes: number oldPayer: BillingEntity diff --git a/apps/sim/lib/billing/storage/project-accounting.integration.ts b/apps/sim/lib/billing/storage/project-accounting.integration.ts index 7da500c4b39..175c79bc419 100644 --- a/apps/sim/lib/billing/storage/project-accounting.integration.ts +++ b/apps/sim/lib/billing/storage/project-accounting.integration.ts @@ -11,6 +11,7 @@ import { sql as query } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres, { type Sql } from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' import { type ProjectStorageBillingContext, resolveProjectStorageBillingContext, @@ -21,10 +22,7 @@ import { changeProjectAndWorkspaceStoragePayersInTx, changeProjectStoragePayersInTx, } from '@/lib/billing/storage/payer-transfer' -import { - incrementStorageUsageForBillingContextInTx, - prepareProjectStorageMutationInTx, -} from '@/lib/billing/storage/tracking' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' import { transferWorkspaceProjects } from '@/lib/projects/membership' @@ -176,7 +174,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' plan: 'team_25000', customStorageLimitGB: 2, }) - const prepared = await prepareProjectStorageMutationInTx(tx, organizationContext) + const prepared = await prepareFileStorageMutationInTx(tx, organizationContext) await prepared.applyDelta(40) await tx.execute(query`UPDATE project SET organization_id = NULL WHERE id = 'project-b'`) const personalContext = await resolveProjectStorageBillingContext( @@ -189,7 +187,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' plan: 'pro_4000', customStorageLimitGB: null, }) - const personalPrepared = await prepareProjectStorageMutationInTx(tx, personalContext) + const personalPrepared = await prepareFileStorageMutationInTx(tx, personalContext) await personalPrepared.applyDelta(20) }) const [organizationPayer] = await sql`SELECT storage_used_bytes::integer AS bytes @@ -299,7 +297,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' const releaseWrite = createDeferred() const teardownPid = createDeferred() const write = database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, { + const prepared = await prepareFileStorageMutationInTx(tx, { ...context(), organizationId: null, billingEntity: { type: 'user', id: 'user-a' }, @@ -359,23 +357,30 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' check( 'two Projects and a workspace share one locked quota without committing rejected file rows', async () => { - const workspaceContext: StorageBillingContext = { - ...context('project-a', 60), - workspaceId: 'workspace-a', - } + await sql`INSERT INTO member (id, organization_id, user_id, role) + VALUES ('payer-owner', 'organization-a', 'user-b', 'owner')` + await sql`INSERT INTO subscription (id, plan, reference_id, status, metadata) + VALUES ('payer-plan', 'team', 'organization-a', 'active', ${JSON.stringify({ customStorageLimitGB: 60 / 1024 ** 3 })})` const attempts = await Promise.allSettled([ ...['project-a', 'project-b'].map((projectId) => database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context(projectId, 60)) + const { mutation: prepared } = await prepareFileAccountingInTx(tx, { + entityType: 'project', + entityId: projectId, + }) await tx.execute(query`INSERT INTO workspace_files (id, project_id, context, size_bytes) VALUES (${projectId}, ${projectId}, 'project', 40)`) await prepared.applyDelta(40) }) ), database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: 'workspace-a', + }) await tx.execute(query`INSERT INTO workspace_files (id, workspace_id, context, size_bytes) VALUES ('workspace-file', 'workspace-a', 'workspace', 40)`) - await incrementStorageUsageForBillingContextInTx(tx, workspaceContext, 40) + await mutation.applyDelta(40) }), ]) expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1) @@ -387,15 +392,90 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' await sql`SELECT storage_used_bytes::integer AS bytes FROM organization WHERE id = 'organization-a'` expect(payer.bytes).toBe(40) expect(await sql`SELECT id FROM workspace_files`).toHaveLength(1) + const [workspaceUsage] = + await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace WHERE id = 'workspace-a'` + const [workspaceHeads] = + await sql`SELECT COALESCE(sum(size_bytes), 0)::integer AS bytes FROM workspace_files WHERE workspace_id = 'workspace-a'` + expect(workspaceUsage.bytes).toBe(workspaceHeads.bytes) } ) + for (const entityType of ['workspace', 'project'] as const) { + check( + `${entityType} admits exact limit, zero and shrinking over quota with consistent ledgers`, + async () => { + const entityId = entityType === 'workspace' ? 'workspace-a' : 'project-a' + await sql`INSERT INTO member (id, organization_id, user_id, role) + VALUES ('payer-owner', 'organization-a', 'user-b', 'owner')` + await sql`INSERT INTO subscription (id, plan, reference_id, status, metadata) + VALUES ('payer-plan', 'team', 'organization-a', 'active', ${JSON.stringify({ customStorageLimitGB: 60 / 1024 ** 3 })})` + await database.transaction(async (tx) => { + const { billing, mutation } = await prepareFileAccountingInTx(tx, { + entityType, + entityId, + }) + expect(billing.billingEntity).toEqual({ type: 'organization', id: 'organization-a' }) + expect(await mutation.applyDelta(60)).toBe(60) + }) + await expect( + database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { entityType, entityId }) + await tx.execute( + query`INSERT INTO workspace_files (id, context, size_bytes) VALUES ('rejected', ${entityType}, 1)` + ) + await mutation.applyDelta(1) + }) + ).rejects.toBeInstanceOf(StorageLimitExceededError) + expect(await sql`SELECT id FROM workspace_files`).toEqual([]) + await sql`UPDATE organization SET storage_used_bytes = 100 WHERE id = 'organization-a'` + for (const [delta, expected] of [ + [0, 100], + [-20, 80], + ] as const) { + await database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { entityType, entityId }) + expect(await mutation.applyDelta(delta)).toBe(expected) + }) + } + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM organization WHERE id = 'organization-a'` + ).toEqual([{ bytes: 80 }]) + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace WHERE id = 'workspace-a'` + ).toEqual([{ bytes: entityType === 'workspace' ? 40 : 0 }]) + } + ) + } + + check('rejects a stale workspace payer snapshot without charging either ledger', async () => { + const snapshot: StorageBillingContext = { + workspaceId: 'workspace-a', + billedAccountUserId: 'user-a', + billingEntity: { type: 'organization', id: 'organization-a' }, + plan: 'team', + customStorageLimitGB: 1, + } + await sql`UPDATE workspace SET organization_id = 'organization-b' WHERE id = 'workspace-a'` + await expect( + database.transaction(async (tx) => { + const mutation = await prepareFileStorageMutationInTx(tx, snapshot) + await mutation.applyDelta(20) + }) + ).rejects.toThrow(/payer changed/) + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM organization ORDER BY id` + ).toEqual([{ bytes: 0 }, { bytes: 0 }]) + expect(await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace`).toEqual([ + { bytes: 0 }, + ]) + }) + check( 'rejects stale owner snapshots and invalid deltas before a charge can survive', async () => { await sql`UPDATE project SET owner_id = 'user-b' WHERE id = 'project-a'` await expect( - database.transaction((tx) => prepareProjectStorageMutationInTx(tx, context())) + database.transaction((tx) => prepareFileStorageMutationInTx(tx, context())) ).rejects.toThrow(/changed/) await sql`UPDATE project SET owner_id = 'user-a' WHERE id = 'project-a'` for (const delta of [ @@ -406,14 +486,14 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' ]) { await expect( database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await prepared.applyDelta(delta) }) ).rejects.toThrow(/Invalid/) } await expect( database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await prepared.applyDelta(1) await prepared.applyDelta(1) }) @@ -428,7 +508,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' 'keeps current-head deltas separate from retained history and rolls back failed finalization', async () => { await database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`INSERT INTO workspace_files (id, project_id, workspace_id, context, size_bytes, deleted_at) VALUES ('file', 'project-a', NULL, 'project', 100, NULL)` ) @@ -438,7 +518,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' await prepared.applyDelta(100) }) await database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`UPDATE workspace_files SET size_bytes = 40, deleted_at = now() WHERE id = 'file'` ) @@ -446,7 +526,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' }) await expect( database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await prepared.applyDelta(10) throw new Error('finalization failed') }) @@ -455,7 +535,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' await sql`SELECT storage_used_bytes::integer AS bytes FROM organization WHERE id = 'organization-a'` expect(payer.bytes).toBe(40) await database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute(query`DELETE FROM workspace_files WHERE id = 'file'`) await prepared.applyDelta(-40) }) @@ -534,11 +614,65 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' } ) + check( + 'a workspace payer transfer waits for prepared finalization and moves its committed head once', + async () => { + const written = createDeferred() + const release = createDeferred() + const waiting = createDeferred() + const write = database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: 'workspace-a', + }) + await tx.execute(query`INSERT INTO workspace_files (id, workspace_id, context, size_bytes) + VALUES ('workspace-file', 'workspace-a', 'workspace', 40)`) + await mutation.applyDelta(40) + written.resolve() + await release.promise + }) + await Promise.race([written.promise, write]) + const transfer = database.transaction(async (tx) => { + const [backend] = await tx.execute<{ pid: number }>(query`SELECT pg_backend_pid() AS pid`) + waiting.resolve(backend.pid) + return changeProjectAndWorkspaceStoragePayersInTx(tx, { + projectChanges: [], + workspaceChanges: [ + { + workspaceId: 'workspace-a', + billedAccountUserId: 'user-b', + organizationId: 'organization-b', + expectedCurrentPayer: { + billedAccountUserId: 'user-a', + organizationId: 'organization-a', + }, + }, + ], + }) + }) + try { + await waitForDatabaseLock(await waiting.promise) + } finally { + release.resolve() + } + await Promise.all([write, transfer]) + expect( + await sql`SELECT id, storage_used_bytes::integer AS bytes FROM organization ORDER BY id` + ).toEqual([ + { id: 'organization-a', bytes: 0 }, + { id: 'organization-b', bytes: 40 }, + ]) + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace WHERE id = 'workspace-a'` + ).toEqual([{ bytes: 40 }]) + } + ) + check('reconciliation waits for a Project write and keeps the committed charge', async () => { const changed = createDeferred() const release = createDeferred() const write = database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`INSERT INTO workspace_files (id, project_id, workspace_id, context, size_bytes, deleted_at) VALUES ('file', 'project-a', NULL, 'project', 40, NULL)` ) @@ -575,7 +709,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' const changed = createDeferred() const release = createDeferred() const write = database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`INSERT INTO workspace_files (id, project_id, workspace_id, context, size_bytes, deleted_at) VALUES ('file', 'project-a', NULL, 'project', 40, NULL)` ) diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 17e9dc47708..f0ede77f8fb 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -219,36 +219,41 @@ function assertWorkspaceStorageContext( } /** - * Establishes Project then payer locks before directory/file/version mutation. The returned - * one-shot delta belongs to this transaction; it is finalization admission, not a reservation. - * Project contribution is derived from retained file heads, so only the payer counter changes. + * Locks the asserted owner and payer before directory/file/version mutation. The caller holds + * lifecycle locks and owns the transaction. The one-shot signed delta admits finalization, not + * a reservation. Workspace files update both ledgers; Projects update only their payer ledger. */ -export async function prepareProjectStorageMutationInTx( +export async function prepareFileStorageMutationInTx( tx: DbTransaction, - context: ProjectStorageBillingContext + context: ProjectStorageBillingContext | StorageBillingContext ): Promise<{ applyDelta(deltaBytes: number): Promise }> { - await lockProject(tx, context.projectId) - const [owner] = await tx - .select({ ownerId: project.ownerId, organizationId: project.organizationId }) - .from(project) - .where(eq(project.id, context.projectId)) - .for('update') - .limit(1) - if (!owner) throw new Error(`Project ${context.projectId} not found for storage accounting`) - const billingEntity: BillingEntity = owner.organizationId - ? { type: 'organization', id: owner.organizationId } - : { type: 'user', id: owner.ownerId } - if ( - owner.ownerId !== context.ownerId || - owner.organizationId !== context.organizationId || - billingEntity.type !== context.billingEntity.type || - billingEntity.id !== context.billingEntity.id || - (!owner.organizationId && context.billedAccountUserId !== owner.ownerId) - ) { - throw new Error( - `Storage payer changed for Project ${context.projectId}; resolve a fresh billing context` - ) + if ('workspaceId' in context) { + await mutateWorkspaceStorageUsage(tx, context.workspaceId, 0, 'decrement', undefined, context) + } else { + await lockProject(tx, context.projectId) + const [owner] = await tx + .select({ ownerId: project.ownerId, organizationId: project.organizationId }) + .from(project) + .where(eq(project.id, context.projectId)) + .for('update') + .limit(1) + if (!owner) throw new Error(`Project ${context.projectId} not found for storage accounting`) + const billingEntity: BillingEntity = owner.organizationId + ? { type: 'organization', id: owner.organizationId } + : { type: 'user', id: owner.ownerId } + if ( + owner.ownerId !== context.ownerId || + owner.organizationId !== context.organizationId || + billingEntity.type !== context.billingEntity.type || + billingEntity.id !== context.billingEntity.id || + (!owner.organizationId && context.billedAccountUserId !== owner.ownerId) + ) { + throw new Error( + `Storage payer changed for Project ${context.projectId}; resolve a fresh billing context` + ) + } } + const billingEntity = context.billingEntity const currentUsage = await lockStorageUsageForMutation(tx, billingEntity) if (!Number.isSafeInteger(currentUsage) || currentUsage < 0) { throw new Error(`Invalid storage usage for payer ${getPayerKey(billingEntity)}`) @@ -259,18 +264,29 @@ export async function prepareProjectStorageMutationInTx( let applied = false return { async applyDelta(deltaBytes) { - if (applied) throw new Error('Project storage delta was already applied') - if (!Number.isSafeInteger(deltaBytes)) throw new Error('Invalid Project storage delta') + if (applied) throw new Error('File storage delta was already applied') + if (!Number.isSafeInteger(deltaBytes)) throw new Error('Invalid file storage delta') applied = true const latestUsage = await lockStorageUsageForMutation(tx, billingEntity) const nextUsage = Math.max(0, latestUsage + deltaBytes) - if (!Number.isSafeInteger(nextUsage)) throw new Error('Invalid Project storage total') + if (!Number.isSafeInteger(nextUsage)) throw new Error('Invalid file storage total') if (deltaBytes > 0 && limit !== undefined && nextUsage > limit) { throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(nextUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(limit / 1024 ** 3).toFixed(0)}GB` ) } if (deltaBytes === 0) return latestUsage + if ('workspaceId' in context) { + const result = await mutateWorkspaceStorageUsage( + tx, + context.workspaceId, + Math.abs(deltaBytes), + deltaBytes > 0 ? 'increment' : 'decrement', + limit, + context + ) + return result.updatedUsage + } if (deltaBytes < 0 && latestUsage < -deltaBytes) { logger.error('Clamping Project storage payer ledger underflow', { projectId: context.projectId, diff --git a/apps/sim/lib/file-retention/folders.ts b/apps/sim/lib/file-retention/folders.ts new file mode 100644 index 00000000000..ea6b9c6576f --- /dev/null +++ b/apps/sim/lib/file-retention/folders.ts @@ -0,0 +1,157 @@ +import { dbFor } from '@sim/db' +import { folder, workspaceFiles } from '@sim/db/schema' +import { and, asc, eq, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm' +import { + consumeRowBudget, + DEFAULT_MAX_BATCHES_PER_TABLE, + type RowBudget, +} from '@/lib/cleanup/batch-delete' +import { generateRestoreName } from '@/lib/core/utils/restore-name' +import type { DbTransaction } from '@/lib/db/types' +import { FILES_PER_QUERY } from '@/lib/file-retention/versions' +import { deduplicateFolderNameInScope } from '@/lib/folders/naming' +import { lockWorkspaceProject } from '@/lib/projects/membership' +import { workspaceFileNameFolderCondition } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { lockFileDirectories } from '@/lib/workspace-files/locks' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +const cleanupDb = dbFor('cleanup') + +/** Recheck expiry and keep child re-rooting atomic with the parent deletion. */ +export async function cleanupExpiredFileFolderInTx( + tx: DbTransaction, + owner: EditableFileOwner, + cutoff: Date, + budget?: RowBudget +): Promise { + const folderScope = fileFolderOwnerCondition(owner) + await lockFileDirectories(tx, [owner]) + const [expired] = await tx + .select({ id: folder.id }) + .from(folder) + .where(and(folderScope, isNotNull(folder.deletedAt), lt(folder.deletedAt, cutoff))) + .orderBy(asc(folder.id)) + .limit(1) + .for('update') + if (!expired) return 0 + consumeRowBudget(budget, 1) + for (;;) { + const children = await tx + .select({ id: folder.id, name: folder.name }) + .from(folder) + .where(and(folderScope, eq(folder.parentId, expired.id), isNull(folder.deletedAt))) + .orderBy(asc(folder.id)) + .limit(FILES_PER_QUERY) + .for('update') + if (!children.length) break + for (const child of children) { + const name = await deduplicateFolderNameInScope(tx, folderScope, null, child.name) + await tx + .update(folder) + .set({ parentId: null, name, updatedAt: new Date() }) + .where(and(folderScope, eq(folder.id, child.id))) + } + } + for (;;) { + const children = await tx + .select({ id: workspaceFiles.id, name: workspaceFiles.originalName }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + eq(workspaceFiles.folderId, expired.id), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .limit(FILES_PER_QUERY) + .for('update') + if (!children.length) break + const candidateNames = new Set(children.map(({ name }) => name)) + const rootFiles = await tx + .select({ name: workspaceFiles.originalName }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + workspaceFileNameFolderCondition(null), + isNull(workspaceFiles.deletedAt), + inArray(workspaceFiles.originalName, [...candidateNames]) + ) + ) + const rootNames = new Set(rootFiles.map(({ name }) => name)) + const renamed: { id: string; name: string }[] = [] + for (const child of children) { + const name = await generateRestoreName( + child.name, + async (candidate) => { + if (rootNames.has(candidate)) return true + if (candidateNames.has(candidate)) return false + const [existing] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + workspaceFileNameFolderCondition(null), + isNull(workspaceFiles.deletedAt), + eq(workspaceFiles.originalName, candidate) + ) + ) + .limit(1) + return Boolean(existing) + }, + { hasExtension: true } + ) + rootNames.add(name) + renamed.push({ id: child.id, name }) + } + await tx + .update(workspaceFiles) + .set({ + folderId: null, + originalName: sql`CASE ${workspaceFiles.id} ${sql.join( + renamed.map(({ id, name }) => sql`WHEN ${id} THEN ${name}`), + sql` ` + )} END`, + updatedAt: new Date(), + }) + .where( + and( + fileOwnerCondition(owner), + inArray( + workspaceFiles.id, + renamed.map(({ id }) => id) + ) + ) + ) + } + await tx.delete(folder).where(and(folderScope, eq(folder.id, expired.id))) + return 1 +} + +/** Workspace file folders share the directory transaction used by Project folders. */ +export async function cleanupArchivedWorkspaceFileFolders( + workspaceIds: string[], + cutoff: Date, + budget?: RowBudget +): Promise { + let total = 0 + for (const workspaceId of workspaceIds) { + for (let batch = 0; batch < DEFAULT_MAX_BATCHES_PER_TABLE && budget?.remaining !== 0; batch++) { + const removed = await cleanupDb.transaction(async (tx) => { + await lockWorkspaceProject(tx, workspaceId) + return cleanupExpiredFileFolderInTx( + tx, + { entityType: 'workspace', entityId: workspaceId }, + cutoff, + budget + ) + }) + total += removed + if (!removed) break + } + } + return total +} diff --git a/apps/sim/lib/file-retention/index.ts b/apps/sim/lib/file-retention/index.ts new file mode 100644 index 00000000000..d3bac9b0f93 --- /dev/null +++ b/apps/sim/lib/file-retention/index.ts @@ -0,0 +1 @@ +export { beginFileArchiveCleanup, cleanupFileVersions, fileRetentionOwners } from './service' diff --git a/apps/sim/lib/file-retention/legacy-archive.ts b/apps/sim/lib/file-retention/legacy-archive.ts new file mode 100644 index 00000000000..dc9dd75c426 --- /dev/null +++ b/apps/sim/lib/file-retention/legacy-archive.ts @@ -0,0 +1,385 @@ +import { db, dbFor } from '@sim/db' +import { changelogRelease, workflowTest, workspaceFile, workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { chunkArray } from '@sim/utils/helpers' +import { and, eq, inArray, isNotNull, lt } from 'drizzle-orm' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' +import { DEFAULT_DELETE_CHUNK_SIZE, selectRowsByIdChunks } from '@/lib/cleanup/batch-delete' +import type { CleanupBudgets } from '@/lib/cleanup/limits' +import { type CleanupOwnerScope, cleanupOwnerCondition } from '@/lib/cleanup/resource-scope' +import { cleanupArchivedWorkspaceFileFolders } from '@/lib/file-retention/folders' +import type { FileArchiveCleanup, FileRetentionOptions } from '@/lib/file-retention/types' +import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { releaseWorkspaceFileVersionsForPurgeInTx } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' + +const logger = createLogger('FileArchiveCleanup') +/** Billing deletion retains the default pool; selection and unbilled cleanup use the cleanup pool. */ +const cleanupDb = dbFor('cleanup') +const BILLED_FILE_CONTEXTS: readonly StorageContext[] = ['workspace', 'test', 'changelog'] + +interface WorkspaceFileScope { + /** Rows from `workspace_file` (singular, legacy workspace-context only). */ + legacyRows: Array<{ id: string; key: string; workspaceId: string }> + /** Rows from `workspace_files` (plural, multi-context). */ + multiContextRows: Array<{ + id: string + key: string + workspaceId: string | null + context: StorageContext + size: number + }> +} + +interface WorkspaceFileStorageCleanupResult { + filesDeleted: number + filesFailed: number + legacyRows: WorkspaceFileScope['legacyRows'] + multiContextRows: WorkspaceFileScope['multiContextRows'] +} + +/** + * Select every soft-deleted file row that's eligible for permanent removal. + * Workspace objects are released through the purge transaction; other legacy + * contexts retain the selected rows for their storage-first cleanup. + */ +async function selectExpiredWorkspaceFiles( + scope: CleanupOwnerScope, + retentionDate: Date, + budgets?: CleanupBudgets +): Promise { + const [legacyRows, multiContextRows] = await Promise.all([ + selectRowsByIdChunks( + scope.kind === 'workspace' ? scope.ids : [], + (chunkIds, chunkLimit) => + cleanupDb + .select({ + id: workspaceFile.id, + key: workspaceFile.key, + workspaceId: workspaceFile.workspaceId, + }) + .from(workspaceFile) + .where( + and( + inArray(workspaceFile.workspaceId, chunkIds), + isNotNull(workspaceFile.deletedAt), + lt(workspaceFile.deletedAt, retentionDate) + ) + ) + .limit(chunkLimit), + { budget: budgets?.legacyFiles } + ), + selectRowsByIdChunks( + scope.ids, + (chunkIds, chunkLimit) => + cleanupDb + .select({ + id: workspaceFiles.id, + key: workspaceFiles.key, + workspaceId: workspaceFiles.workspaceId, + context: workspaceFiles.context, + sizeBytes: workspaceFiles.sizeBytes, + }) + .from(workspaceFiles) + .where( + and( + cleanupOwnerCondition(workspaceFiles, scope, chunkIds), + scope.kind === 'organization' + ? eq(workspaceFiles.context, 'knowledge-base') + : undefined, + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + .limit(chunkLimit), + { budget: budgets?.files } + ), + ]) + + return { + legacyRows, + multiContextRows: multiContextRows.map((r) => ({ + id: r.id, + key: r.key, + workspaceId: r.workspaceId, + context: r.context as StorageContext, + size: getWorkspaceFileSize(r), + })), + } +} + +async function cleanupWorkspaceFileStorage( + scope: WorkspaceFileScope +): Promise { + const result: WorkspaceFileStorageCleanupResult = { + filesDeleted: 0, + filesFailed: 0, + legacyRows: [], + multiContextRows: [], + } + if (!isUsingCloudStorage()) { + return { + ...result, + legacyRows: scope.legacyRows, + multiContextRows: scope.multiContextRows, + } + } + + const candidatesByContext = new Map() + result.legacyRows = scope.legacyRows + for (const row of scope.multiContextRows) { + if (BILLED_FILE_CONTEXTS.includes(row.context)) { + result.multiContextRows.push(row) + continue + } + const bucket = candidatesByContext.get(row.context) + if (bucket) bucket.push(row) + else candidatesByContext.set(row.context, [row]) + } + + for (const [context, candidates] of candidatesByContext) { + for (const batch of chunkArray(candidates, DEFAULT_DELETE_CHUNK_SIZE)) { + const deletion = await StorageService.deleteFiles( + batch.map((row) => row.key), + context + ) + const failedKeys = new Set(deletion.failed.map(({ key }) => key)) + result.filesDeleted += batch.filter((row) => !failedKeys.has(row.key)).length + result.filesFailed += deletion.failed.length + + for (const row of batch) { + if (!failedKeys.has(row.key)) result.multiContextRows.push(row) + } + for (const { key, error } of deletion.failed) { + logger.error(`Failed to delete storage file ${key} (context: ${context}):`, { error }) + } + } + } + + return result +} + +async function deleteExpiredLegacyWorkspaceFileRows( + rows: WorkspaceFileScope['legacyRows'], + retentionDate: Date, + label: string +): Promise<{ deleted: number; failed: number }> { + const result = { deleted: 0, failed: 0 } + for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await cleanupDb.transaction(async (tx) => { + const rows = await tx + .delete(workspaceFile) + .where( + and( + inArray( + workspaceFile.id, + batch.map(({ id }) => id) + ), + isNotNull(workspaceFile.deletedAt), + lt(workspaceFile.deletedAt, retentionDate) + ) + ) + .returning({ id: workspaceFile.id, key: workspaceFile.key }) + await enqueueWorkspaceFileStorageCleanups( + tx, + rows.map(({ key }) => key) + ) + return rows + }) + result.deleted += deleted.length + result.failed += batch.length - deleted.length + } catch (error) { + result.failed += batch.length + logger.error(`[${label}/workspaceFile] Exact-row delete failed`, { error }) + } + } + return result +} + +async function deleteExpiredUnbilledWorkspaceFileRows( + rows: WorkspaceFileScope['multiContextRows'], + retentionDate: Date, + label: string +): Promise<{ deleted: number; failed: number }> { + const result = { deleted: 0, failed: 0 } + const rowsByContext = new Map() + for (const row of rows) { + if (BILLED_FILE_CONTEXTS.includes(row.context)) continue + const bucket = rowsByContext.get(row.context) + if (bucket) bucket.push(row) + else rowsByContext.set(row.context, [row]) + } + + for (const [context, contextRows] of rowsByContext) { + for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await cleanupDb + .delete(workspaceFiles) + .where( + and( + inArray( + workspaceFiles.id, + batch.map(({ id }) => id) + ), + eq(workspaceFiles.context, context), + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + .returning({ id: workspaceFiles.id }) + result.deleted += deleted.length + result.failed += batch.length - deleted.length + } catch (error) { + result.failed += batch.length + logger.error(`[${label}/workspaceFiles] Exact-row ${context} delete failed`, { error }) + } + } + } + return result +} + +async function deleteExpiredBillableWorkspaceFileRows( + rows: WorkspaceFileScope['multiContextRows'], + retentionDate: Date, + label: string +): Promise<{ deleted: number; failed: number }> { + const result = { deleted: 0, failed: 0 } + const rowsByWorkspace = new Map() + for (const row of rows) { + if (!BILLED_FILE_CONTEXTS.includes(row.context)) continue + if (!row.workspaceId) { + result.failed++ + logger.error(`[${label}/workspaceFiles] Billable row has no workspace attribution`, { + fileId: row.id, + }) + continue + } + const bucket = rowsByWorkspace.get(row.workspaceId) + if (bucket) bucket.push(row) + else rowsByWorkspace.set(row.workspaceId, [row]) + } + + for (const [workspaceId, workspaceRows] of rowsByWorkspace) { + for (const batch of chunkArray(workspaceRows, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deletedCount = await db.transaction(async (tx) => { + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) + const fileIds = batch.map(({ id }) => id) + await tx + .delete(workflowTest) + .where( + and( + inArray(workflowTest.bodyFileId, fileIds), + isNotNull(workflowTest.deletedAt), + lt(workflowTest.deletedAt, retentionDate) + ) + ) + await tx.delete(changelogRelease).where( + inArray( + changelogRelease.bodyFileId, + tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.id, fileIds), + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + ) + ) + await releaseWorkspaceFileVersionsForPurgeInTx( + tx, + batch.map(({ id }) => id), + retentionDate + ) + const deletedRows = await tx + .delete(workspaceFiles) + .where( + and( + inArray( + workspaceFiles.id, + batch.map(({ id }) => id) + ), + eq(workspaceFiles.workspaceId, workspaceId), + inArray(workspaceFiles.context, BILLED_FILE_CONTEXTS), + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + .returning({ + id: workspaceFiles.id, + key: workspaceFiles.key, + sizeBytes: workspaceFiles.sizeBytes, + }) + await enqueueWorkspaceFileStorageCleanups( + tx, + deletedRows.map(({ key }) => key) + ) + const deletedBytes = deletedRows.reduce( + (total, row) => total + getWorkspaceFileSize(row), + 0 + ) + await accounting.mutation.applyDelta(-deletedBytes) + return deletedRows.length + }) + result.deleted += deletedCount + result.failed += batch.length - deletedCount + } catch (error) { + result.failed += batch.length + logger.error(`[${label}/workspaceFiles] Atomic delete and decrement failed`, { + error, + workspaceId, + }) + } + } + } + return result +} + +/** Preserve legacy workspace/multi-context cleanup while selecting each owner batch once. */ +export async function prepareLegacyFileArchiveCleanup( + scope: CleanupOwnerScope, + { cutoff, budgets, label }: FileRetentionOptions +): Promise { + const selected = await selectExpiredWorkspaceFiles(scope, cutoff, budgets) + return { + async cleanupStorage() { + const storage = await cleanupWorkspaceFileStorage(selected) + if (budgets && storage.filesFailed) throw new Error('File storage cleanup failed') + return { + filesDeleted: storage.filesDeleted, + async deleteRows() { + const legacy = await deleteExpiredLegacyWorkspaceFileRows( + storage.legacyRows, + cutoff, + label + ) + const billable = await deleteExpiredBillableWorkspaceFileRows( + storage.multiContextRows, + cutoff, + label + ) + const unbilled = await deleteExpiredUnbilledWorkspaceFileRows( + storage.multiContextRows, + cutoff, + label + ) + if (budgets && (legacy.failed || billable.failed || unbilled.failed)) + throw new Error('File row cleanup failed') + const foldersDeleted = + scope.kind === 'workspace' + ? await cleanupArchivedWorkspaceFileFolders(scope.ids, cutoff, budgets?.folders) + : 0 + return legacy.deleted + billable.deleted + unbilled.deleted + foldersDeleted + }, + } + }, + } +} diff --git a/apps/sim/lib/file-retention/service.ts b/apps/sim/lib/file-retention/service.ts new file mode 100644 index 00000000000..51225fb6d72 --- /dev/null +++ b/apps/sim/lib/file-retention/service.ts @@ -0,0 +1,147 @@ +import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher' +import { + DEFAULT_DELETE_CHUNK_SIZE, + DEFAULT_MAX_BATCHES_PER_TABLE, +} from '@/lib/cleanup/batch-delete' +import { prepareLegacyFileArchiveCleanup } from '@/lib/file-retention/legacy-archive' +import type { + FileArchiveCleanup, + FileArchiveDeletion, + FileRetentionOptions, + FileVersionCleanupResult, +} from '@/lib/file-retention/types' +import { cleanupWorkspaceFileVersions } from '@/lib/file-retention/workspace-versions' +import { + cleanupArchivedProjectFileFolders, + cleanupArchivedProjectFiles, + cleanupProjectFileVersions, +} from '@/lib/projects/files/retention' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { FileOwner } from '@/lib/workspace-files/ownership' + +interface RetentionAdapter { + versions?( + ids: string[], + options: FileRetentionOptions, + limit: number + ): Promise + archive(ids: string[], options: FileRetentionOptions): Promise +} + +const OWNER_ADAPTERS: FileOwnerAdapters = { + workspace: { + versions: cleanupWorkspaceFileVersions, + archive: (ids, options) => prepareLegacyFileArchiveCleanup({ kind: 'workspace', ids }, options), + }, + organization: { + archive: (ids, options) => + prepareLegacyFileArchiveCleanup({ kind: 'organization', ids }, options), + }, + project: { + async versions(ids, _options, limit) { + let deleted = 0 + for (const id of ids) { + if (deleted >= limit) break + deleted += await cleanupProjectFileVersions(id, limit - deleted) + } + return { deleted, attempted: deleted } + }, + async archive(ids, { budgets }) { + const files = budgets?.files ?? { + remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, + } + const folders = budgets?.folders ?? { + remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, + } + let deleted = 0 + for (const id of ids) { + if (files.remaining <= 0 && folders.remaining <= 0) break + deleted += await cleanupArchivedProjectFiles(id, files) + deleted += await cleanupArchivedProjectFileFolders(id, folders) + } + return { + cleanupStorage: async () => ({ filesDeleted: 0, deleteRows: async () => deleted }), + } + }, + }, +} + +/** Keep queued payloads compatible while file operations consume one owner interface. */ +export function fileRetentionOwners( + payload: Pick +): FileOwner[] { + return [ + ...(payload.projectIds ?? []).map( + (entityId): FileOwner => ({ entityType: 'project', entityId }) + ), + ...payload.workspaceIds.map((entityId): FileOwner => ({ entityType: 'workspace', entityId })), + ...(payload.organizationIds ?? []).map( + (entityId): FileOwner => ({ entityType: 'organization', entityId }) + ), + ] +} + +function ownerBatches(owners: FileOwner[], operation: 'versions' | 'archive') { + const batches = new Map< + FileOwner['entityType'], + { adapter: RetentionAdapter; ids: Set } + >() + for (const owner of owners) { + const adapter = requireFileOwnerAdapter(OWNER_ADAPTERS, owner) + if (!adapter[operation]) + throw new Error(`File ${operation} retention does not support this owner`) + const batch = batches.get(owner.entityType) + if (batch) batch.ids.add(owner.entityId) + else batches.set(owner.entityType, { adapter, ids: new Set([owner.entityId]) }) + } + return [...batches.entries()].map(([type, { adapter, ids }]) => ({ + adapter, + ids: type === 'project' ? [...ids].sort() : [...ids], + })) +} + +/** Validate every owner before destructive work and share one attempt cap across batches. */ +export async function cleanupFileVersions( + owners: FileOwner[], + options: FileRetentionOptions, + limit: number +): Promise { + const batches = ownerBatches(owners, 'versions') + let deleted = 0 + let attempted = 0 + for (const { adapter, ids } of batches) { + if (attempted >= limit) break + if (!adapter.versions) throw new Error('File version retention is unavailable') + const result = await adapter.versions(ids, options, limit - attempted) + deleted += result.deleted + attempted += result.attempted + } + return deleted +} + +/** Begin owner cleanup; Project commits immediately, while legacy storage and row phases stay ordered. */ +export async function beginFileArchiveCleanup( + owners: FileOwner[], + options: FileRetentionOptions +): Promise { + const batches = ownerBatches(owners, 'archive') + const cleanups: FileArchiveCleanup[] = [] + for (const { adapter, ids } of batches) cleanups.push(await adapter.archive(ids, options)) + return { + async cleanupStorage() { + const deletions: FileArchiveDeletion[] = [] + for (const cleanup of cleanups) deletions.push(await cleanup.cleanupStorage()) + return { + filesDeleted: deletions.reduce((sum, deletion) => sum + deletion.filesDeleted, 0), + async deleteRows() { + let deleted = 0 + for (const deletion of deletions) deleted += await deletion.deleteRows() + return deleted + }, + } + }, + } +} diff --git a/apps/sim/lib/file-retention/types.ts b/apps/sim/lib/file-retention/types.ts new file mode 100644 index 00000000000..2ac368c3007 --- /dev/null +++ b/apps/sim/lib/file-retention/types.ts @@ -0,0 +1,24 @@ +import type { PlanCategory } from '@/lib/billing/plan-helpers' +import type { CleanupBudgets } from '@/lib/cleanup/limits' + +export interface FileRetentionOptions { + plan: PlanCategory + cutoff: Date + label: string + budgets?: CleanupBudgets +} + +export interface FileVersionCleanupResult { + deleted: number + attempted: number +} + +/** Exact selected rows survive the storage phase; failed objects never enter row deletion. */ +export interface FileArchiveCleanup { + cleanupStorage(): Promise +} + +export interface FileArchiveDeletion { + filesDeleted: number + deleteRows(): Promise +} diff --git a/apps/sim/lib/file-retention/versions.ts b/apps/sim/lib/file-retention/versions.ts new file mode 100644 index 00000000000..b9c72ace8a7 --- /dev/null +++ b/apps/sim/lib/file-retention/versions.ts @@ -0,0 +1,72 @@ +import type { db } from '@sim/db' +import { workspaceFileVersion } from '@sim/db/schema' +import { and, gt, inArray, isNotNull, lt, or, sql } from 'drizzle-orm' +import type { DbTransaction } from '@/lib/db/types' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' + +/** Always retain the current head and its newest nine predecessors. */ +export const KEEP_SUPERSEDED = 9 +export const FILES_PER_QUERY = 500 + +/** Rank each file independently; expiry never removes its protected recent history. */ +export function selectExpiredFileVersions( + executor: Pick, + fileIds: string[], + cutoff: Date, + maxSuperseded: number, + limit: number +) { + const ranked = executor + .select({ + id: workspaceFileVersion.id, + supersededAt: workspaceFileVersion.supersededAt, + rank: sql`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as( + 'rank' + ), + }) + .from(workspaceFileVersion) + .where( + and( + inArray(workspaceFileVersion.fileId, fileIds), + isNotNull(workspaceFileVersion.supersededAt) + ) + ) + .as('ranked') + return executor + .select({ id: ranked.id }) + .from(ranked) + .where( + and( + gt(ranked.rank, KEEP_SUPERSEDED), + or(lt(ranked.supersededAt, cutoff), gt(ranked.rank, maxSuperseded)) + ) + ) + .limit(limit) +} + +/** Release history and its storage-cleanup intent in the caller's existing transaction. */ +export async function releaseExpiredFileVersions( + tx: DbTransaction, + rows: Array<{ id: string }>, + context: 'workspace' | 'project' +): Promise { + if (!rows.length) return 0 + const removed = await tx + .delete(workspaceFileVersion) + .where( + and( + inArray( + workspaceFileVersion.id, + rows.map(({ id }) => id) + ), + isNotNull(workspaceFileVersion.supersededAt) + ) + ) + .returning({ key: workspaceFileVersion.key }) + await enqueueWorkspaceFileStorageCleanups( + tx, + removed.map(({ key }) => key), + context === 'workspace' ? undefined : context + ) + return removed.length +} diff --git a/apps/sim/lib/file-retention/workspace-versions.ts b/apps/sim/lib/file-retention/workspace-versions.ts new file mode 100644 index 00000000000..9b801531c88 --- /dev/null +++ b/apps/sim/lib/file-retention/workspace-versions.ts @@ -0,0 +1,100 @@ +import { dbFor } from '@sim/db' +import { workspaceFileVersion } from '@sim/db/schema' +import { chunkArray } from '@sim/utils/helpers' +import { and, asc, count, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm' +import { + DEFAULT_DELETE_CHUNK_SIZE, + DEFAULT_MAX_BATCHES_PER_TABLE, + DEFAULT_WORKSPACE_CHUNK_SIZE, +} from '@/lib/cleanup/batch-delete' +import type { FileRetentionOptions, FileVersionCleanupResult } from '@/lib/file-retention/types' +import { + FILES_PER_QUERY, + KEEP_SUPERSEDED, + releaseExpiredFileVersions, + selectExpiredFileVersions, +} from '@/lib/file-retention/versions' +import { MAX_SUPERSEDED_FILE_VERSIONS } from '@/lib/uploads/contexts/workspace/workspace-file-versions' + +const cleanupDb = dbFor('cleanup') +const FREE_MAX_SUPERSEDED_VERSIONS = 99 + +async function selectCandidateFileIds( + workspaceIds: string[], + cutoff: Date, + maxSuperseded: number, + afterId: string +): Promise { + const rows = await cleanupDb + .select({ fileId: workspaceFileVersion.fileId }) + .from(workspaceFileVersion) + .where( + and( + inArray(workspaceFileVersion.workspaceId, workspaceIds), + isNotNull(workspaceFileVersion.supersededAt), + gt(workspaceFileVersion.fileId, afterId) + ) + ) + .groupBy(workspaceFileVersion.fileId) + .having( + and( + gt(count(), KEEP_SUPERSEDED), + or( + lt( + min(workspaceFileVersion.supersededAt), + sql.param(cutoff, workspaceFileVersion.supersededAt) + ), + gt(count(), maxSuperseded) + ) + ) + ) + .orderBy(asc(workspaceFileVersion.fileId)) + .limit(FILES_PER_QUERY) + return rows.map((row) => row.fileId) +} + +/** Preserve workspace batching and the queued workspace retention policy. */ +export async function cleanupWorkspaceFileVersions( + workspaceIds: string[], + { cutoff, plan }: FileRetentionOptions, + limit: number +): Promise { + const maxSuperseded = + plan === 'free' ? FREE_MAX_SUPERSEDED_VERSIONS : MAX_SUPERSEDED_FILE_VERSIONS + let deleted = 0 + let attempted = 0 + for (const group of chunkArray(workspaceIds, DEFAULT_WORKSPACE_CHUNK_SIZE)) { + if (attempted >= limit) break + let batches = 0 + let afterId = '' + while (batches < DEFAULT_MAX_BATCHES_PER_TABLE && attempted < limit) { + const fileIds = await selectCandidateFileIds(group, cutoff, maxSuperseded, afterId) + if (fileIds.length === 0) break + afterId = fileIds[fileIds.length - 1] + let exhausted = false + while (!exhausted && batches < DEFAULT_MAX_BATCHES_PER_TABLE && attempted < limit) { + batches++ + const batchSize = Math.min(DEFAULT_DELETE_CHUNK_SIZE, limit - attempted) + const expired = await selectExpiredFileVersions( + cleanupDb, + fileIds, + cutoff, + maxSuperseded, + batchSize + ) + attempted += expired.length + const removed = + expired.length > 0 + ? await cleanupDb.transaction((tx) => + releaseExpiredFileVersions(tx, expired, 'workspace') + ) + : 0 + deleted += removed + exhausted = expired.length < batchSize || removed === 0 + } + if (!exhausted) break + } + } + + return { deleted, attempted } +} 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 99e7d4c3c09..d5e4f7355b7 100644 --- a/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts @@ -32,7 +32,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/context' -import { prepareProjectStorageMutationInTx } from '@/lib/billing/storage/tracking' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { processOutboxEventById } from '@/lib/core/outbox/service' import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' @@ -315,7 +315,7 @@ describe('Private Project teardown and durable object cleanup', () => { { projectId: f.projectId, ownerId: f.userId, organizationId: null }, tx ) - const accounting = await prepareProjectStorageMutationInTx(tx, context) + const accounting = await prepareFileStorageMutationInTx(tx, context) await tx .update(workspaceFiles) .set({ sizeBytes: 10 }) diff --git a/apps/sim/lib/projects/files/__integration__/retention.integration.ts b/apps/sim/lib/projects/files/__integration__/retention.integration.ts index b8dc25bd9c4..ea4947e9ccb 100644 --- a/apps/sim/lib/projects/files/__integration__/retention.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/retention.integration.ts @@ -11,6 +11,7 @@ import { user, userStats, workspace, + workspaceFile, workspaceFiles, workspaceFileVersion, } from '@sim/db/schema' @@ -18,6 +19,7 @@ import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { createDeferred } from '@sim/testing/helpers/deferred' import { envFlagsMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -26,13 +28,18 @@ import postgres from 'postgres' 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 { authorizeProject } from '@/lib/projects/application/authorization' import { projectOperations } from '@/lib/projects/application/operations' +import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { deleteFile, downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import type { FileOwner } from '@/lib/workspace-files/ownership' import { runCleanupFileVersions } from '@/background/cleanup-file-versions' import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes' @@ -45,6 +52,7 @@ const fixtures: { workspaceId: string projectId: string }[] = [] +const storedKeys: string[] = [] const HOUR = 60 * 60 * 1000 const control = postgres(readTestDatabaseUrl(), { max: 2, onnotice: () => undefined }) @@ -67,6 +75,7 @@ function check(name: string, run: () => Promise) { } beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) setEnvFlags({ isBillingEnabled: true, isDataRetentionEnabled: true }) }) @@ -206,19 +215,108 @@ async function versions(fileId: string) { .orderBy(workspaceFileVersion.version) } -async function cleanupEvents(projectId: string) { +async function cleanupEvents(entityId: string, kind = 'project') { return db - .select({ payload: outboxEvent.payload }) + .select({ id: outboxEvent.id, payload: outboxEvent.payload }) .from(outboxEvent) .where( and( eq(outboxEvent.eventType, 'workspace-file.storage.cleanup'), - sql`${outboxEvent.payload}->>'key' LIKE ${`project/${projectId}/%`}` + sql`${outboxEvent.payload}->>'key' LIKE ${`${kind}/${entityId}/%`}` ) ) } describe('Project file retention follows the current payer in PostgreSQL', () => { + check( + 'unsupported file owners reject the whole batch before any history or archive deletion', + async () => { + const f = await fixture() + const expired = await seedFile(f, 40 * 24, true) + const owners: FileOwner[] = [ + { entityType: 'project', entityId: f.projectId }, + { entityType: 'user', entityId: f.ownerId }, + ] + const options = { plan: 'free' as const, cutoff: new Date(), label: 'invalid-owner-batch' } + await expect(cleanupFileVersions(owners, options, 10)).rejects.toThrow( + 'File owner is unavailable' + ) + await expect(beginFileArchiveCleanup(owners, options)).rejects.toThrow( + 'File owner is unavailable' + ) + expect(await versions(expired.fileId)).toHaveLength(12) + expect( + await db + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, expired.fileId)) + ).toEqual([{ id: expired.fileId }]) + expect(await cleanupEvents(f.projectId)).toEqual([]) + const [usage] = await db + .select({ bytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, f.ownerId)) + expect(usage.bytes).toBe(15) + } + ) + + check( + 'a mixed queued batch retains each owner policy and leaves unselected workspace history alone', + async () => { + const f = await fixture('pro') + const other = await fixture() + const projectFile = await seedFile(f, 60 * 24) + const workspaceFile = await seedFile(f, 60 * 24, false, 'workspace') + const unrelated = await seedFile(other, 60 * 24, false, 'workspace') + await runCleanupFileVersions({ + ...payload(f.projectId), + projectIds: [f.projectId, f.projectId], + workspaceIds: [f.workspaceId, f.workspaceId], + }) + expect(await versions(projectFile.fileId)).toHaveLength(12) + expect(await versions(workspaceFile.fileId)).toHaveLength(10) + expect(await versions(unrelated.fileId)).toHaveLength(12) + expect(await cleanupEvents(f.projectId)).toEqual([]) + const events = await db + .select({ payload: outboxEvent.payload }) + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, 'workspace-file.storage.cleanup'), + sql`${outboxEvent.payload}->>'key' LIKE ${`workspace/${f.workspaceId}/%`}` + ) + ) + expect(events.map(({ payload }) => payload)).toEqual( + expect.arrayContaining(workspaceFile.keys.slice(0, 2).map((key) => ({ key }))) + ) + expect(events).toHaveLength(2) + } + ) + + check( + 'one version-deletion budget bounds a mixed owner batch without changing either current head', + async () => { + const f = await fixture() + const projectFile = await seedFile(f, 60 * 24) + const workspaceFile = await seedFile(f, 60 * 24, false, 'workspace') + const deleted = await cleanupFileVersions( + [ + { entityType: 'project', entityId: f.projectId }, + { entityType: 'workspace', entityId: f.workspaceId }, + ], + { plan: 'free', cutoff: new Date(), label: 'bounded-owner-batch' }, + 3 + ) + expect(deleted).toBe(3) + expect(await versions(projectFile.fileId)).toEqual( + Array.from({ length: 10 }, (_, index) => ({ version: index + 3 })) + ) + const remaining = await versions(workspaceFile.fileId) + expect(remaining).toHaveLength(11) + expect(remaining.at(-1)).toEqual({ version: 12 }) + } + ) + check( 'omits empty Projects and batches retained Projects with the same cleanup policy', async () => { @@ -356,6 +454,266 @@ describe('Project file retention follows the current payer in PostgreSQL', () => } ) + check( + 'workspace archive release preserves a restored head and durably deletes only expired objects', + async () => { + const f = await fixture() + const expired = await seedFile(f, 40 * 24, true, 'workspace') + const restored = await seedFile(f, 40 * 24, true, 'workspace') + const legacyKey = `workspace/${f.workspaceId}/${generateId()}.md` + await db.insert(workspaceFile).values({ + id: generateId(), + workspaceId: f.workspaceId, + name: 'legacy.md', + key: legacyKey, + size: 5, + type: 'text/markdown', + uploadedBy: f.ownerId, + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + await db + .update(workspace) + .set({ storageUsedBytes: 10 }) + .where(eq(workspace.id, f.workspaceId)) + for (const key of [expired.keys[11], restored.keys[11], legacyKey]) { + storedKeys.push(key) + await uploadFile({ + file: Buffer.from('draft'), + fileName: 'note.md', + contentType: 'text/markdown', + customKey: key, + preserveKey: true, + context: 'workspace', + persistMetadata: false, + }) + } + const cleanup = await beginFileArchiveCleanup( + [{ entityType: 'workspace', entityId: f.workspaceId }], + { plan: 'free', cutoff: new Date(), label: 'restore-selected-workspace-file' } + ) + await db + .update(workspaceFiles) + .set({ deletedAt: null }) + .where(eq(workspaceFiles.id, restored.fileId)) + const deletion = await cleanup.cleanupStorage() + expect(await deletion.deleteRows()).toBe(2) + const events = await cleanupEvents(f.workspaceId, 'workspace') + expect(new Set(events.map(({ payload }) => (payload as { key: string }).key))).toEqual( + new Set([...expired.keys, legacyKey]) + ) + expect( + events.find(({ payload }) => (payload as { key: string }).key === expired.keys[11])?.payload + ).toEqual({ key: expired.keys[11] }) + await processWorkspaceFileStorageCleanupsNow( + events.map(({ id }) => id), + { label: 'retention-fixture' } + ) + expect(await downloadFile({ key: restored.keys[11], context: 'workspace' })).toEqual( + Buffer.from('draft') + ) + await expect( + downloadFile({ key: expired.keys[11], context: 'workspace' }) + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(downloadFile({ key: legacyKey, context: 'workspace' })).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect(await versions(restored.fileId)).toHaveLength(12) + const [usage] = await db + .select({ bytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, f.ownerId)) + expect(usage.bytes).toBe(10) + } + ) + + check( + 'folder retention deduplicates more than one file batch before deleting its parent', + async () => { + const f = await fixture() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + projectId: f.projectId, + userId: f.ownerId, + resourceType: 'file', + name: 'Expired parent', + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + const files = Array.from({ length: 502 }, (_, index) => ({ + id: generateId(), + projectId: f.projectId, + userId: f.ownerId, + context: 'project', + key: `project/${f.projectId}/batch-${index}`, + originalName: `batch-${index}.md`, + contentType: 'text/markdown', + sizeBytes: 0, + folderId, + })) + await db.insert(workspaceFiles).values(files) + await db.insert(workspaceFiles).values({ + ...files[0], + id: generateId(), + key: `project/${f.projectId}/root`, + folderId: null, + }) + await runCleanupSoftDeletes(payload(f.projectId), createCleanupBudgets({ folders: 1 })) + expect(await db.select().from(folder).where(eq(folder.id, folderId))).toEqual([]) + const retained = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.projectId, f.projectId)) + expect(retained).toHaveLength(503) + expect(retained.every((file) => file.folderId === null)).toBe(true) + expect(new Set(retained.map((file) => file.originalName)).size).toBe(503) + expect(retained.find((file) => file.id === files[0].id)?.originalName).not.toBe('batch-0.md') + } + ) + + check('workspace file folder deletion failure rolls back child locations and names', async () => { + const f = await fixture() + const parentId = generateId() + const childId = generateId() + const owner = { workspaceId: f.workspaceId, userId: f.ownerId, resourceType: 'file' as const } + await db.insert(folder).values({ + ...owner, + id: parentId, + name: 'Archived parent', + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + await db.insert(folder).values({ ...owner, id: childId, name: 'Child', parentId }) + const file = await seedFile(f, 1, false, 'workspace') + await db + .update(workspaceFiles) + .set({ folderId: parentId, originalName: 'Child.md' }) + .where(eq(workspaceFiles.id, file.fileId)) + const trigger = `folder_retention_failure_${generateId().replaceAll('-', '')}` + await db.execute( + sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + IF OLD.id = '${parentId}' THEN RAISE EXCEPTION 'folder retention rollback fixture'; END IF; RETURN OLD; END $$`) + ) + await db.execute( + sql.raw( + `CREATE TRIGGER ${trigger} BEFORE DELETE ON folder FOR EACH ROW EXECUTE FUNCTION ${trigger}()` + ) + ) + try { + await runCleanupSoftDeletes( + { + workspaceIds: [f.workspaceId], + plan: 'free', + retentionHours: 30 * 24, + label: 'workspace-folder-rollback', + }, + createCleanupBudgets({ folders: 1 }) + ).catch(() => undefined) + const [child] = await db + .select({ parentId: folder.parentId, name: folder.name }) + .from(folder) + .where(eq(folder.id, childId)) + expect(child).toEqual({ parentId, name: 'Child' }) + const [head] = await db + .select({ folderId: workspaceFiles.folderId, name: workspaceFiles.originalName }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, file.fileId)) + expect(head).toEqual({ folderId: parentId, name: 'Child.md' }) + } finally { + await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON folder`)) + await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`)) + } + }) + + check( + 'workspace file folder purge waits for a concurrent restore and leaves its children in place', + async () => { + const f = await fixture() + const parentId = generateId() + const childId = generateId() + const owner = { workspaceId: f.workspaceId, userId: f.ownerId, resourceType: 'file' as const } + await db.insert(folder).values({ + ...owner, + id: parentId, + name: 'Restoring parent', + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + await db.insert(folder).values({ ...owner, id: childId, name: 'Child', parentId }) + 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 tx + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, f.workspaceId)) + .for('share') + const result = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + acquired.resolve(result[0].pid) + await restore.promise + await tx.update(folder).set({ deletedAt: null }).where(eq(folder.id, parentId)) + }) + const authPid = await Promise.race([ + acquired.promise, + authority.then(() => { + throw new Error('Restore ended before publishing its lock') + }), + ]) + let settled = false + const cleanup = runCleanupSoftDeletes( + { + workspaceIds: [f.workspaceId], + plan: 'free', + retentionHours: 30 * 24, + label: 'workspace-folder-restore', + }, + createCleanupBudgets({ folders: 1 }) + ).then( + () => { + settled = true + return null + }, + (error: unknown) => { + settled = true + return error + } + ) + let observed: string | null = null + try { + for (let attempt = 0; attempt < 100 && !settled; attempt++) { + const [waiting] = await control< + { wait_event: string }[] + >`SELECT wait_event FROM pg_stat_activity WHERE ${authPid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock'` + if (waiting) { + observed = waiting.wait_event + break + } + await sleep(10) + } + } finally { + restore.resolve() + await authority + await cleanup + } + expect(observed).toBe('advisory') + expect(await cleanup).toBeNull() + const [parent] = await db + .select({ deletedAt: folder.deletedAt }) + .from(folder) + .where(eq(folder.id, parentId)) + expect(parent).toEqual({ deletedAt: null }) + const [child] = await db + .select({ parentId: folder.parentId, name: folder.name }) + .from(folder) + .where(eq(folder.id, childId)) + expect(child).toEqual({ parentId, name: 'Child' }) + } + ) + check( 'free history is pruned with its ten-version floor while a paid Project ignores a stale free queue policy and uploader plan', async () => { @@ -485,42 +843,66 @@ describe('Project file retention follows the current payer in PostgreSQL', () => } ) - check( - 'a failed archive deletion rolls back current bytes, version removal, and the cleanup outbox', - async () => { - const f = await fixture() - const expired = await seedFile(f, 40 * 24, true) - const trigger = `retention_failure_${generateId().replaceAll('-', '')}` - await db.execute( - sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + for (const kind of ['project', 'workspace'] as const) + check( + `a failed ${kind} archive deletion rolls back current bytes, version removal, and the cleanup outbox`, + async () => { + const f = await fixture() + const expired = await seedFile(f, 40 * 24, true, kind) + if (kind === 'workspace') + await db + .update(workspace) + .set({ storageUsedBytes: 5 }) + .where(eq(workspace.id, f.workspaceId)) + const trigger = `retention_failure_${generateId().replaceAll('-', '')}` + await db.execute( + sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.id = '${expired.fileId}' THEN RAISE EXCEPTION 'retention rollback fixture'; END IF; RETURN OLD; END $$`) - ) - await db.execute( - sql.raw( - `CREATE TRIGGER ${trigger} BEFORE DELETE ON workspace_files FOR EACH ROW EXECUTE FUNCTION ${trigger}()` ) - ) - try { - await expect( - runCleanupSoftDeletes(payload(f.projectId), createCleanupBudgets({ files: 1 })) - ).rejects.toMatchObject({ cause: { message: 'retention rollback fixture' } }) - expect(await versions(expired.fileId)).toHaveLength(12) - expect(await cleanupEvents(f.projectId)).toEqual([]) - const [usage] = await db - .select({ bytes: userStats.storageUsedBytes }) - .from(userStats) - .where(eq(userStats.userId, f.ownerId)) - expect(usage.bytes).toBe(15) - } finally { - await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON workspace_files`)) - await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`)) + await db.execute( + sql.raw( + `CREATE TRIGGER ${trigger} BEFORE DELETE ON workspace_files FOR EACH ROW EXECUTE FUNCTION ${trigger}()` + ) + ) + try { + await expect( + runCleanupSoftDeletes( + kind === 'project' + ? payload(f.projectId) + : { + workspaceIds: [f.workspaceId], + plan: 'free', + retentionHours: 30 * 24, + label: 'workspace-rollback', + }, + createCleanupBudgets({ files: 1 }) + ) + ).rejects.toMatchObject( + kind === 'project' + ? { cause: { message: 'retention rollback fixture' } } + : { message: 'File row cleanup failed' } + ) + expect(await versions(expired.fileId)).toHaveLength(12) + expect( + await cleanupEvents(kind === 'project' ? f.projectId : f.workspaceId, kind) + ).toEqual([]) + const [usage] = await db + .select({ bytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, f.ownerId)) + expect(usage.bytes).toBe(15) + } finally { + await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON workspace_files`)) + await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`)) + } } - } - ) + ) }) afterAll(async () => { try { + for (const key of storedKeys) + await deleteFile({ key, context: 'workspace' }).catch(() => undefined) for (const f of fixtures) { await db .delete(outboxEvent) diff --git a/apps/sim/lib/projects/files/retention.ts b/apps/sim/lib/projects/files/retention.ts index 865a4dd2fc8..fa7299ab29e 100644 --- a/apps/sim/lib/projects/files/retention.ts +++ b/apps/sim/lib/projects/files/retention.ts @@ -1,10 +1,10 @@ import { dbFor } from '@sim/db' -import { folder, organization, project, workspaceFiles, workspaceFileVersion } from '@sim/db/schema' -import { and, asc, count, eq, gt, inArray, isNotNull, isNull, lt, min, or, sql } from 'drizzle-orm' +import { organization, project, workspaceFiles, workspaceFileVersion } from '@sim/db/schema' +import { and, asc, count, eq, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm' import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { getPlanType } from '@/lib/billing/plan-helpers' import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/context' -import { prepareProjectStorageMutationInTx } from '@/lib/billing/storage/tracking' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { consumeRowBudget, DEFAULT_DELETE_CHUNK_SIZE, @@ -12,22 +12,24 @@ import { type RowBudget, } from '@/lib/cleanup/batch-delete' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbTransaction } from '@/lib/db/types' -import { acquireFolderMutationLock } from '@/lib/folders/locks' -import { deduplicateFolderNameInScope } from '@/lib/folders/naming' +import { cleanupExpiredFileFolderInTx } from '@/lib/file-retention/folders' +import { + FILES_PER_QUERY, + KEEP_SUPERSEDED, + releaseExpiredFileVersions, + selectExpiredFileVersions, +} from '@/lib/file-retention/versions' import { lockProject } from '@/lib/projects/membership' -import { workspaceFileNameFolderCondition } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { MAX_SUPERSEDED_FILE_VERSIONS, releaseWorkspaceFileVersionsForPurgeInTx, } from '@/lib/uploads/contexts/workspace/workspace-file-versions' -import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { lockFileDirectories } from '@/lib/workspace-files/locks' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' const cleanupDb = dbFor('cleanup') -const KEEP_SUPERSEDED = 9 -const FILES_PER_QUERY = 500 type FileRetentionJob = 'cleanup-file-versions' | 'cleanup-soft-deletes' @@ -41,7 +43,7 @@ async function prepareRetention(tx: DbTransaction, projectId: string, job: FileR tx ) if (isBillingEnabled && owner.organizationId && billing.plan === null) return null - const mutation = await prepareProjectStorageMutationInTx(tx, billing) + const mutation = await prepareFileStorageMutationInTx(tx, billing) const plan = isBillingEnabled ? getPlanType(billing.plan) : 'enterprise' const config = CLEANUP_CONFIG[job] let hours: number | null = config.defaults[plan] @@ -112,58 +114,18 @@ export async function cleanupProjectFileVersions( ) .orderBy(asc(workspaceFiles.id)) .for('update') - const ranked = tx - .select({ - id: workspaceFileVersion.id, - supersededAt: workspaceFileVersion.supersededAt, - rank: sql`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as( - 'rank' - ), - }) - .from(workspaceFileVersion) - .where( - and( - inArray( - workspaceFileVersion.fileId, - candidates.map((row) => row.id) - ), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .as('ranked') const versionLimit = Math.min(DEFAULT_DELETE_CHUNK_SIZE, limit - released) - const expired = await tx - .select({ id: ranked.id }) - .from(ranked) - .where( - and( - gt(ranked.rank, KEEP_SUPERSEDED), - or(lt(ranked.supersededAt, policy.cutoff), gt(ranked.rank, maximum)) - ) - ) - .limit(versionLimit) - const removed = expired.length - ? await tx - .delete(workspaceFileVersion) - .where( - and( - inArray( - workspaceFileVersion.id, - expired.map((row) => row.id) - ), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .returning({ key: workspaceFileVersion.key }) - : [] - await enqueueWorkspaceFileStorageCleanups( + const expired = await selectExpiredFileVersions( tx, - removed.map((row) => row.key), - 'project' + candidates.map(({ id }) => id), + policy.cutoff, + maximum, + versionLimit ) + const removed = await releaseExpiredFileVersions(tx, expired, 'project') return { - lastId: removed.length === versionLimit ? afterId : candidates[candidates.length - 1].id, - removed: removed.length, + lastId: removed === versionLimit ? afterId : candidates[candidates.length - 1].id, + removed, } }) if (!batch) break @@ -183,7 +145,7 @@ export async function cleanupArchivedProjectFiles( const removed = await cleanupDb.transaction(async (tx) => { const policy = await prepareRetention(tx, projectId, 'cleanup-soft-deletes') if (!policy) return 0 - await acquireFolderMutationLock(tx, `project:${projectId}`, 'file') + await lockFileDirectories(tx, [{ entityType: 'project', entityId: projectId }]) const rows = await tx .select({ id: workspaceFiles.id, @@ -235,88 +197,22 @@ export async function cleanupArchivedProjectFiles( return total } -/** Surviving children are re-rooted under the directory lock before an expired folder is removed. */ +/** Surviving children are re-rooted atomically under current Project retention policy. */ export async function cleanupArchivedProjectFileFolders( projectId: string, budget?: RowBudget ): Promise { let total = 0 - const owner = { entityType: 'project' as const, entityId: projectId } - const folderScope = fileFolderOwnerCondition(owner) for (let batch = 0; batch < DEFAULT_MAX_BATCHES_PER_TABLE && budget?.remaining !== 0; batch++) { const removed = await cleanupDb.transaction(async (tx) => { const policy = await prepareRetention(tx, projectId, 'cleanup-soft-deletes') if (!policy) return 0 - await acquireFolderMutationLock(tx, `project:${projectId}`, 'file') - const [expired] = await tx - .select({ id: folder.id }) - .from(folder) - .where(and(folderScope, isNotNull(folder.deletedAt), lt(folder.deletedAt, policy.cutoff))) - .orderBy(asc(folder.id)) - .limit(1) - .for('update') - if (!expired) return 0 - consumeRowBudget(budget, 1) - for (;;) { - const children = await tx - .select({ id: folder.id, name: folder.name }) - .from(folder) - .where(and(folderScope, eq(folder.parentId, expired.id), isNull(folder.deletedAt))) - .orderBy(asc(folder.id)) - .limit(FILES_PER_QUERY) - .for('update') - if (!children.length) break - for (const child of children) { - const name = await deduplicateFolderNameInScope(tx, folderScope, null, child.name) - await tx - .update(folder) - .set({ parentId: null, name, updatedAt: new Date() }) - .where(and(folderScope, eq(folder.id, child.id))) - } - } - for (;;) { - const children = await tx - .select({ id: workspaceFiles.id, name: workspaceFiles.originalName }) - .from(workspaceFiles) - .where( - and( - projectFiles(projectId), - eq(workspaceFiles.folderId, expired.id), - isNull(workspaceFiles.deletedAt) - ) - ) - .orderBy(asc(workspaceFiles.id)) - .limit(FILES_PER_QUERY) - .for('update') - if (!children.length) break - for (const child of children) { - const originalName = await generateRestoreName( - child.name, - async (name) => { - const [existing] = await tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - projectFiles(projectId), - eq(workspaceFiles.originalName, name), - workspaceFileNameFolderCondition(null), - isNull(workspaceFiles.deletedAt) - ) - ) - .limit(1) - return Boolean(existing) - }, - { hasExtension: true } - ) - await tx - .update(workspaceFiles) - .set({ folderId: null, originalName, updatedAt: new Date() }) - .where(and(projectFiles(projectId), eq(workspaceFiles.id, child.id))) - } - } - await tx.delete(folder).where(and(folderScope, eq(folder.id, expired.id))) - return 1 + return cleanupExpiredFileFolderInTx( + tx, + { entityType: 'project', entityId: projectId }, + policy.cutoff, + budget + ) }) total += removed if (!removed) break diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts index 1cc36f7b5a5..53160ab84fd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts @@ -210,7 +210,7 @@ describe('workspace file version history in PostgreSQL', () => { phase === 'storage' ? null : vi - .spyOn(storageBilling, 'incrementStorageUsageForBillingContextInTx') + .spyOn(storageBilling, 'prepareFileAccountingInTx') .mockRejectedValueOnce(operationError) try { const operation = diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 08ef27bbc8c..0038402b6c3 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -570,7 +570,7 @@ async function getFileFolder( } /** Resolves one active folder by ID or path within the authorized owner. */ -async function resolveFileFolderTarget( +export async function resolveFileFolderTarget( owner: O, target: { folderId?: string | null; folderPath?: string }, client: DbOrTx = db diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 1487c43f5ce..92cf3008363 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -20,6 +20,7 @@ import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode, + isPostgresCommitRejection, } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { omit } from '@sim/utils/object' @@ -41,11 +42,9 @@ import { timestampKey, } from '@/lib/api/list-query' import { - decrementStorageUsageForBillingContextInTx, - incrementStorageUsageForBillingContextInTx, lockWorkspaceStorageForMutationInTx, maybeNotifyStorageLimitForBillingContext, - resolveStorageBillingContext, + prepareFileAccountingInTx, } from '@/lib/billing/storage' import { CollabDocStateConflictError, @@ -66,10 +65,7 @@ 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/workspace-file-folder-manager' -import { - enqueueWorkspaceFileLiveDocReconciliation, - processWorkspaceFileLiveDocReconciliationNow, -} from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' +import { enqueueWorkspaceFileLiveDocReconciliation } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { applyWorkspaceFileSecretProvenancePolicyInTx, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, @@ -101,6 +97,7 @@ import { headObject, uploadFile, } from '@/lib/uploads/core/storage-service' +import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import type { ServableFile } from '@/lib/uploads/utils/file-utils.server' @@ -120,7 +117,7 @@ import { import { type EditableFileOwner, editableFileOwnerColumns, - type FileOwner, + matchesFileOwner, resolveFileOwner, } from '@/lib/workspace-files/ownership' import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' @@ -142,6 +139,7 @@ import { listFileFolders, listWorkspaceFileFolders, normalizeWorkspaceFileItemName, + resolveFileFolderTarget, resolveWorkspaceFileFolderTarget, workspaceFileNameFolderCondition, } from './workspace-file-folder-manager' @@ -489,6 +487,20 @@ interface StagedFileContent { } const stagedFileContents = new WeakSet() + +interface PlannedFileIdentity { + readonly id: string +} + +const plannedFileOwners = new WeakMap>() + +/** Reserves an in-process destination identity so a compound copy can rewrite selected references. */ +function planFileIdentity(owner: EditableFileOwner): PlannedFileIdentity { + const identity = Object.freeze({ id: `wf_${generateShortId()}` }) + plannedFileOwners.set(identity, Object.freeze({ ...owner })) + return identity +} + /** Writes private bytes before the owner-authorized metadata transaction begins. */ async function stageFileContent(args: { owner: EditableFileOwner @@ -497,7 +509,9 @@ async function stageFileContent(args: { contentType: string content: Buffer folderId?: string | null + signal?: AbortSignal }): Promise { + args.signal?.throwIfAborted() const name = normalizeWorkspaceFileItemName(args.name, 'File') const owner = Object.freeze({ ...args.owner }) const key = @@ -532,6 +546,7 @@ async function stageFileContent(args: { ...(args.folderId ? { folderId: args.folderId } : {}), }, persistMetadata: false, + signal: args.signal, }) if (uploaded.key !== key) throw new Error('Storage returned an unexpected file key') } catch (error) { @@ -581,30 +596,6 @@ async function discardStagedFileContent(staged: StagedFileContent): Promise( staged: StagedFileContent, @@ -634,6 +625,81 @@ async function finalizeStagedFileContent( } } +/** Commits new canonical metadata and provenance under the owner's directory lock. */ +async function commitFileCreateInTx( + tx: DbTransaction, + args: { + owner: EditableFileOwner + staged: StagedFileContent + userId: string + folderId?: string | null + folderPath?: string + exactName?: boolean + identity?: PlannedFileIdentity + secretProvenance?: WorkspaceFileSecretProvenance + } +): Promise { + assertStagedFileOwner(args.owner, args.staged) + if (args.identity) { + const plannedOwner = plannedFileOwners.get(args.identity) + if ( + plannedOwner?.entityType !== args.owner.entityType || + plannedOwner.entityId !== args.owner.entityId + ) + throw new Error('Planned file identity does not belong to this owner') + } + if (args.owner.entityType === 'workspace') await lockWorkspaceProject(tx, args.owner.entityId) + await lockFileDirectories(tx, [args.owner]) + const target = await resolveFileFolderTarget(args.owner, args, tx) + const folderId = target?.id ?? null + const exists = async (name: string) => { + const [row] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(args.owner), + eq(workspaceFiles.originalName, name), + workspaceFileNameFolderCondition(folderId), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + return Boolean(row) + } + let name = args.staged.name + if (await exists(name)) { + if (args.exactName) throw new FileConflictError(name) + let suffix = 1 + do { + name = withCopySuffix( + args.staged.name, + suffix <= MAX_NUMBERED_COPY_SUFFIX ? suffix : generateShortId(8) + ) + suffix += 1 + } while (suffix <= MAX_NUMBERED_COPY_SUFFIX + 2 && (await exists(name))) + } + const inserted = await insertFileMetadataInTx(tx, args.owner, { + id: args.identity?.id ?? `wf_${generateShortId()}`, + key: args.staged.key, + userId: args.userId, + folderId, + originalName: name, + contentType: args.staged.contentType, + size: args.staged.size, + }) + if (!inserted) throw new FileConflictError(name) + if (args.secretProvenance) { + await replaceWorkspaceFileSecretProvenanceInTx( + tx, + inserted.id, + inserted.contentUpdatedAt, + args.secretProvenance + ) + } + return inserted +} + /** * Upload a file to workspace-scoped storage */ @@ -679,7 +745,6 @@ export async function uploadWorkspaceFile( const effectiveName = pageRestore?.name ?? normalizedFileName const effectiveContentType = pageRestore ? SIM_PAGE_CONTENT_TYPE : contentType const exactName = options?.exactName ?? false - const storageBillingContext = await resolveStorageBillingContext(workspaceId) let lastError: unknown const maxAttempts = exactName ? 1 : MAX_UPLOAD_UNIQUE_RETRIES @@ -690,7 +755,7 @@ export async function uploadWorkspaceFile( if (exactName && (await fileExistsInWorkspace(workspaceId, uniqueName, folderId))) { throw new FileConflictError(uniqueName) } - const fileId = `wf_${generateShortId()}` + const identity = planFileIdentity({ entityType: 'workspace', entityId: workspaceId }) try { const uploadResult = await stageFileContent({ @@ -703,49 +768,34 @@ export async function uploadWorkspaceFile( }) const finalized = await finalizeStagedFileContent(uploadResult, async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - await lockFileDirectories(tx, [{ entityType: 'workspace', entityId: workspaceId }]) - let activeFolderId: string | null + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) + let activeFolderId = folderId if (options?.folderPath !== undefined) { + await lockFileDirectories(tx, [{ entityType: 'workspace', entityId: workspaceId }]) const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file', tx) const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) if (resolvedFolderId === undefined) { throw new OrchestrationError('not_found', 'Target folder not found') } activeFolderId = resolvedFolderId - } else { - activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) } - const inserted = await insertWorkspaceFileMetadataInTx(tx, { - id: fileId, - key: uploadResult.key, + const inserted = await commitFileCreateInTx(tx, { + owner: { entityType: 'workspace', entityId: workspaceId }, + staged: uploadResult, + identity, userId, - workspaceId, folderId: activeFolderId, - originalName: uniqueName, - contentType: effectiveContentType, - size: effectiveBuffer.length, + exactName: true, + secretProvenance: options?.secretProvenance, }) - if (!inserted) { - throw new FileConflictError(uniqueName) - } - if (options?.secretProvenance) { - await replaceWorkspaceFileSecretProvenanceInTx( - tx, - inserted.id, - inserted.contentUpdatedAt, - options.secretProvenance - ) - } - const usage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - effectiveBuffer.length - ) - return { inserted, updatedUsage: usage } + const usage = await accounting.mutation.applyDelta(effectiveBuffer.length) + return { inserted, updatedUsage: usage, billing: accounting.billing } }) - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage) + void maybeNotifyStorageLimitForBillingContext(finalized.billing, finalized.updatedUsage) logger.info( `Successfully uploaded workspace file: ${uniqueName} with key: ${uploadResult.key}` @@ -812,7 +862,6 @@ export async function createOwnedWorkspaceFile(params: { const fileId = `wf_${generateShortId()}` const name = params.fileName(fileId) const buffer = Buffer.from(params.content, 'utf-8') - const storageBillingContext = await resolveStorageBillingContext(params.workspaceId) const staged = await stageFileContent({ owner: { entityType: 'workspace', entityId: params.workspaceId }, userId: params.userId, @@ -820,7 +869,11 @@ export async function createOwnedWorkspaceFile(params: { contentType: params.contentType, content: buffer, }) - const { owner, updatedUsage } = await finalizeStagedFileContent(staged, async (tx) => { + const { owner, updatedUsage, billing } = await finalizeStagedFileContent(staged, async (tx) => { + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: params.workspaceId, + }) const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key: staged.key, @@ -833,14 +886,14 @@ export async function createOwnedWorkspaceFile(params: { context: params.context, }) if (!inserted) throw new Error(`Owned ${params.context} file ${fileId} was not inserted`) - const updatedUsage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - buffer.length - ) - return { owner: await params.insertOwner(tx, fileId), updatedUsage } + const updatedUsage = await accounting.mutation.applyDelta(buffer.length) + return { + owner: await params.insertOwner(tx, fileId), + updatedUsage, + billing: accounting.billing, + } }) - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, updatedUsage) + void maybeNotifyStorageLimitForBillingContext(billing, updatedUsage) return { fileId, owner } } @@ -1012,13 +1065,15 @@ export async function registerUploadedWorkspaceFile(params: { const folderId = params.folderId ?? null - const storageBillingContext = await resolveStorageBillingContext(workspaceId) for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { const fileId = `wf_${generateShortId()}` const displayName = await allocateUniqueWorkspaceFileName(workspaceId, effectiveName, folderId) const finalized = await db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) await lockFileDirectories(tx, [{ entityType: 'workspace', entityId: workspaceId }]) const activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) const inserted = await insertWorkspaceFileMetadataInTx(tx, { @@ -1055,11 +1110,7 @@ export async function registerUploadedWorkspaceFile(params: { return { kind: 'existing', file: raceWinner } as const } - const updatedUsage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - effectiveSize - ) + const updatedUsage = await accounting.mutation.applyDelta(effectiveSize) await replaceWorkspaceFileSecretProvenanceInTx( tx, inserted.id, @@ -1067,7 +1118,7 @@ export async function registerUploadedWorkspaceFile(params: { secretProvenance ) await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, inserted.id) - return { kind: 'created', file: inserted, updatedUsage } as const + return { kind: 'created', file: inserted, updatedUsage, billing: accounting.billing } as const }) if (finalized.kind === 'name-conflict') { @@ -1078,7 +1129,7 @@ export async function registerUploadedWorkspaceFile(params: { } if (finalized.kind === 'created') { - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage) + void maybeNotifyStorageLimitForBillingContext(finalized.billing, finalized.updatedUsage) } await commitPageRestoreRewrite() @@ -1694,6 +1745,7 @@ const WORKSPACE_FILE_SORTS = { } satisfies Record[]> export interface QueryWorkspaceFilesOptions { + fileIds?: readonly string[] scope?: WorkspaceFileScope /** Restrict to one file folder. */ /** `undefined` lists every folder, `null` lists only root files. */ @@ -1801,6 +1853,7 @@ async function queryFileRecords( const conditions = [ fileOwnerCondition(owner), + options.fileIds ? inArray(workspaceFiles.id, [...options.fileIds]) : undefined, scope === 'all' ? undefined : scope === 'archived' @@ -2073,15 +2126,13 @@ async function listWorkspaceFileReferenceCandidates( return hydrateWorkspaceFilePaths(rows, workspaceId) } -/** - * Load the canonical authorization context for an active workspace file by resource ID. - * Database failures propagate so callers never confuse unavailable state with a missing file. - * Chat uploads are admitted only on explicit opt-in (see {@link WorkspaceFileLookupOptions}). - */ -export async function loadActiveWorkspaceFileContext( +async function loadWorkspaceFileContext( fileId: string, - options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean } -): Promise { + options: WorkspaceFileLookupOptions & { + includeDeleted?: boolean + includeArchivedWorkspace?: boolean + } +): Promise { const [context] = await db .select({ fileId: workspaceFiles.id, @@ -2090,6 +2141,7 @@ export async function loadActiveWorkspaceFileContext( workspaceOrganizationId: workspace.organizationId, allowPersonalApiKeys: workspace.allowPersonalApiKeys, billedAccountUserId: workspace.billedAccountUserId, + deletedAt: workspaceFiles.deletedAt, ownership: { projectId: workspaceFiles.projectId, context: workspaceFiles.context, @@ -2105,63 +2157,42 @@ export async function loadActiveWorkspaceFileContext( and( eq(workspaceFiles.id, fileId), workspaceFileContextCondition(options), - ...(options?.includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]), - isNull(workspace.archivedAt) + options.includeDeleted ? undefined : isNull(workspaceFiles.deletedAt), + options.includeArchivedWorkspace ? undefined : isNull(workspace.archivedAt) ) ) .limit(1) if ( !context || - !matchesWorkspaceFileOwner(resolveFileOwner(context.ownership), context.workspaceId) + !matchesFileOwner(resolveFileOwner(context.ownership), { + entityType: 'workspace', + entityId: context.workspaceId, + }) ) { return null } return omit(context, ['ownership']) } -function matchesWorkspaceFileOwner(owner: FileOwner | null, workspaceId: string): boolean { - return owner?.entityType === 'workspace' && owner.entityId === workspaceId +/** Loads canonical active-workspace identity; chat uploads and deleted files require explicit opt-in. */ +export async function loadActiveWorkspaceFileContext( + fileId: string, + options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean } +): Promise { + const context = await loadWorkspaceFileContext(fileId, { + includeDeleted: options?.includeDeleted, + includeChatUploads: options?.includeChatUploads, + includeOwnedFiles: options?.includeOwnedFiles, + }) + return context ? omit(context, ['deletedAt']) : null } -/** - * Load a workspace file for a lifecycle transition, including archived files. - * The workspace archive state is returned by the canonical workspace record and is enforced by - * the operation's manager primitive where the transition requires an active workspace. - */ +/** Loads workspace files for lifecycle transitions; the manager enforces the operation's archive policy. */ export async function loadWorkspaceFileLifecycleContext( fileId: string ): Promise { - const [context] = await db - .select({ - fileId: workspaceFiles.id, - fileContext: workspaceFiles.context, - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - deletedAt: workspaceFiles.deletedAt, - ownership: { - projectId: workspaceFiles.projectId, - context: workspaceFiles.context, - workspaceId: workspaceFiles.workspaceId, - organizationId: workspaceFiles.organizationId, - userId: workspaceFiles.userId, - chatId: workspaceFiles.chatId, - }, - }) - .from(workspaceFiles) - .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) - .where(and(eq(workspaceFiles.id, fileId), eq(workspaceFiles.context, 'workspace'))) - .limit(1) - - if ( - !context || - !matchesWorkspaceFileOwner(resolveFileOwner(context.ownership), context.workspaceId) - ) { - return null - } - return omit(context, ['ownership']) + return loadWorkspaceFileContext(fileId, { includeDeleted: true, includeArchivedWorkspace: true }) } /** @@ -2379,13 +2410,10 @@ interface CommitFileContentOptions { secretProvenancePolicy?: WorkspaceFileSecretProvenancePolicy } -/** Commits bytes, history and provenance under canonical scope and storage locks. */ +/** Commits bytes, history and provenance after the caller has locked its canonical billing payer. */ async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { const { owner, fileId, staged } = options - if (owner.entityType === 'workspace') { - await lockWorkspaceProject(tx, owner.entityId) - await lockWorkspaceStorageForMutationInTx(tx, owner.entityId) - } + if (owner.entityType === 'workspace') await lockWorkspaceProject(tx, owner.entityId) if (staged.contentHash === null) throw new Error('Content replacement requires staged bytes with a verified content hash') assertStagedFileOwner(owner, staged) @@ -2557,7 +2585,6 @@ export async function updateWorkspaceFileContent( throw new OrchestrationError('not_found', 'File not found') } - const storageBillingContext = await resolveStorageBillingContext(workspaceId) const nextContentType = contentType || fileRecord.type try { const staged = await stageFileContent({ @@ -2570,6 +2597,10 @@ export async function updateWorkspaceFileContent( }) const finalized = await finalizeStagedFileContent(staged, async (tx) => { + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) const committed = await commitFileContentInTx(tx, { owner: { entityType: 'workspace', entityId: workspaceId }, fileId, @@ -2582,20 +2613,7 @@ export async function updateWorkspaceFileContent( sizeDiff, storageCleanupEventIds, } = committed - let updatedUsage: number | undefined - if (sizeDiff > 0) { - updatedUsage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - sizeDiff - ) - } else if (sizeDiff < 0) { - await decrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - Math.abs(sizeDiff) - ) - } + const updatedUsage = await accounting.mutation.applyDelta(sizeDiff) const liveDocEventId = options.syncLiveDoc !== false && @@ -2612,6 +2630,7 @@ export async function updateWorkspaceFileContent( file: updatedFile, sizeDiff, updatedUsage, + billing: accounting.billing, liveDocEventId, storageCleanupEventIds, currentVersion: committed.currentVersion, @@ -2620,37 +2639,15 @@ export async function updateWorkspaceFileContent( if (finalized.sizeDiff !== 0) { void maybeNotifyStorageLimitForBillingContext( - storageBillingContext, + finalized.billing, finalized.updatedUsage, finalized.sizeDiff < 0 ) } - await processWorkspaceFileStorageCleanupsNow(finalized.storageCleanupEventIds, { - workspaceId, - fileId, - reason: 'released version', - }) - - if (finalized.liveDocEventId) { - try { - const result = await processWorkspaceFileLiveDocReconciliationNow(finalized.liveDocEventId) - if (result !== 'completed') { - logger.warn('Live document reconciliation deferred to outbox retry', { - workspaceId, - fileId, - eventId: finalized.liveDocEventId, - result, - }) - } - } catch (error) { - logger.warn('Live document reconciliation deferred after inline processing error', { - workspaceId, - fileId, - eventId: finalized.liveDocEventId, - error: getErrorMessage(error), - }) - } - } + await finishFileContentEffects( + { cleanupIds: finalized.storageCleanupEventIds, liveDocEventId: finalized.liveDocEventId }, + { workspaceId, fileId, reason: 'released version' } + ) const currentFolderPath = finalized.file.folderId === fileRecord.folderId ? fileRecord.folderPath : null @@ -2851,7 +2848,6 @@ export async function purgeCreatedWorkspaceFile(params: { expectedFolderId: string | null expectedUpdatedAt: Date }): Promise { - const storageBillingContext = await resolveStorageBillingContext(params.workspaceId) const expectedFolder = params.expectedFolderId === null ? isNull(workspaceFiles.folderId) @@ -2868,8 +2864,10 @@ export async function purgeCreatedWorkspaceFile(params: { isNull(workspaceFiles.deletedAt) ) const cleanupEventIds = await db.transaction(async (tx) => { - await lockWorkspaceProject(tx, params.workspaceId) - await lockWorkspaceStorageForMutationInTx(tx, params.workspaceId) + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: params.workspaceId, + }) const [lockedFile] = await tx .select({ id: workspaceFiles.id, @@ -2890,11 +2888,7 @@ export async function purgeCreatedWorkspaceFile(params: { .returning({ id: workspaceFiles.id }) if (!deleted) throw new Error('Locked archive-created file could not be deleted') - await decrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - getWorkspaceFileSize(lockedFile) - ) + await accounting.mutation.applyDelta(-getWorkspaceFileSize(lockedFile)) const keys = new Set([lockedFile.key, ...versionKeys]) return enqueueWorkspaceFileStorageCleanups(tx, [...keys]) }) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index 570e7ae5788..38d8389cc6e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -16,6 +16,7 @@ import { workspaceFileSecretProvenanceMock, workspaceFileSecretProvenanceMockFns, } from '@sim/testing/mocks/workspace-file-secret-provenance.mock' +import { toRecord } from '@sim/utils/object' import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -116,13 +117,10 @@ workspaceFileFoldersMockFns.mockNormalizeWorkspaceFileItemName.mockImplementatio ) const mockResolveRestoredFolderId = folderQueriesMockFns.mockResolveRestoredFolderId -const mockDecrementStorageUsageForBillingContextInTx = - billingStorageMockFns.mockDecrementStorageUsageForBillingContextInTx -const mockIncrementStorageUsageForBillingContextInTx = - billingStorageMockFns.mockIncrementStorageUsageForBillingContextInTx +const mockApplyFileStorageDelta = billingStorageMockFns.mockApplyFileStorageDelta const mockMaybeNotifyStorageLimitForBillingContext = billingStorageMockFns.mockMaybeNotifyStorageLimitForBillingContext -const mockResolveStorageBillingContext = billingStorageMockFns.mockResolveStorageBillingContext +const mockPrepareFileAccountingInTx = billingStorageMockFns.mockPrepareFileAccountingInTx const mockInitializeWorkspaceFileSecretProvenanceInTx = workspaceFileSecretProvenanceMockFns.mockInitializeWorkspaceFileSecretProvenanceInTx @@ -179,7 +177,10 @@ describe('workspace file metadata and storage accounting', () => { let stagedKey = '' beforeEach(() => { resetDbChainMock() - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) + mockPrepareFileAccountingInTx.mockResolvedValue({ + billing: STORAGE_CONTEXT, + mutation: { applyDelta: mockApplyFileStorageDelta }, + }) mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) mockAssertWorkspaceFileFolderTarget.mockResolvedValue(null) mockHasCloudStorage.mockReturnValue(false) @@ -197,9 +198,8 @@ describe('workspace file metadata and storage accounting', () => { mockGetWorkspaceWithOwner.mockResolvedValue({ archivedAt: null }) mockFileNameExistsInWorkspaceFolder.mockResolvedValue(false) mockResolveRestoredFolderId.mockResolvedValue(null) - mockIncrementStorageUsageForBillingContextInTx.mockReset().mockResolvedValue(10) + mockApplyFileStorageDelta.mockReset().mockResolvedValue(10) mockInitializeWorkspaceFileSecretProvenanceInTx.mockResolvedValue(undefined) - mockDecrementStorageUsageForBillingContextInTx.mockResolvedValue(undefined) mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) mockDeleteFile.mockResolvedValue(undefined) mockEnqueueWorkspaceFileStorageCleanups.mockImplementation(async (_executor, keys: string[]) => @@ -228,6 +228,12 @@ describe('workspace file metadata and storage accounting', () => { mockResolveFolderPathFromIndex .mockReturnValueOnce('folder-initial') .mockReturnValueOnce('folder-final') + workspaceFileFoldersMockFns.mockResolveFileFolderTarget.mockImplementationOnce( + async (_owner, target) => { + const folderId = toRecord(target).folderId + return typeof folderId === 'string' ? { id: folderId } : null + } + ) dbChainMockFns.returning.mockResolvedValueOnce([inserted]) await uploadWorkspaceFile( @@ -260,9 +266,7 @@ describe('workspace file metadata and storage accounting', () => { it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('payer update failed') - ) + mockApplyFileStorageDelta.mockRejectedValueOnce(new Error('payer update failed')) await expect( uploadWorkspaceFile( @@ -306,18 +310,14 @@ describe('workspace file metadata and storage accounting', () => { expect(eq).toHaveBeenCalledWith(workspaceFiles.originalName, FILE_ROW.originalName) expect(eq).toHaveBeenCalledWith(workspaceFiles.folderId, extractedRow.folderId) expect(eq).toHaveBeenCalledWith(workspaceFiles.updatedAt, FILE_ROW.updatedAt) - expect(mockDecrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.any(Object), - STORAGE_CONTEXT, - FILE_ROW.size - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(-FILE_ROW.size) expect(mockEnqueueWorkspaceFileStorageCleanups).toHaveBeenCalledWith(expect.any(Object), [ FILE_ROW.key, ]) expect(dbChainMockFns.delete.mock.invocationCallOrder[0]).toBeLessThan( - mockDecrementStorageUsageForBillingContextInTx.mock.invocationCallOrder[0] + mockApplyFileStorageDelta.mock.invocationCallOrder[0] ) - expect(mockDecrementStorageUsageForBillingContextInTx.mock.invocationCallOrder[0]).toBeLessThan( + expect(mockApplyFileStorageDelta.mock.invocationCallOrder[0]).toBeLessThan( mockEnqueueWorkspaceFileStorageCleanups.mock.invocationCallOrder[0] ) expect(mockProcessWorkspaceFileStorageCleanupsNow).toHaveBeenCalledWith( @@ -341,7 +341,7 @@ describe('workspace file metadata and storage accounting', () => { }) ).resolves.toBe(false) - expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockEnqueueWorkspaceFileStorageCleanups).not.toHaveBeenCalled() expect(mockProcessWorkspaceFileStorageCleanupsNow).not.toHaveBeenCalled() expect(mockDeleteFile).not.toHaveBeenCalled() @@ -373,7 +373,7 @@ describe('workspace file metadata and storage accounting', () => { ]) expect([first.created, second.created].sort()).toEqual([false, true]) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledTimes(1) + expect(mockApplyFileStorageDelta).toHaveBeenCalledTimes(1) expect(mockReplaceWorkspaceFileSecretProvenanceInTx).toHaveBeenCalledTimes(1) expect(mockReplaceWorkspaceFileSecretProvenanceInTx).toHaveBeenCalledWith( expect.any(Object), @@ -411,7 +411,7 @@ describe('workspace file metadata and storage accounting', () => { { status: 'exact', entries: [] } ) expect(mockReplaceWorkspaceFileSecretProvenanceInTx).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() }) it('does not delete an object when a registration race finds a different operation', async () => { @@ -431,7 +431,7 @@ describe('workspace file metadata and storage accounting', () => { }) ).rejects.toThrow('already registered to a different workspace file operation') - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockDeleteFile).not.toHaveBeenCalled() }) @@ -453,7 +453,7 @@ describe('workspace file metadata and storage accounting', () => { }) ).rejects.toMatchObject({ code: 'conflict' }) expect(dbChainMockFns.returning).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() }) @@ -468,7 +468,7 @@ describe('workspace file metadata and storage accounting', () => { await deleteWorkspaceFile(FILE_ROW.workspaceId, FILE_ROW.id) await deleteWorkspaceFile(FILE_ROW.workspaceId, FILE_ROW.id) - expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() }) it('re-roots a restored file whose folder has since been archived', async () => { @@ -516,11 +516,7 @@ describe('workspace file metadata and storage accounting', () => { persistMetadata: false, }) ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.any(Object), - STORAGE_CONTEXT, - 3 - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(3) expect(mockApplyWorkspaceFileSecretProvenancePolicyInTx).toHaveBeenCalledWith( expect.any(Object), FILE_ROW.id, @@ -599,9 +595,7 @@ describe('workspace file metadata and storage accounting', () => { dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW]) dbChainMockFns.returning.mockResolvedValueOnce([updatedFile]) - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('Storage limit exceeded') - ) + mockApplyFileStorageDelta.mockRejectedValueOnce(new Error('Storage limit exceeded')) await expect( updateWorkspaceFileContent( @@ -642,11 +636,7 @@ describe('workspace file metadata and storage accounting', () => { MD_ROW.id, PREPARED_COLLAB_STATE ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - transaction, - STORAGE_CONTEXT, - content.length - MD_ROW.sizeBytes - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(content.length - MD_ROW.sizeBytes) expect(mockEnqueueWorkspaceFileLiveDocReconciliation).toHaveBeenCalledWith( transaction, expect.objectContaining({ fileId: MD_ROW.id }) @@ -722,8 +712,7 @@ describe('workspace file metadata and storage accounting', () => { expect(mockSaveCollabDocStateInTx).toHaveBeenCalledWith(transaction, MD_ROW.id, preparedState) expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockApplyWorkspaceFileSecretProvenancePolicyInTx).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockEnqueueWorkspaceFileLiveDocReconciliation).not.toHaveBeenCalled() expect(mockProcessWorkspaceFileLiveDocReconciliationNow).not.toHaveBeenCalled() expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() @@ -776,9 +765,7 @@ describe('workspace file metadata and storage accounting', () => { { ...MD_ROW, key: replacementKey, sizeBytes: 13 }, ]) - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('accounting unavailable') - ) + mockApplyFileStorageDelta.mockRejectedValueOnce(new Error('accounting unavailable')) await expect( updateWorkspaceFileContent( @@ -801,11 +788,7 @@ describe('workspace file metadata and storage accounting', () => { MD_ROW.id, PREPARED_COLLAB_STATE ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - transaction, - STORAGE_CONTEXT, - 8 - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(8) expect(mockEnqueueWorkspaceFileLiveDocReconciliation).not.toHaveBeenCalled() expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() expect(mockEnqueueWorkspaceFileStorageCleanups).toHaveBeenCalledWith( diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts deleted file mode 100644 index 93a76b0b06e..00000000000 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { billingStorageMock, billingStorageMockFns } from '@sim/testing/mocks/billing-storage.mock' -import { projectMembershipMock } from '@sim/testing/mocks/project-membership.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { uploadsMock, uploadsMockFns } from '@sim/testing/mocks/uploads.mock' -import { - workspaceFileFoldersMock, - workspaceFileFoldersMockFns, -} from '@sim/testing/mocks/workspace-file-folders.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/projects/membership', () => projectMembershipMock) -vi.mock('@/lib/billing/storage', () => billingStorageMock) - -vi.mock('@/lib/uploads', () => uploadsMock) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock( - '@/lib/uploads/contexts/workspace/workspace-file-folder-manager', - () => workspaceFileFoldersMock -) - -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -const mockUploadFile = storageServiceMockFns.mockUploadFile -const mockIncrementStorageUsageForBillingContextInTx = - billingStorageMockFns.mockIncrementStorageUsageForBillingContextInTx -const mockMaybeNotifyStorageLimitForBillingContext = - billingStorageMockFns.mockMaybeNotifyStorageLimitForBillingContext -const mockResolveStorageBillingContext = billingStorageMockFns.mockResolveStorageBillingContext -const mockResolveWorkspaceFileFolderTarget = - workspaceFileFoldersMockFns.mockResolveWorkspaceFileFolderTarget - -workspaceFileFoldersMockFns.mockBuildWorkspaceFileFolderPathMap.mockImplementation(() => new Map()) -workspaceFileFoldersMockFns.mockNormalizeWorkspaceFileItemName.mockImplementation( - (name: string) => name -) - -uploadsMockFns.mockGetServePathPrefix.mockImplementation(() => '/api/files/serve/s3/') - -const STORAGE_CONTEXT = { - workspaceId: 'workspace-1', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -describe('workspace file storage attribution', () => { - beforeEach(() => { - resetDbChainMock() - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) - mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(5) - mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) - mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) - }) - - it.each(['external-collaborator', 'personal-api-key-user'])( - 'charges the workspace payer while retaining %s as uploader metadata', - async (actorUserId) => { - dbChainMockFns.returning - .mockImplementationOnce(async () => [ - { - id: 'file-1', - key: mockUploadFile.mock.calls.at(-1)?.[0].customKey, - userId: actorUserId, - workspaceId: 'workspace-1', - folderId: null, - context: 'workspace', - chatId: null, - originalName: 'note.txt', - displayName: 'note.txt', - contentType: 'text/plain', - size: 5, - sizeBytes: 5, - deletedAt: null, - uploadedAt: new Date(), - updatedAt: new Date(), - contentUpdatedAt: new Date(), - }, - ]) - .mockResolvedValueOnce([{ id: 'file-1' }]) - - await uploadWorkspaceFile( - 'workspace-1', - actorUserId, - Buffer.from('hello'), - 'note.txt', - 'text/plain' - ) - - expect(mockResolveStorageBillingContext).toHaveBeenCalledWith('workspace-1') - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.any(Object), - STORAGE_CONTEXT, - 5 - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: actorUserId, - workspaceId: 'workspace-1', - }) - ) - } - ) -}) diff --git a/apps/sim/lib/uploads/server/content-effects.test.ts b/apps/sim/lib/uploads/server/content-effects.test.ts new file mode 100644 index 00000000000..3ad543f2c90 --- /dev/null +++ b/apps/sim/lib/uploads/server/content-effects.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ cleanup: vi.fn(), reconcile: vi.fn() })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox', () => ({ + processWorkspaceFileStorageCleanupsNow: mocks.cleanup, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox', () => ({ + processWorkspaceFileLiveDocReconciliationNow: mocks.reconcile, +})) + +import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' + +const effects = { cleanupIds: [], liveDocEventId: 'event-1' } +describe('committed file reconciliation policy', () => { + beforeEach(() => { + mocks.cleanup.mockResolvedValue(undefined) + }) + it.each(['completed', 'pending', 'processing', 'lease_lost'])( + 'allows a %s result to finish or retry durably', + async (status) => { + mocks.reconcile.mockResolvedValue(status) + await expect(finishFileContentEffects(effects, {})).resolves.toBeUndefined() + } + ) + it.each(['dead_letter', 'not_found'])( + 'reports a terminal %s result without failing the committed write', + async (status) => { + mocks.reconcile.mockResolvedValue(status) + await expect(finishFileContentEffects(effects, {})).resolves.toBeUndefined() + } + ) + it('reports an inline processing exception without failing the committed write', async () => { + mocks.reconcile.mockRejectedValue(new Error('database unavailable')) + await expect(finishFileContentEffects(effects, {})).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/uploads/server/content-effects.ts b/apps/sim/lib/uploads/server/content-effects.ts new file mode 100644 index 00000000000..4ab1822747f --- /dev/null +++ b/apps/sim/lib/uploads/server/content-effects.ts @@ -0,0 +1,32 @@ +import { createLogger } from '@sim/logger' +import { describeError } from '@sim/utils/errors' +import { processWorkspaceFileLiveDocReconciliationNow } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' +import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' + +const logger = createLogger('FileContentEffects') + +/** Reports durable effect failures without changing the outcome of an already committed write. */ +export async function finishFileContentEffects( + effects: { cleanupIds: readonly string[]; liveDocEventId?: string }, + logContext: Record +): Promise { + await processWorkspaceFileStorageCleanupsNow(effects.cleanupIds, logContext) + if (!effects.liveDocEventId) return + const context = { ...logContext, eventId: effects.liveDocEventId } + try { + const result = await processWorkspaceFileLiveDocReconciliationNow(effects.liveDocEventId) + if (result === 'dead_letter' || result === 'not_found') { + logger.error('Committed file live-document reconciliation requires intervention', { + ...context, + result, + }) + } else if (result !== 'completed') { + logger.warn('Live document reconciliation remains pending', { ...context, result }) + } + } catch (error) { + logger.error('Committed file live-document reconciliation failed inline', { + ...context, + error: describeError(error), + }) + } +} diff --git a/apps/sim/lib/workspace-files/ownership.ts b/apps/sim/lib/workspace-files/ownership.ts index 058467c6e24..c1618254b3c 100644 --- a/apps/sim/lib/workspace-files/ownership.ts +++ b/apps/sim/lib/workspace-files/ownership.ts @@ -62,3 +62,8 @@ export function resolveFileOwner(file: PersistedFileOwnership): FileOwner | null } return null } + +/** Compares canonical owner identity without treating attribution as authority. */ +export function matchesFileOwner(owner: FileOwner | null, assertedOwner: FileOwner): boolean { + return owner?.entityType === assertedOwner.entityType && owner.entityId === assertedOwner.entityId +} diff --git a/packages/testing/src/mocks/billing-storage.mock.ts b/packages/testing/src/mocks/billing-storage.mock.ts index 334280ec593..0c0291d6837 100644 --- a/packages/testing/src/mocks/billing-storage.mock.ts +++ b/packages/testing/src/mocks/billing-storage.mock.ts @@ -1,5 +1,7 @@ import { vi } from 'vitest' +const mockApplyFileStorageDelta = vi.fn<(deltaBytes: number) => Promise>(async () => 0) + /** * Stand-in for `StorageLimitExceededError` with the real `name`, constructor, and * `code: 'payload_too_large'`. It is NOT a subclass of the real `OrchestrationError`; a test @@ -17,9 +19,8 @@ export class MockStorageLimitExceededError extends Error { /** * Controllable mock functions for `@/lib/billing/storage`. * - * Every export is a bare `vi.fn()` (the async ones resolve `undefined`, which covers the - * fire-and-forget `maybeNotifyStorageLimitForBillingContext` and the `*InTx` counters); set the - * billing context, quota result, or limit a test needs per case. + * The file preparer returns a controllable signed mutation; other exports are bare `vi.fn()` + * functions. Set the billing context, quota result, or limit a test needs per case. * * @example * ```ts @@ -30,6 +31,12 @@ export class MockStorageLimitExceededError extends Error { * ``` */ export const billingStorageMockFns = { + mockLockWorkspaceStorageForMutationInTx: vi.fn(async () => undefined), + mockApplyFileStorageDelta, + mockPrepareFileAccountingInTx: vi.fn(async () => ({ + billing: {}, + mutation: { applyDelta: mockApplyFileStorageDelta }, + })), mockResolveStorageBillingContext: vi.fn(), mockCheckStorageQuota: vi.fn(), mockCheckStorageQuotaForBillingContext: vi.fn(), @@ -38,7 +45,6 @@ export const billingStorageMockFns = { mockGetUserStorageLimit: vi.fn(), mockGetUserStorageUsage: vi.fn(), mockApplyStorageUsageDeltasInTx: vi.fn(), - mockLockWorkspaceStorageForMutationInTx: vi.fn(), mockCheckAndIncrementStorageUsageInTx: vi.fn(), mockDecrementStorageUsageForBillingContextInTx: vi.fn(), mockIncrementAdmittedStorageUsageForBillingContextInTx: vi.fn(), @@ -56,6 +62,9 @@ export const billingStorageMockFns = { * ``` */ export const billingStorageMock = { + lockWorkspaceStorageForMutationInTx: + billingStorageMockFns.mockLockWorkspaceStorageForMutationInTx, + prepareFileAccountingInTx: billingStorageMockFns.mockPrepareFileAccountingInTx, StorageLimitExceededError: MockStorageLimitExceededError, resolveStorageBillingContext: billingStorageMockFns.mockResolveStorageBillingContext, checkStorageQuota: billingStorageMockFns.mockCheckStorageQuota, @@ -65,8 +74,6 @@ export const billingStorageMock = { getUserStorageLimit: billingStorageMockFns.mockGetUserStorageLimit, getUserStorageUsage: billingStorageMockFns.mockGetUserStorageUsage, applyStorageUsageDeltasInTx: billingStorageMockFns.mockApplyStorageUsageDeltasInTx, - lockWorkspaceStorageForMutationInTx: - billingStorageMockFns.mockLockWorkspaceStorageForMutationInTx, checkAndIncrementStorageUsageInTx: billingStorageMockFns.mockCheckAndIncrementStorageUsageInTx, decrementStorageUsageForBillingContextInTx: billingStorageMockFns.mockDecrementStorageUsageForBillingContextInTx, diff --git a/packages/testing/src/mocks/workspace-file-folders.mock.ts b/packages/testing/src/mocks/workspace-file-folders.mock.ts index a056dac1549..791b4109d52 100644 --- a/packages/testing/src/mocks/workspace-file-folders.mock.ts +++ b/packages/testing/src/mocks/workspace-file-folders.mock.ts @@ -74,6 +74,9 @@ export const workspaceFileFoldersMockFns = { mockListWorkspaceFileFolders: vi.fn(async (..._args: unknown[]): Promise => []), mockListFileFolders: vi.fn(async (..._args: unknown[]): Promise => []), mockGetWorkspaceFileFolder: vi.fn(), + mockResolveFileFolderTarget: vi.fn( + async (..._args: unknown[]): Promise<{ id: string } | null> => null + ), mockResolveWorkspaceFileFolderTarget: vi.fn( async (..._args: unknown[]): Promise => null ), @@ -123,6 +126,7 @@ export const workspaceFileFoldersMock = { listWorkspaceFileFolders: fns.mockListWorkspaceFileFolders, listFileFolders: fns.mockListFileFolders, getWorkspaceFileFolder: fns.mockGetWorkspaceFileFolder, + resolveFileFolderTarget: fns.mockResolveFileFolderTarget, resolveWorkspaceFileFolderTarget: fns.mockResolveWorkspaceFileFolderTarget, assertWorkspaceFileFolderTarget: fns.mockAssertWorkspaceFileFolderTarget, createWorkspaceFileFolder: fns.mockCreateWorkspaceFileFolder, diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index 14248fb4e25..863772624ac 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -30,6 +30,30 @@ export function getPostgresErrorCode(error: unknown): string | undefined { return readPgErrorField(error, 'code') } +const POSTGRES_COMMIT_REJECTION_CODES = new Set([ + '23000', + '23001', + '23502', + '23503', + '23505', + '23514', + '23P01', + '40000', + '40001', + '40002', + '40P01', +]) + +/** + * Recognizes explicit PostgreSQL constraint/rollback rejections at COMMIT. Use only on the + * transaction's rejection after its callback completed, never on errors from post-commit work. + * Unknown outcomes, including 40003 and connection failures, are not safe cleanup authority. + */ +export function isPostgresCommitRejection(error: unknown): boolean { + const code = getPostgresErrorCode(error) + return code !== undefined && POSTGRES_COMMIT_REJECTION_CODES.has(code) +} + const POSTGRES_CANCELLATION_REASONS = [ ['57014', 'canceling statement due to statement timeout', 'statement_timeout'], ['57014', 'canceling statement due to user request', 'user_cancel'], diff --git a/scripts/check-file-names.baseline.json b/scripts/check-file-names.baseline.json index ee3a89c5661..9294e57c23b 100644 --- a/scripts/check-file-names.baseline.json +++ b/scripts/check-file-names.baseline.json @@ -108,7 +108,6 @@ "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts", - "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-versions.ts", From 427586bfbc3a9b8bc9924a45000e93b2d28c43d8 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 01:15:18 -0700 Subject: [PATCH 02/10] feat(project-files): add core storage and realtime operations --- .github/scripts/http-e2e.sh | 13 +- .github/workflows/checks.yml | 4 +- apps/docs/content/docs/cli/commands.mdx | 1 + apps/docs/content/docs/cli/meta.json | 1 + apps/docs/content/docs/cli/projects.mdx | 543 ++ apps/docs/content/docs/cli/reference.mdx | 539 ++ apps/docs/openapi-v2-files-audit.json | 7403 ++++++++++++----- apps/realtime/src/access-revalidation.ts | 3 +- apps/realtime/src/handlers/file-doc-app.ts | 3 +- .../handlers/file-doc-editor.integration.ts | 20 +- apps/realtime/src/handlers/file-doc-owner.ts | 76 + .../handlers/file-doc-project.integration.ts | 131 +- .../src/handlers/file-doc-store.test.ts | 58 +- apps/realtime/src/handlers/file-doc-store.ts | 7 +- .../handlers/file-doc.multireplica.test.ts | 44 +- apps/realtime/src/handlers/file-doc.test.ts | 39 +- apps/realtime/src/handlers/file-doc.ts | 299 +- apps/realtime/src/handlers/file-list-app.ts | 59 + apps/realtime/src/handlers/index.ts | 5 +- apps/realtime/src/handlers/room-join-auth.ts | 13 +- .../workspace-invalidation-room.test.ts | 208 +- .../handlers/workspace-invalidation-room.ts | 158 +- apps/realtime/src/middleware/permissions.ts | 18 +- apps/realtime/src/routes/http.ts | 103 +- apps/sim/app/api/files/uploads/purposes.ts | 5 + .../[projectId]/[fileId]/access/route.ts | 20 + .../[projectId]/[fileId]/persist/route.ts | 25 + .../[projectId]/[fileId]/seed/route.ts | 24 + .../[projectId]/access/route.ts | 21 + .../[id]/files/[fileId]/content/route.ts | 55 + .../[id]/files/[fileId]/restore/route.ts | 18 + .../api/projects/[id]/files/[fileId]/route.ts | 33 + .../versions/[version]/content/route.ts | 26 + .../versions/[version]/revert/route.ts | 31 + .../[fileId]/versions/[version]/route.ts | 40 + .../[id]/files/[fileId]/versions/route.ts | 48 + .../api/projects/[id]/files/archive/route.ts | 18 + .../files/folders/[folderId]/restore/route.ts | 18 + .../[id]/files/folders/[folderId]/route.ts | 19 + .../api/projects/[id]/files/folders/route.ts | 37 + .../app/api/projects/[id]/files/move/route.ts | 18 + apps/sim/app/api/projects/[id]/files/route.ts | 119 + .../uploads/[uploadId]/complete/route.ts | 25 + .../files/uploads/[uploadId]/parts/route.ts | 26 + .../[id]/files/uploads/[uploadId]/route.ts | 45 + .../api/projects/[id]/files/uploads/route.ts | 33 + .../app/api/users/me/settings/route.test.ts | 10 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 17 +- .../versions/[version]/content/route.ts | 17 +- .../files/[fileId]/content/route.ts | 46 + .../files/[fileId]/metadata/route.ts | 21 + .../files/[fileId]/restore/route.ts | 21 + .../[projectId]/files/[fileId]/route.ts | 21 + .../versions/[version]/content/route.ts | 25 + .../versions/[version]/revert/route.ts | 34 + .../[fileId]/versions/[version]/route.ts | 41 + .../files/[fileId]/versions/route.ts | 51 + .../[projectId]/files/archive/route.ts | 20 + .../files/folders/[folderId]/restore/route.ts | 23 + .../files/folders/[folderId]/route.ts | 21 + .../[projectId]/files/folders/route.ts | 35 + .../projects/[projectId]/files/move/route.ts | 24 + .../v2/projects/[projectId]/files/route.ts | 82 + .../uploads/[uploadId]/complete/route.ts | 25 + .../files/uploads/[uploadId]/parts/route.ts | 25 + .../files/uploads/[uploadId]/route.ts | 46 + .../[projectId]/files/uploads/route.ts | 34 + .../runs/[runId]/files/[fileId]/route.ts | 2 +- .../[id]/files/[fileId]/content/route.test.ts | 7 +- .../versions/[version]/content/route.ts | 36 + .../versions/[version]/revert/route.ts | 26 + .../[id]/files/[fileId]/versions/route.ts | 47 + .../collaboration/file-doc-provider.test.ts | 217 +- .../collaboration/file-doc-provider.ts | 122 +- .../pending-update-journal.test.ts | 4 +- .../collaboration/pending-update-journal.ts | 18 +- .../use-file-doc-collaboration.ts | 2 +- .../sim/hooks/queries/workspace-file-table.ts | 2 +- apps/sim/hooks/queries/workspace-files.ts | 2 +- apps/sim/lib/api/contracts/file-browser.ts | 33 + .../sim/lib/api/contracts/project-file-doc.ts | 57 + .../lib/api/contracts/project-file-folders.ts | 68 + .../api/contracts/project-file-lifecycle.ts | 112 + .../lib/api/contracts/project-file-uploads.ts | 92 + .../api/contracts/project-file-versions.ts | 94 + apps/sim/lib/api/contracts/project-files.ts | 170 + apps/sim/lib/api/contracts/projects.ts | 2 + .../lib/api/contracts/realtime-file-lists.ts | 22 + apps/sim/lib/api/contracts/v2/files.ts | 4 +- .../api/contracts/v2/list-pagination.test.ts | 14 + .../api/contracts/v2/openapi/files-audit.ts | 12 +- .../v2/openapi/project-file-folders.ts | 159 + .../v2/openapi/project-file-uploads.ts | 192 + .../v2/openapi/project-file-versions.ts | 172 + .../api/contracts/v2/openapi/project-files.ts | 320 + .../api/contracts/v2/project-file-folders.ts | 119 + .../contracts/v2/project-file-lifecycle.ts | 77 + .../api/contracts/v2/project-file-uploads.ts | 98 + .../api/contracts/v2/project-file-versions.ts | 66 + .../sim/lib/api/contracts/v2/project-files.ts | 124 + .../api/contracts/workspace-file-versions.ts | 52 + apps/sim/lib/api/contracts/workspace-files.ts | 2 - apps/sim/lib/api/list-query.ts | 25 +- .../lib/api/mcp/generated/v2-operations.ts | 246 + .../routes/internal-binary-route.test.ts | 51 + .../server/routes/internal-binary-route.ts | 32 +- .../server/routes/internal-json-route.test.ts | 31 +- .../api/server/routes/internal-json-route.ts | 2 +- .../api/server/routes/v2-binary-route.test.ts | 32 +- .../lib/api/server/routes/v2-binary-route.ts | 3 + .../server/routes/v2-route-table.generated.ts | 74 + apps/sim/lib/api/server/validation.test.ts | 16 +- apps/sim/lib/api/server/validation.ts | 43 +- apps/sim/lib/auth/realtime-file-delegation.ts | 42 + .../lib/auth/realtime-file-list-delegation.ts | 39 + apps/sim/lib/collab-doc/collab-state.ts | 7 +- apps/sim/lib/collab-doc/persist.ts | 2 +- apps/sim/lib/collab-doc/seed.ts | 46 +- apps/sim/lib/core/application/operation.ts | 6 +- .../application/organization-authorization.ts | 3 +- .../application/resource-delegation.test.ts | 304 + .../core/application/resource-delegation.ts | 179 + .../application/workspace-authorization.ts | 2 + .../core/application/workspace-operation.ts | 3 + .../sim/lib/core/config/feature-flags.test.ts | 13 + apps/sim/lib/core/config/feature-flags.ts | 2 +- apps/sim/lib/core/config/request-limits.ts | 2 + apps/sim/lib/core/outbox/handlers.ts | 6 +- apps/sim/lib/core/security/csp.ts | 4 + apps/sim/lib/core/utils/with-route-handler.ts | 9 + .../lib/mothership/generated/docs-manifest.ts | 1 + apps/sim/lib/projects/README.md | 33 +- .../__integration__/foundation.integration.ts | 138 + .../lib/projects/application/authorization.ts | 114 +- .../sim/lib/projects/application/discovery.ts | 68 + .../lib/projects/application/operations.ts | 18 +- .../sim/lib/projects/application/use-cases.ts | 15 +- .../authorization.integration.ts | 1097 +++ .../__integration__/browser.integration.ts | 299 + .../__integration__/content.integration.ts | 1478 ++++ .../__integration__/documents.integration.ts | 472 ++ .../private-purge.integration.ts | 34 +- .../__integration__/retention.integration.ts | 30 +- .../__integration__/uploads.integration.ts | 651 ++ .../projects/files/api/content-presenter.ts | 19 + apps/sim/lib/projects/files/api/index.ts | 5 + apps/sim/lib/projects/files/api/presenters.ts | 42 + .../projects/files/api/upload-presenter.ts | 26 + .../projects/files/api/version-presenters.ts | 22 + .../files/application/authorization.ts | 294 + .../files/application/authorized-use-case.ts | 142 + .../files/application/collection-access.ts | 10 + .../lib/projects/files/application/content.ts | 306 + .../files/application/document-lifecycle.ts | 89 +- .../projects/files/application/documents.ts | 244 + .../lib/projects/files/application/folders.ts | 109 + .../lib/projects/files/application/index.ts | 31 + .../projects/files/application/lifecycle.ts | 202 + .../projects/files/application/operations.ts | 341 + .../lib/projects/files/application/read.ts | 140 + .../lib/projects/files/application/uploads.ts | 384 + .../projects/files/application/versions.ts | 438 + .../files/application/write-effects.ts | 64 + apps/sim/lib/projects/files/prefix-cleanup.ts | 2 +- apps/sim/lib/realtime/notify.test.ts | 19 +- apps/sim/lib/realtime/notify.ts | 77 +- .../workspace-file-folder-manager.ts | 12 +- .../workspace/workspace-file-manager.ts | 137 +- .../workspace-file-storage-accounting.test.ts | 6 +- .../uploads/server/content-effects.test.ts | 4 +- .../sim/lib/uploads/server/content-effects.ts | 4 +- apps/sim/lib/uploads/server/delivery.ts | 142 + .../live-doc-outbox.test.ts} | 30 +- .../live-doc-outbox.ts} | 77 +- .../upload-session/project-file-binding.ts | 86 + .../upload-session/project-file-provenance.ts | 40 + .../sim/lib/uploads/upload-session/service.ts | 196 +- apps/sim/lib/uploads/utils/file-utils.ts | 2 +- apps/sim/lib/users/queries.ts | 6 +- .../application/run-workflow-from-copilot.ts | 1 + apps/sim/lib/workspace-files/api/index.ts | 1 + .../workspace-files/api/version-presenters.ts | 19 + .../application/file-delivery-observer.ts | 30 + .../application/file-version-write.ts | 1 + .../application/file-versions.ts | 76 +- .../workspace-files/application/subject.ts | 36 + .../update-workspace-file-dimensions.test.ts | 6 +- .../application/version-authors.ts | 24 + .../application/version-content.ts | 33 + .../application/workspace-file-context.ts | 45 +- apps/sim/lib/workspace-files/browser-query.ts | 254 + apps/sim/lib/workspace-files/browser.ts | 48 + apps/sim/lib/workspace-files/owner-paths.ts | 15 + apps/sim/next.config.ts | 12 +- .../scripts/test-file-list-realtime-e2e.ts | 494 ++ .../scripts/test-project-file-browser-e2e.ts | 206 + .../scripts/test-project-file-history-e2e.ts | 466 ++ apps/sim/scripts/test-project-files-e2e.ts | 459 + knip.jsonc | 2 + packages/auth/src/principal.ts | 80 + packages/platform-authz/src/room-policy.ts | 1 + packages/realtime-protocol/package.json | 4 + .../realtime-protocol/src/file-doc-target.ts | 101 + packages/realtime-protocol/src/file-doc.ts | 9 +- packages/realtime-protocol/src/rooms.ts | 53 +- .../src/commands/protocol/files-get.ts | 91 +- .../src/commands/protocol/files-upload.ts | 142 +- .../sim-cli/src/commands/protocol/index.ts | 13 +- packages/sim-cli/src/contract/commands.ts | 177 +- packages/sim-cli/src/generated/v2-api.ts | 1340 ++- packages/sim-cli/src/http/client.test.ts | 13 + packages/sim-cli/src/runtime/execute.ts | 7 + .../sim-cli/src/transfer/upload-session.ts | 10 +- .../src/mocks/workspace-authorization.mock.ts | 1 + packages/utils/src/client-info.ts | 1 + scripts/check-file-names.baseline.json | 2 - scripts/check-test-types.baseline.json | 10 - ...check-tool-registry-boundary.baseline.json | 708 +- scripts/check-unused-exports.baseline.json | 30 - scripts/generate-v2-cli-api.ts | 1 + 220 files changed, 24778 insertions(+), 3301 deletions(-) create mode 100644 apps/docs/content/docs/cli/projects.mdx create mode 100644 apps/realtime/src/handlers/file-doc-owner.ts create mode 100644 apps/realtime/src/handlers/file-list-app.ts create mode 100644 apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts create mode 100644 apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts create mode 100644 apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts create mode 100644 apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/archive/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/folders/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/move/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/projects/[id]/files/uploads/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts create mode 100644 apps/sim/lib/api/contracts/file-browser.ts create mode 100644 apps/sim/lib/api/contracts/project-file-doc.ts create mode 100644 apps/sim/lib/api/contracts/project-file-folders.ts create mode 100644 apps/sim/lib/api/contracts/project-file-lifecycle.ts create mode 100644 apps/sim/lib/api/contracts/project-file-uploads.ts create mode 100644 apps/sim/lib/api/contracts/project-file-versions.ts create mode 100644 apps/sim/lib/api/contracts/project-files.ts create mode 100644 apps/sim/lib/api/contracts/realtime-file-lists.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/project-files.ts create mode 100644 apps/sim/lib/api/contracts/v2/project-file-folders.ts create mode 100644 apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts create mode 100644 apps/sim/lib/api/contracts/v2/project-file-uploads.ts create mode 100644 apps/sim/lib/api/contracts/v2/project-file-versions.ts create mode 100644 apps/sim/lib/api/contracts/v2/project-files.ts create mode 100644 apps/sim/lib/api/contracts/workspace-file-versions.ts create mode 100644 apps/sim/lib/api/server/routes/internal-binary-route.test.ts create mode 100644 apps/sim/lib/auth/realtime-file-delegation.ts create mode 100644 apps/sim/lib/auth/realtime-file-list-delegation.ts create mode 100644 apps/sim/lib/core/application/resource-delegation.test.ts create mode 100644 apps/sim/lib/core/application/resource-delegation.ts create mode 100644 apps/sim/lib/core/config/request-limits.ts create mode 100644 apps/sim/lib/projects/application/discovery.ts create mode 100644 apps/sim/lib/projects/files/__integration__/authorization.integration.ts create mode 100644 apps/sim/lib/projects/files/__integration__/browser.integration.ts create mode 100644 apps/sim/lib/projects/files/__integration__/content.integration.ts create mode 100644 apps/sim/lib/projects/files/__integration__/documents.integration.ts create mode 100644 apps/sim/lib/projects/files/__integration__/uploads.integration.ts create mode 100644 apps/sim/lib/projects/files/api/content-presenter.ts create mode 100644 apps/sim/lib/projects/files/api/index.ts create mode 100644 apps/sim/lib/projects/files/api/presenters.ts create mode 100644 apps/sim/lib/projects/files/api/upload-presenter.ts create mode 100644 apps/sim/lib/projects/files/api/version-presenters.ts create mode 100644 apps/sim/lib/projects/files/application/authorization.ts create mode 100644 apps/sim/lib/projects/files/application/authorized-use-case.ts create mode 100644 apps/sim/lib/projects/files/application/collection-access.ts create mode 100644 apps/sim/lib/projects/files/application/content.ts create mode 100644 apps/sim/lib/projects/files/application/documents.ts create mode 100644 apps/sim/lib/projects/files/application/folders.ts create mode 100644 apps/sim/lib/projects/files/application/index.ts create mode 100644 apps/sim/lib/projects/files/application/lifecycle.ts create mode 100644 apps/sim/lib/projects/files/application/operations.ts create mode 100644 apps/sim/lib/projects/files/application/read.ts create mode 100644 apps/sim/lib/projects/files/application/uploads.ts create mode 100644 apps/sim/lib/projects/files/application/versions.ts create mode 100644 apps/sim/lib/projects/files/application/write-effects.ts create mode 100644 apps/sim/lib/uploads/server/delivery.ts rename apps/sim/lib/uploads/{contexts/workspace/workspace-file-live-doc-outbox.test.ts => server/live-doc-outbox.test.ts} (87%) rename apps/sim/lib/uploads/{contexts/workspace/workspace-file-live-doc-outbox.ts => server/live-doc-outbox.ts} (60%) create mode 100644 apps/sim/lib/uploads/upload-session/project-file-binding.ts create mode 100644 apps/sim/lib/uploads/upload-session/project-file-provenance.ts create mode 100644 apps/sim/lib/workspace-files/api/version-presenters.ts create mode 100644 apps/sim/lib/workspace-files/application/file-delivery-observer.ts create mode 100644 apps/sim/lib/workspace-files/application/subject.ts create mode 100644 apps/sim/lib/workspace-files/application/version-authors.ts create mode 100644 apps/sim/lib/workspace-files/application/version-content.ts create mode 100644 apps/sim/lib/workspace-files/browser-query.ts create mode 100644 apps/sim/lib/workspace-files/browser.ts create mode 100644 apps/sim/lib/workspace-files/owner-paths.ts create mode 100644 apps/sim/scripts/test-file-list-realtime-e2e.ts create mode 100644 apps/sim/scripts/test-project-file-browser-e2e.ts create mode 100644 apps/sim/scripts/test-project-file-history-e2e.ts create mode 100644 apps/sim/scripts/test-project-files-e2e.ts create mode 100644 packages/realtime-protocol/src/file-doc-target.ts diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index dc00349029c..928281b468a 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. @@ -19,7 +19,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=300 mkdir -p "$report_dir" @@ -211,6 +211,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 9331a76ff44..acf768ca7b9 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/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index 85c9eb0da58..a1fedef42cf 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -47,6 +47,7 @@ These apply to every command, and may be written before or after it. | [`sim meta`](/cli/meta) | Manage meta | | [`sim organizations`](/cli/organizations) | Manage organizations | | [`sim permission-groups`](/cli/permission-groups) | Manage permission groups | +| [`sim projects`](/cli/projects) | Manage projects | | [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | | [`sim selectors`](/cli/selectors) | Manage selectors | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 14ef23a60dd..30ee29465d3 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -30,6 +30,7 @@ "meta", "organizations", "permission-groups", + "projects", "sandboxes", "secrets", "selectors", diff --git a/apps/docs/content/docs/cli/projects.mdx b/apps/docs/content/docs/cli/projects.mdx new file mode 100644 index 00000000000..47a730b59e8 --- /dev/null +++ b/apps/docs/content/docs/cli/projects.mdx @@ -0,0 +1,543 @@ +--- +title: Projects +description: Manage projects — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Archive project file items + +```bash +sim projects files archive [options] +``` + +Archive Project File Items (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to archive. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to archive recursively, including their files and descendants. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Create project file + +```bash +sim projects files create [options] +``` + +Create Project File (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | +| `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | +| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. | +| `--encoding ` | No | Encoding of the content field. Accepted values: `utf-8`, `base64`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + +## Create project file folder + +```bash +sim projects files folders create [options] +``` + +Create Project File Folder (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Name for the new folder. | +| `--parent-id ` | No | Parent folder identifier; omit or use null for the root. (--parent-id null sends the word, not JSON null). | + + + +## List project file folders + +```bash +sim projects files folders list [options] +``` + +List Project File Folders (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | No | Folder lifecycle scope. Accepted values: `active`, `archived`, `all`. | + + + +## Restore project file folder + +```bash +sim projects files folders restore +``` + +Restore Project File Folder (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +## Update project file folder + +```bash +sim projects files folders update [options] +``` + +Update Project File Folder (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New folder name; omit to leave unchanged. | +| `--parent-id ` | No | New parent folder identifier; null moves to the root, omission leaves the parent unchanged. (--parent-id null sends the word, not JSON null). | +| `--sort-order ` | No | New manual position; omit to leave unchanged. | + + + +## Permanently delete a previous version of a shared project file + +```bash +sim projects files versions delete [options] +``` + +Permanently delete a previous version of a shared Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Show the metadata of one version of a shared project file + +```bash +sim projects files versions describe +``` + +Show the metadata of one version of a shared Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +## List the recorded versions of a shared project file + +```bash +sim projects files versions list [options] +``` + +List the recorded versions of a shared Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `version`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +## Make a previous version of a shared project file current again + +```bash +sim projects files versions revert [options] +``` + +Make a previous version of a shared Project file current again (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--expected-current-version ` | No | 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. | +| `--expected-revision ` | No | 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. | + + + +## Download a project file version’s stored source to stdout or a local file + +```bash +sim projects files versions source [options] +``` + +Download a Project file version’s stored source to stdout or a local file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +## Show project file metadata and ownership + +```bash +sim projects files describe +``` + +Show Project file metadata and ownership (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +## List project files + +```bash +sim projects files list [options] +``` + +List Project Files (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | +| `--no-recursive` | No | Send --recursive as false. | +| `--scope ` | No | 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. Accepted values: `active`, `archived`. | +| `--search ` | No | Case-insensitive substring match against the file name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +## Move project file items + +```bash +sim projects files move [options] +``` + +Move Project File Items (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to move. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to move with their contents. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--to ` | No | Destination folder path; omit for root. | + + + +## Rename project file + +```bash +sim projects files rename [options] +``` + +Rename Project File (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | New file name, including its extension. | + + + +## Restore project file + +```bash +sim projects files restore +``` + +Restore Project File (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +## Replace a shared project file’s contents + +```bash +sim projects files set-content [options] +``` + +Replace a shared Project file’s contents (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | +| `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | + + + +## Upload a shared project file + +```bash +sim projects files upload [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that will own the file | +| `path` | Yes | Local file to upload | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; defaults to the Project root. | +| `--name ` | No | Store it under a different name. | + + + +## Download a project file’s stored source to stdout or a local file + +```bash +sim projects files source [options] +``` + +Download a Project file’s stored source to stdout or a local file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File whose stored source to read | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index f46c03bb001..4ce97415f5e 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -4294,6 +4294,545 @@ sim permission-groups update [options] +## sim projects + +### sim projects files archive + +Archive Project File Items (OAuth login or personal API key required) + +```bash +sim projects files archive [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to archive. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to archive recursively, including their files and descendants. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim projects files create + +Create Project File (OAuth login or personal API key required) + +```bash +sim projects files create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | +| `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | +| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. | +| `--encoding ` | No | Encoding of the content field. Accepted values: `utf-8`, `base64`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + +### sim projects files folders create + +Create Project File Folder (OAuth login or personal API key required) + +```bash +sim projects files folders create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Name for the new folder. | +| `--parent-id ` | No | Parent folder identifier; omit or use null for the root. (--parent-id null sends the word, not JSON null). | + + + +### sim projects files folders list + +List Project File Folders (OAuth login or personal API key required) + +```bash +sim projects files folders list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | No | Folder lifecycle scope. Accepted values: `active`, `archived`, `all`. | + + + +### sim projects files folders restore + +Restore Project File Folder (OAuth login or personal API key required) + +```bash +sim projects files folders restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +### sim projects files folders update + +Update Project File Folder (OAuth login or personal API key required) + +```bash +sim projects files folders update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New folder name; omit to leave unchanged. | +| `--parent-id ` | No | New parent folder identifier; null moves to the root, omission leaves the parent unchanged. (--parent-id null sends the word, not JSON null). | +| `--sort-order ` | No | New manual position; omit to leave unchanged. | + + + +### sim projects files versions delete + +Permanently delete a previous version of a shared Project file (OAuth login or personal API key required) + +```bash +sim projects files versions delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim projects files versions describe + +Show the metadata of one version of a shared Project file (OAuth login or personal API key required) + +```bash +sim projects files versions describe +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +### sim projects files versions list + +List the recorded versions of a shared Project file (OAuth login or personal API key required) + +```bash +sim projects files versions list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `version`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +### sim projects files versions revert + +Make a previous version of a shared Project file current again (OAuth login or personal API key required) + +```bash +sim projects files versions revert [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--expected-current-version ` | No | 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. | +| `--expected-revision ` | No | 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. | + + + +### sim projects files versions source + +Download a Project file version’s stored source to stdout or a local file (OAuth login or personal API key required) + +```bash +sim projects files versions source [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +### sim projects files describe + +Show Project file metadata and ownership (OAuth login or personal API key required) + +```bash +sim projects files describe +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +### sim projects files list + +List Project Files (OAuth login or personal API key required) + +```bash +sim projects files list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | +| `--no-recursive` | No | Send --recursive as false. | +| `--scope ` | No | 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. Accepted values: `active`, `archived`. | +| `--search ` | No | Case-insensitive substring match against the file name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +### sim projects files move + +Move Project File Items (OAuth login or personal API key required) + +```bash +sim projects files move [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to move. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to move with their contents. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--to ` | No | Destination folder path; omit for root. | + + + +### sim projects files rename + +Rename Project File (OAuth login or personal API key required) + +```bash +sim projects files rename [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | New file name, including its extension. | + + + +### sim projects files restore + +Restore Project File (OAuth login or personal API key required) + +```bash +sim projects files restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +### sim projects files set-content + +Replace a shared Project file’s contents (OAuth login or personal API key required) + +```bash +sim projects files set-content [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | +| `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | + + + +### sim projects files upload + +Upload a shared Project file + +```bash +sim projects files upload [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that will own the file | +| `path` | Yes | Local file to upload | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; defaults to the Project root. | +| `--name ` | No | Store it under a different name. | + + + +### sim projects files source + +Download a Project file’s stored source to stdout or a local file (OAuth login or personal API key required) + +```bash +sim projects files source [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File whose stored source to read | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + ## sim sandboxes Also spelled `sim sandbox`. diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 9e96b13c639..8992a249cba 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Files & Audit Logs", - "description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.", + "description": "Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Create, upload, download, organize, share, version, and delete workspace files." + "description": "Create, upload, download, organize, share, version, and delete workspace and Project files." }, { "name": "Audit Logs", @@ -39,135 +39,52 @@ } ], "paths": { - "/api/v2/files": { - "get": { - "operationId": "listFiles", - "summary": "List Files", - "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.list", - "x-oauth-scope": "api:read", + "/api/v2/projects/{projectId}/files/{fileId}": { + "patch": { + "operationId": "renameProjectFile", + "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`.", + "x-sim-operation": "project_files.rename", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "projectId", + "in": "path", "required": true, - "description": "Workspace whose files should be listed.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace whose files should be listed." - } - }, - { - "name": "folderPath", - "in": "query", - "required": false, - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "schema": { - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "schema": { - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "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.", - "schema": { - "default": "active", - "description": "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.", - "type": "string", - "enum": ["active", "archived"] + "description": "Project identifier." } }, { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the file name.", + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier within the Project.", "schema": { - "description": "Case-insensitive substring match against the file name.", "type": "string", "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "uploadedAt", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "size", "uploadedAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", - "schema": { - "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", - "type": "integer", - "default": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "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.", - "schema": { - "description": "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.", - "type": "string", - "minLength": 1 + "description": "File identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "The new file name.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameProjectFileRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of workspace files.", + "description": "Metadata for the renamed file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -182,7 +99,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileListResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -199,9 +116,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -212,28 +135,43 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/projects/{projectId}/files/move": { "post": { - "operationId": "createFile", - "summary": "Create File", - "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.create", + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.move_items", "x-oauth-scope": "api:write", "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + } + ], "requestBody": { "required": true, - "description": "Inline content and placement for a new workspace file.", + "description": "Selected files and folders and their destination.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileRequest" + "$ref": "#/components/schemas/MoveProjectFileItemsRequest" } } } }, "responses": { - "201": { - "description": "The created file.", + "200": { + "description": "Counts and identifiers of moved items.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -248,7 +186,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2CreatedFileResponse" + "$ref": "#/components/schemas/V2MoveProjectFileItemsResponse" } } } @@ -286,28 +224,41 @@ } } }, - "/api/v2/files/uploads": { + "/api/v2/projects/{projectId}/files/archive": { "post": { - "operationId": "createFileUpload", - "summary": "Create File Upload", - "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.create", + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.archive_items", "x-oauth-scope": "api:write", "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + } + ], "requestBody": { "required": true, - "description": "File metadata required to create an upload session.", + "description": "Files and folders to archive.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadRequest" + "$ref": "#/components/schemas/ArchiveProjectFileItemsRequest" } } } }, "responses": { - "201": { - "description": "The created upload session and transfer instructions.", + "200": { + "description": "Counts and identifiers of archived items.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -322,7 +273,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadResponse" + "$ref": "#/components/schemas/V2ArchiveProjectFileItemsResponse" } } } @@ -339,6 +290,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -357,53 +311,52 @@ } } }, - "/api/v2/files/uploads/{uploadId}": { - "get": { - "operationId": "getFileUpload", - "summary": "Get File Upload", - "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.upload.read", - "x-oauth-scope": "api:read", + "/api/v2/projects/{projectId}/files/{fileId}/restore": { + "post": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.restore", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." + "description": "Project identifier." } }, { - "name": "upload-token", - "in": "header", + "name": "fileId", + "in": "path", "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "File identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "An empty object; the file is identified by its path parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreProjectFileRequest" + } + } + } + }, "responses": { "200": { - "description": "Current upload-session state.", + "description": "Metadata for the restored file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -418,7 +371,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -435,6 +388,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -445,53 +407,43 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "abortFileUpload", - "summary": "Abort File Upload", - "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.cancel", + } + }, + "/api/v2/projects/{projectId}/files": { + "post": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.create", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "Project identifier." } } ], + "requestBody": { + "required": true, + "description": "Name, content, encoding, and containing folder within the Project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectFileRequest" + } + } + } + }, "responses": { - "200": { - "description": "The aborted upload session.", + "201": { + "description": "The created file and its content revision.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -506,7 +458,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -526,6 +478,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -536,66 +494,136 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/uploads/{uploadId}/parts": { - "post": { - "operationId": "createFileUploadPartUrls", - "summary": "Create File Upload Part URLs", - "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.parts", - "x-oauth-scope": "api:write", + }, + "get": { + "operationId": "listProjectFiles", + "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`.", + "x-sim-operation": "project_files.list", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Upload session identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", + "name": "folderPath", "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "required": false, + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "schema": { + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "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.", "schema": { + "default": "active", + "description": "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.", "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." + "enum": ["active", "archived"] } }, { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the file name.", "schema": { + "description": "Case-insensitive substring match against the file name.", "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "maxLength": 200 } - } - ], - "requestBody": { - "required": true, - "description": "Multipart part numbers requiring signed URLs.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFileUploadPartUrlsRequest" - } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "uploadedAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.", + "schema": { + "default": 100, + "description": "Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.", + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "description": "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.", + "type": "string", + "minLength": 1 } } - }, + ], "responses": { "200": { - "description": "Signed URLs for the requested upload parts.", + "description": "A page of Project files.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -610,7 +638,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadPartUrlsResponse" + "$ref": "#/components/schemas/V2ProjectFileListResponse" } } } @@ -627,15 +655,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -648,68 +667,57 @@ } } }, - "/api/v2/files/uploads/{uploadId}/complete": { - "post": { - "operationId": "completeFileUpload", - "summary": "Complete File Upload", - "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.complete", - "x-oauth-scope": "api:write", + "/api/v2/projects/{projectId}/files/{fileId}/content": { + "get": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.read_content", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." + "description": "Project identifier." } }, { - "name": "upload-token", - "in": "header", + "name": "fileId", + "in": "path", "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "File identifier within the Project." } } ], "responses": { "200": { - "description": "The completed or finalizing upload session.", + "description": "The current stored content bytes.", "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "Content-Type": { + "$ref": "#/components/headers/Content-Type" }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "Content-Length": { + "$ref": "#/components/headers/Content-Length" } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "type": "string", + "format": "binary" } } } @@ -729,6 +737,9 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -739,81 +750,52 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/{fileId}/text": { - "get": { - "operationId": "readFileText", - "summary": "Read File Text", - "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.read_content", - "x-oauth-scope": "api:read", + }, + "put": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.update_content", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 4096, - "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "fileId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "maxBytes", - "in": "query", - "required": false, - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "schema": { - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "type": "integer", - "minimum": 1, - "maximum": 26214400 - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "schema": { - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "How many lines to return from `offset`. Absent reads to the end.", - "schema": { - "description": "How many lines to return from `offset`. Absent reads to the end.", - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "description": "File identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "Complete replacement bytes and an optional optimistic concurrency revision.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectFileContentRequest" + } + } + } + }, "responses": { "200": { - "description": "The extracted text and its extraction-quality flags.", + "description": "The updated file and its content revision.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -828,7 +810,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileTextResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -851,6 +833,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -863,38 +848,113 @@ } } }, - "/api/v2/files/{fileId}/versions": { + "/api/v2/projects/{projectId}/files/{fileId}/metadata": { "get": { - "operationId": "listFileVersions", - "summary": "List File Versions", - "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.list", + "operationId": "getProjectFileMetadata", + "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`.", + "x-sim-operation": "project_files.read_metadata", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "File identifier within the Project." + } + } + ], + "responses": { + "200": { + "description": "Project file metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/projects/{projectId}/files/{fileId}/versions": { + "get": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.versions.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "fileId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "File identifier within the Project." } }, { @@ -948,7 +1008,7 @@ ], "responses": { "200": { - "description": "A page of file versions.", + "description": "A cursor-paginated page of Project file versions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -963,7 +1023,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionListResponse" + "$ref": "#/components/schemas/V2ProjectFileVersionListResponse" } } } @@ -992,26 +1052,35 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}": { + "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}": { "get": { - "operationId": "getFileVersion", - "summary": "Get File Version", - "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.read", + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.versions.read", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "File identifier within the Project." } }, { @@ -1025,23 +1094,11 @@ "maximum": 2147483647, "description": "Version number." } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } } ], "responses": { "200": { - "description": "The file version.", + "description": "Metadata for the selected version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1056,7 +1113,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionResponse" + "$ref": "#/components/schemas/V2ProjectFileVersionResponse" } } } @@ -1085,24 +1142,33 @@ } }, "delete": { - "operationId": "deleteFileVersion", - "summary": "Delete File Version", - "description": "Permanently delete one earlier version and its stored content, for example to purge a leaked value from history before retention removes it. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.versions.delete", + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.versions.delete", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "File identifier within the Project." } }, { @@ -1116,23 +1182,11 @@ "maximum": 2147483647, "description": "Version number." } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } } ], "responses": { "200": { - "description": "Deletion confirmation.", + "description": "Deletion acknowledgement for the superseded version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1147,7 +1201,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionDeleteResponse" + "$ref": "#/components/schemas/V2ProjectFileVersionDeleteResponse" } } } @@ -1179,92 +1233,53 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}/text": { + "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/content": { "get": { - "operationId": "readFileVersionText", - "summary": "Read File Version Text", - "description": "Extract the text of one version, exactly as Read File Text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.read_content", + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.versions.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "version", + "name": "fileId", "in": "path", "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "maxBytes", - "in": "query", - "required": false, - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "schema": { - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "type": "integer", - "minimum": 1, - "maximum": 26214400 - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "schema": { - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "description": "File identifier within the Project." } }, { - "name": "limit", - "in": "query", - "required": false, - "description": "How many lines to return from `offset`. Absent reads to the end.", + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", "schema": { - "description": "How many lines to return from `offset`. Absent reads to the end.", "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } } ], "responses": { "200": { - "description": "The extracted text of the version and its extraction-quality flags.", + "description": "The selected version’s stored source bytes.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1277,9 +1292,10 @@ } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/FileVersionTextResponse" + "type": "string", + "format": "binary" } } } @@ -1314,135 +1330,35 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}/content": { - "get": { - "operationId": "downloadFileVersion", - "summary": "Download File Version", - "description": "Download the bytes of one version, served exactly as Download File serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.download", - "x-oauth-scope": "api:read", + "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/revert": { + "post": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.versions.revert", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "version", + "name": "projectId", "in": "path", "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - } - ], - "responses": { - "200": { - "description": "The version bytes.", - "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "Content-Length": { - "$ref": "#/components/headers/Content-Length" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } + "description": "Project identifier." } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/versions/{version}/revert": { - "post": { - "operationId": "revertFileVersion", - "summary": "Revert File Version", - "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MB returns `413`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.versions.revert", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "parameters": [ { "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "File identifier within the Project." } }, { @@ -1460,18 +1376,18 @@ ], "requestBody": { "required": true, - "description": "Workspace scope and an optional current-version precondition.", + "description": "Optional revision or current-version preconditions.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevertFileVersionRequest" + "$ref": "#/components/schemas/RevertProjectFileVersionRequest" } } } }, "responses": { "200": { - "description": "The file and its current version after the revert.", + "description": "The file and current version after the revert.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1486,7 +1402,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionRevertResponse" + "$ref": "#/components/schemas/V2ProjectFileVersionRevertResponse" } } } @@ -1524,58 +1440,43 @@ } } }, - "/api/v2/files/bulk-download": { + "/api/v2/projects/{projectId}/files/folders": { "get": { - "operationId": "bulkDownloadFiles", - "summary": "Bulk Download Files", - "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.download", + "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. Returns the complete set in 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`.", + "x-sim-operation": "project_file_folders.list", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "projectId", + "in": "path", "required": true, - "description": "Workspace containing the selection.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the selection." - } - }, - { - "name": "fileIds", - "in": "query", - "required": false, - "description": "File identifiers to include, comma-separated. At most 100 entries.", - "schema": { - "description": "File identifiers to include, comma-separated. At most 100 entries.", - "type": "string" + "description": "Project identifier." } }, { - "name": "folderPaths", + "name": "scope", "in": "query", "required": false, - "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "description": "Folder lifecycle scope.", "schema": { - "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", - "type": "string" + "default": "active", + "description": "Folder lifecycle scope.", + "type": "string", + "enum": ["active", "archived", "all"] } } ], "responses": { "200": { - "description": "The selected files as a zip archive.", + "description": "The complete set of folders in the selected lifecycle scope.", "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1587,10 +1488,9 @@ } }, "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/V2ProjectFileFolderListResponse" } } } @@ -1607,9 +1507,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1620,45 +1517,41 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/{fileId}/unzip": { + }, "post": { - "operationId": "unzipFile", - "summary": "Unzip File", - "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.extract_archive", + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_folders.create", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } } ], "requestBody": { "required": true, - "description": "Workspace scope for the archive.", + "description": "The folder name and optional parent identifier.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnzipFileBody" + "$ref": "#/components/schemas/CreateProjectFileFolderRequest" } } } }, "responses": { - "200": { - "description": "Counts and destination folder for the unpacked archive.", + "201": { + "description": "The created folder and its owner and creator attribution.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1673,7 +1566,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUnzipResponse" + "$ref": "#/components/schemas/V2ProjectFileFolderResponse" } } } @@ -1711,54 +1604,53 @@ } } }, - "/api/v2/files/{fileId}": { - "get": { - "operationId": "downloadFile", - "summary": "Download File", - "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.download", - "x-oauth-scope": "api:read", + "/api/v2/projects/{projectId}/files/folders/{folderId}": { + "patch": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_folders.update", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "folderId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "Folder identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Folder identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "The fields to change on the folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectFileFolderRequest" + } + } + } + }, "responses": { "200": { - "description": "The file bytes.", + "description": "The updated folder.", "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "Content-Length": { - "$ref": "#/components/headers/Content-Length" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1770,10 +1662,9 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/V2ProjectFileFolderResponse" } } } @@ -1796,6 +1687,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1806,44 +1700,54 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteFile", - "summary": "Delete File", - "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.delete", + } + }, + "/api/v2/projects/{projectId}/files/folders/{folderId}/restore": { + "post": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_folders.restore", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "folderId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "Folder identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Folder identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "An empty object; the folder is identified in the path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreProjectFileFolderRequest" + } + } + } + }, "responses": { "200": { - "description": "Deletion confirmation.", + "description": "The restored folder and affected item counts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1858,7 +1762,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2DeleteFileResponse" + "$ref": "#/components/schemas/V2RestoreProjectFileFolderResponse" } } } @@ -1875,53 +1779,62 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" }, "503": { "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "renameFile", - "summary": "Rename File", - "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.rename", + } + }, + "/api/v2/projects/{projectId}/files/uploads": { + "post": { + "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. The original API credential and upload-token are required on every control request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.create", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } } ], "requestBody": { "required": true, - "description": "Workspace scope and new file name.", + "description": "The parameters for this upload operation.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RenameFileRequest" + "$ref": "#/components/schemas/ProjectUploadCreateRequest" } } } }, "responses": { - "200": { - "description": "The renamed file.", + "201": { + "description": "Create Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1936,7 +1849,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" + "$ref": "#/components/schemas/V2CreateProjectFileUploadResponse" } } } @@ -1974,43 +1887,52 @@ } } }, - "/api/v2/files/{fileId}/restore": { - "post": { - "operationId": "restoreFile", - "summary": "Restore File", - "description": "Restore a soft-deleted file to its original folder, or the workspace root if that folder was archived. Name collisions add a `_restored` suffix; read `folderPath` and `name` from the response. Already-active files return unchanged, making retries safe. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.restore", + "/api/v2/projects/{projectId}/files/uploads/{uploadId}": { + "get": { + "operationId": "getProjectFileUpload", + "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:write`.", + "x-sim-operation": "project_file_uploads.read", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope for the archived file.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreFileRequest" - } + }, + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier within the Project." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { "200": { - "description": "The file as it exists after the restore.", + "description": "Get Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2025,7 +1947,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2RestoreFileResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadResponse" } } } @@ -2061,58 +1983,52 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/{fileId}/metadata": { - "get": { - "operationId": "getFile", - "summary": "Get File Metadata", - "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.read_metadata", - "x-oauth-scope": "api:read", + }, + "delete": { + "operationId": "abortProjectFileUpload", + "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`.", + "x-sim-operation": "project_file_uploads.cancel", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "Upload session identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Upload session identifier within the Project." } }, { - "name": "scope", - "in": "query", - "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "type": "string", - "enum": ["active", "archived"] + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], "responses": { "200": { - "description": "File metadata and public-share state.", + "description": "Abort Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2127,7 +2043,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileMetadataResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadResponse" } } } @@ -2144,6 +2060,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2156,142 +2081,52 @@ } } }, - "/api/v2/audit-logs": { - "get": { - "operationId": "listAuditLogs", - "summary": "List Audit Logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "audit_logs.list", - "x-oauth-scope": "api:read", - "tags": ["Audit Logs"], + "/api/v2/projects/{projectId}/files/uploads/{uploadId}/complete": { + "post": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.complete", + "x-oauth-scope": "api:write", + "tags": ["Files"], "parameters": [ { - "name": "action", - "in": "query", - "required": false, - "description": "Filter by exact action name.", - "schema": { - "description": "Filter by exact action name.", - "type": "string" - } - }, - { - "name": "resourceType", - "in": "query", - "required": false, - "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", - "schema": { - "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", - "type": "string" - } - }, - { - "name": "resourceId", - "in": "query", - "required": false, - "description": "Filter by exact resource identifier.", - "schema": { - "description": "Filter by exact resource identifier.", - "type": "string" - } - }, - { - "name": "workspaceId", - "in": "query", - "required": false, - "description": "Filter to actions in one workspace.", + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", "schema": { - "description": "Filter to actions in one workspace.", "type": "string", "minLength": 1, - "maxLength": 128 - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "includeDeparted", - "in": "query", - "required": false, - "description": "Include actions by users who have left the organization.", - "schema": { - "description": "Include actions by users who have left the organization.", - "type": "boolean" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "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.", - "schema": { - "description": "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.", - "type": "string", - "minLength": 1 + "description": "Project identifier." } }, { - "name": "organizationId", - "in": "query", - "required": false, - "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier within the Project.", "schema": { - "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", "type": "string", - "minLength": 1 + "minLength": 1, + "description": "Upload session identifier within the Project." } }, { - "name": "actorEmail", - "in": "query", - "required": false, - "description": "Filter by actor email address.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "description": "Filter by actor email address.", "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], "responses": { "200": { - "description": "A page of audit-log entries.", + "description": "Complete Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2306,7 +2141,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2AuditLogListResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadResponse" } } } @@ -2320,6 +2155,18 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2332,106 +2179,63 @@ } } }, - "/api/v2/audit-logs/{auditLogId}": { - "get": { - "operationId": "getAuditLog", - "summary": "Get Audit Log", - "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "audit_logs.read_detail", - "x-oauth-scope": "api:read", - "tags": ["Audit Logs"], + "/api/v2/projects/{projectId}/files/uploads/{uploadId}/parts": { + "post": { + "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.parts", + "x-oauth-scope": "api:write", + "tags": ["Files"], "parameters": [ { - "name": "auditLogId", + "name": "projectId", "in": "path", "required": true, - "description": "Audit-log entry identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Audit-log entry identifier." + "description": "Project identifier." } }, { - "name": "organizationId", - "in": "query", - "required": false, - "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier within the Project.", "schema": { - "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", "type": "string", - "minLength": 1 - } - } - ], - "responses": { - "200": { - "description": "The requested audit-log entry.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2AuditLogResponse" - } - } + "minLength": 1, + "description": "Upload session identifier within the Project." } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } } - } - } - }, - "/api/v2/files/move": { - "post": { - "operationId": "moveFileItems", - "summary": "Move Files", - "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.move", - "x-oauth-scope": "api:write", - "tags": ["Files"], + ], "requestBody": { "required": true, - "description": "Files and destination selected for a bulk move.", + "description": "The parameters for this upload operation.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveFileItemsRequest" + "$ref": "#/components/schemas/ProjectFileUploadPartUrlsRequest" } } } }, "responses": { "200": { - "description": "Count of moved files.", + "description": "Get Project File Upload Part URLs result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2446,7 +2250,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2MoveFileItemsResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadPartUrlsResponse" } } } @@ -2484,44 +2288,135 @@ } } }, - "/api/v2/files/{fileId}/share": { + "/api/v2/files": { "get": { - "operationId": "getFileShare", - "summary": "Get File Share", - "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.share.read", + "operationId": "listFiles", + "summary": "List Files", + "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.list", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "File identifier.", + "description": "Workspace whose files should be listed.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Workspace whose files should be listed." } }, { - "name": "workspaceId", + "name": "folderPath", "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "required": false, + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "schema": { + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "default": "active", + "description": "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.", + "type": "string", + "enum": ["active", "archived"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the file name.", "schema": { + "description": "Case-insensitive substring match against the file name.", "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "uploadedAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "schema": { + "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "type": "integer", + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "description": "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.", + "type": "string", + "minLength": 1 } } ], "responses": { "200": { - "description": "Current nullable file-share state.", + "description": "A page of workspace files.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2536,7 +2431,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" + "$ref": "#/components/schemas/V2FileListResponse" } } } @@ -2553,6 +2448,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2564,42 +2462,27 @@ } } }, - "patch": { - "operationId": "upsertFileShare", - "summary": "Enable or Disable File Share", - "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.share.update", + "post": { + "operationId": "createFile", + "summary": "Create File", + "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.create", "x-oauth-scope": "api:write", "tags": ["Files"], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], "requestBody": { "required": true, - "description": "Desired public-share state and access policy.", + "description": "Inline content and placement for a new workspace file.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertFileShareRequest" + "$ref": "#/components/schemas/CreateFileRequest" } } } }, "responses": { - "200": { - "description": "The updated file share.", + "201": { + "description": "The created file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2614,7 +2497,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" + "$ref": "#/components/schemas/V2CreatedFileResponse" } } } @@ -2631,6 +2514,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -2649,43 +2535,28 @@ } } }, - "/api/v2/files/{fileId}/content": { - "patch": { - "operationId": "editFileContent", - "summary": "Edit File Content", - "description": "Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.update_content", + "/api/v2/files/uploads": { + "post": { + "operationId": "createFileUpload", + "summary": "Create File Upload", + "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.create", "x-oauth-scope": "api:write", "tags": ["Files"], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], "requestBody": { "required": true, - "description": "Workspace scope and the change to apply.", + "description": "File metadata required to create an upload session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditFileContentRequest" + "$ref": "#/components/schemas/CreateFileUploadRequest" } } } }, "responses": { - "200": { - "description": "The edited file and its new line count.", + "201": { + "description": "The created upload session and transfer instructions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2700,7 +2571,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2EditedFileResponse" + "$ref": "#/components/schemas/CreateFileUploadResponse" } } } @@ -2717,18 +2588,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2739,43 +2604,55 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "put": { - "operationId": "updateFileContent", - "summary": "Replace File Content", - "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.update_content", - "x-oauth-scope": "api:write", + } + }, + "/api/v2/files/uploads/{uploadId}": { + "get": { + "operationId": "getFileUpload", + "summary": "Get File Upload", + "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.upload.read", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "uploadId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Upload session identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the upload session.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Workspace that owns the upload session." } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and complete replacement content.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFileContentRequest" - } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { "200": { - "description": "The updated file.", + "description": "Current upload-session state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2790,7 +2667,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2WrittenFileResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -2807,15 +2684,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2826,104 +2694,53 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/search": { - "get": { - "operationId": "searchFileContent", - "summary": "Search File Content", - "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.search_content", - "x-oauth-scope": "api:read", + }, + "delete": { + "operationId": "abortFileUpload", + "summary": "Abort File Upload", + "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.cancel", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Workspace to search.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace to search." + "description": "Upload session identifier." } }, { - "name": "query", + "name": "workspaceId", "in": "query", "required": true, - "description": "Regular expression, or exact text when `mode` is `exact`.", + "description": "Workspace that owns the upload session.", "schema": { "type": "string", - "description": "Regular expression, or exact text when `mode` is `exact`.", - "minLength": 3, - "maxLength": 512 + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." } }, { - "name": "mode", - "in": "query", - "required": false, - "description": "How `query` is read.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "default": "regex", - "description": "How `query` is read.", "type": "string", - "enum": ["exact", "regex"] - } - }, - { - "name": "maxResults", - "in": "query", - "required": false, - "description": "Maximum matching lines to return.", - "schema": { - "default": 50, - "description": "Maximum matching lines to return.", - "type": "integer", - "minimum": 1, - "maximum": 200 - } - }, - { - "name": "folderPaths", - "in": "query", - "required": false, - "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", - "schema": { - "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", - "type": "string" - } - }, - { - "name": "includeSubfolders", - "in": "query", - "required": false, - "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "schema": { - "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], "responses": { "200": { - "description": "Matching lines and the index coverage they were drawn from.", + "description": "The aborted upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2938,7 +2755,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileSearchResultsResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -2955,8 +2772,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "423": { - "$ref": "#/components/responses/Locked" + "409": { + "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -2970,29 +2787,65 @@ } } }, - "/api/v2/files/bulk-delete": { + "/api/v2/files/uploads/{uploadId}/parts": { "post": { - "operationId": "bulkDeleteFiles", - "summary": "Delete Files", - "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.delete", + "operationId": "createFileUploadPartUrls", + "summary": "Create File Upload Part URLs", + "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.parts", "x-oauth-scope": "api:write", "tags": ["Files"], - "requestBody": { - "required": true, - "description": "Workspace and files selected for deletion.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkDeleteFilesRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Count of deleted files.", - "headers": { + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the upload session.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } + } + ], + "requestBody": { + "required": true, + "description": "Multipart part numbers requiring signed URLs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileUploadPartUrlsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested upload parts.", + "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -3006,7 +2859,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkDeleteFilesResponse" + "$ref": "#/components/schemas/CreateFileUploadPartUrlsResponse" } } } @@ -3023,6 +2876,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -3041,125 +2897,172 @@ } } }, - "/api/v2/files/folders": { - "get": { - "operationId": "listFilesFolders", - "summary": "List Folders", - "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.folders.list", - "x-oauth-scope": "api:read", + "/api/v2/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeFileUpload", + "summary": "Complete File Upload", + "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.complete", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Workspace whose folders should be listed.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." + "description": "Upload session identifier." } }, { - "name": "parentPath", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "required": true, + "description": "Workspace that owns the upload session.", "schema": { - "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." } }, { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder name.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "description": "Case-insensitive substring match against the folder name.", "type": "string", "minLength": 1, - "maxLength": 200 + "description": "Signed upload control token returned when the upload session was created." + } + } + ], + "responses": { + "200": { + "description": "The completed or finalizing upload session.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadResponse" + } + } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/text": { + "get": { + "operationId": "readFileText", + "summary": "Read File Text", + "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_content", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", - "enum": ["name", "createdAt", "updatedAt"] + "minLength": 1, + "maxLength": 4096, + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." } }, { - "name": "sortOrder", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Sort direction.", + "required": true, + "description": "Workspace that owns the file.", "schema": { - "default": "asc", - "description": "Sort direction.", "type": "string", - "enum": ["asc", "desc"] + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } }, { - "name": "scope", + "name": "maxBytes", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", "schema": { - "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", - "type": "string", - "enum": ["active", "archived"] + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 } }, { - "name": "recursive", + "name": "offset", "in": "query", "required": false, - "description": "Whether parentPath includes every descendant instead of direct children only.", + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", "schema": { - "description": "Whether parentPath includes every descendant instead of direct children only.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, { - "name": "depth", + "name": "limit", "in": "query", "required": false, - "description": "Deepest level below parentPath to include when recursive is true.", + "description": "How many lines to return from `offset`. Absent reads to the end.", "schema": { - "description": "Deepest level below parentPath to include when recursive is true.", + "description": "How many lines to return from `offset`. Absent reads to the end.", "type": "integer", "minimum": 1, - "maximum": 64 + "maximum": 9007199254740991 } } ], "responses": { "200": { - "description": "Workspace file folders.", + "description": "The extracted text and its extraction-quality flags.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3174,7 +3077,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderListResponse" + "$ref": "#/components/schemas/FileTextResponse" } } } @@ -3191,6 +3094,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3201,28 +3110,94 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "post": { - "operationId": "createFilesFolder", - "summary": "Create Folder", - "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.create", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "requestBody": { - "required": true, - "description": "Workspace and canonical path for a new folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFileFolderRequest" - } + } + }, + "/api/v2/files/{fileId}/versions": { + "get": { + "operationId": "listFileVersions", + "summary": "List File Versions", + "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "version", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["version"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "description": "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.", + "type": "string", + "minLength": 1 } } - }, + ], "responses": { - "201": { - "description": "The created folder.", + "200": { + "description": "A page of file versions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3237,7 +3212,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderResponse" + "$ref": "#/components/schemas/V2FileVersionListResponse" } } } @@ -3254,15 +3229,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3273,28 +3239,58 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "relocateFilesFolder", - "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.update", - "x-oauth-scope": "api:write", + } + }, + "/api/v2/files/{fileId}/versions/{version}": { + "get": { + "operationId": "getFileVersion", + "summary": "Get File Version", + "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read", + "x-oauth-scope": "api:read", "tags": ["Files"], - "requestBody": { - "required": true, - "description": "Current and destination canonical paths for a folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RelocateFileFolderRequest" - } + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } } - }, + ], "responses": { "200": { - "description": "The relocated folder.", + "description": "The file version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3309,7 +3305,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderResponse" + "$ref": "#/components/schemas/V2FileVersionResponse" } } } @@ -3326,15 +3322,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3347,64 +3334,54 @@ } }, "delete": { - "operationId": "deleteFilesFolder", - "summary": "Delete Folder", - "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.delete", + "operationId": "deleteFileVersion", + "summary": "Delete File Version", + "description": "Permanently delete one earlier version and its stored content, for example to purge a leaked value from history before retention removes it. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.delete", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "fileId", + "in": "path", "required": true, - "description": "Workspace containing the folder.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace containing the folder." + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { - "name": "path", - "in": "query", + "name": "version", + "in": "path", "required": true, - "description": "Path of the folder to delete.", + "description": "Version number.", "schema": { - "description": "Path of the folder to delete.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } }, { - "name": "recursive", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "required": true, + "description": "Workspace that owns the file.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "default": "false", - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } } ], "responses": { "200": { - "description": "Folder deletion confirmation and deleted item counts.", + "description": "Deletion confirmation.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3419,7 +3396,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteFileFolderResponse" + "$ref": "#/components/schemas/V2FileVersionDeleteResponse" } } } @@ -3451,28 +3428,92 @@ } } }, - "/api/v2/files/folders/restore": { - "post": { - "operationId": "restoreFilesFolder", - "summary": "Restore Folder", - "description": "Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.restore", - "x-oauth-scope": "api:write", + "/api/v2/files/{fileId}/versions/{version}/text": { + "get": { + "operationId": "readFileVersionText", + "summary": "Read File Version Text", + "description": "Extract the text of one version, exactly as Read File Text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read_content", + "x-oauth-scope": "api:read", "tags": ["Files"], - "requestBody": { - "required": true, - "description": "Workspace scope and archived folder path.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreFileFolderRequest" - } + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 } } - }, + ], "responses": { "200": { - "description": "The restored folder and what it brought back.", + "description": "The extracted text of the version and its extraction-quality flags.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3487,7 +3528,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderRestoreResponse" + "$ref": "#/components/schemas/FileVersionTextResponse" } } } @@ -3510,9 +3551,6 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3524,391 +3562,4215 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } - }, - "headers": { - "Content-Type": { - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", - "schema": { - "type": "string", - "title": "Content type", - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." - } - }, - "Content-Disposition": { - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", - "schema": { - "type": "string", - "title": "Content disposition", - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." - } - }, - "Content-Length": { - "description": "File size in bytes.", - "schema": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)$", - "title": "Content length", - "description": "File size in bytes." - } - }, - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { + "/api/v2/files/{fileId}/versions/{version}/content": { + "get": { + "operationId": "downloadFileVersion", + "summary": "Download File Version", + "description": "Download the bytes of one version, served exactly as Download File serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.download", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } - } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" - } + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } - } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + ], + "responses": { + "200": { + "description": "The version bytes.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + } + }, + "/api/v2/files/{fileId}/versions/{version}/revert": { + "post": { + "operationId": "revertFileVersion", + "summary": "Revert File Version", + "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MB returns `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.revert", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "The request conflicts with the current resource state" + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and an optional current-version precondition.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertFileVersionRequest" } } } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "responses": { + "200": { + "description": "The file and its current version after the revert.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileVersionRevertResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { + } + }, + "/api/v2/files/bulk-download": { + "get": { + "operationId": "bulkDownloadFiles", + "summary": "Bulk Download Files", + "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the selection.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the selection." + } + }, + { + "name": "fileIds", + "in": "query", + "required": false, + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "schema": { + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "type": "string" + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "schema": { + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "type": "string" } } - } + ], + "responses": { + "200": { + "description": "The selected files as a zip archive.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/unzip": { + "post": { + "operationId": "unzipFile", + "summary": "Unzip File", + "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.extract_archive", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archive.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnzipFileBody" + } + } + } + }, + "responses": { + "200": { + "description": "Counts and destination folder for the unpacked archive.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUnzipResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "The file bytes.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "Deletion confirmation.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "renameFile", + "summary": "Rename File", + "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.rename", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and new file name.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameFileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/restore": { + "post": { + "operationId": "restoreFile", + "summary": "Restore File", + "description": "Restore a soft-deleted file to its original folder, or the workspace root if that folder was archived. Name collisions add a `_restored` suffix; read `folderPath` and `name` from the response. Already-active files return unchanged, making retries safe. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.restore", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archived file.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The file as it exists after the restore.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2RestoreFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/metadata": { + "get": { + "operationId": "getFile", + "summary": "Get File Metadata", + "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_metadata", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "schema": { + "default": "active", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] + } + } + ], + "responses": { + "200": { + "description": "File metadata and public-share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileMetadataResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.list", + "x-oauth-scope": "api:read", + "tags": ["Audit Logs"], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by exact action name.", + "schema": { + "description": "Filter by exact action name.", + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", + "schema": { + "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by exact resource identifier.", + "schema": { + "description": "Filter by exact resource identifier.", + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter to actions in one workspace.", + "schema": { + "description": "Filter to actions in one workspace.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "Include actions by users who have left the organization.", + "schema": { + "description": "Include actions by users who have left the organization.", + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "description": "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.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "organizationId", + "in": "query", + "required": false, + "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "schema": { + "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "actorEmail", + "in": "query", + "required": false, + "description": "Filter by actor email address.", + "schema": { + "description": "Filter by actor email address.", + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } + } + ], + "responses": { + "200": { + "description": "A page of audit-log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/audit-logs/{auditLogId}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.read_detail", + "x-oauth-scope": "api:read", + "tags": ["Audit Logs"], + "parameters": [ + { + "name": "auditLogId", + "in": "path", + "required": true, + "description": "Audit-log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Audit-log entry identifier." + } + }, + { + "name": "organizationId", + "in": "query", + "required": false, + "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "schema": { + "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "The requested audit-log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/move": { + "post": { + "operationId": "moveFileItems", + "summary": "Move Files", + "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.move", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Files and destination selected for a bulk move.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveFileItemsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Count of moved files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2MoveFileItemsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.share.read", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "Current nullable file-share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2GetFileShareResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.share.update", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Desired public-share state and access policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertFileShareRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated file share.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UpsertFileShareResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/content": { + "patch": { + "operationId": "editFileContent", + "summary": "Edit File Content", + "description": "Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the change to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditFileContentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The edited file and its new line count.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2EditedFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and complete replacement content.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFileContentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2WrittenFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/search": { + "get": { + "operationId": "searchFileContent", + "summary": "Search File Content", + "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.search_content", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace to search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace to search." + } + }, + { + "name": "query", + "in": "query", + "required": true, + "description": "Regular expression, or exact text when `mode` is `exact`.", + "schema": { + "type": "string", + "description": "Regular expression, or exact text when `mode` is `exact`.", + "minLength": 3, + "maxLength": 512 + } + }, + { + "name": "mode", + "in": "query", + "required": false, + "description": "How `query` is read.", + "schema": { + "default": "regex", + "description": "How `query` is read.", + "type": "string", + "enum": ["exact", "regex"] + } + }, + { + "name": "maxResults", + "in": "query", + "required": false, + "description": "Maximum matching lines to return.", + "schema": { + "default": 50, + "description": "Maximum matching lines to return.", + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "schema": { + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "type": "string" + } + }, + { + "name": "includeSubfolders", + "in": "query", + "required": false, + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Matching lines and the index coverage they were drawn from.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileSearchResultsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/bulk-delete": { + "post": { + "operationId": "bulkDeleteFiles", + "summary": "Delete Files", + "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace and files selected for deletion.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteFilesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Count of deleted files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteFilesResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/folders": { + "get": { + "operationId": "listFilesFolders", + "summary": "List Folders", + "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.folders.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose folders should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose folders should be listed." + } + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder name.", + "schema": { + "description": "Case-insensitive substring match against the folder name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether parentPath includes every descendant instead of direct children only.", + "schema": { + "description": "Whether parentPath includes every descendant instead of direct children only.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "depth", + "in": "query", + "required": false, + "description": "Deepest level below parentPath to include when recursive is true.", + "schema": { + "description": "Deepest level below parentPath to include when recursive is true.", + "type": "integer", + "minimum": 1, + "maximum": 64 + } + } + ], + "responses": { + "200": { + "description": "Workspace file folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createFilesFolder", + "summary": "Create Folder", + "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.create", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace and canonical path for a new folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileFolderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "relocateFilesFolder", + "summary": "Rename or Move Folder", + "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.update", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Current and destination canonical paths for a folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateFileFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The relocated folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteFilesFolder", + "summary": "Delete Folder", + "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the folder.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + } + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Path of the folder to delete.", + "schema": { + "description": "Path of the folder to delete.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "default": "false", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Folder deletion confirmation and deleted item counts.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteFileFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/folders/restore": { + "post": { + "operationId": "restoreFilesFolder", + "summary": "Restore Folder", + "description": "Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.restore", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace scope and archived folder path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The restored folder and what it brought back.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderRestoreResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." + } + }, + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } + }, + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } + }, + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "The request conflicts with the current resource state" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "Locked": { + "description": "The resource is temporarily locked or unavailable; retry the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "The file or its search index is temporarily locked; retry the request" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "V2ProjectFile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "examples": ["wf_V1StGXR8z5jdHi6BmyT91"] + }, + "name": { + "type": "string", + "description": "Original file name.", + "examples": ["data.csv"] + }, + "size": { + "type": "number", + "minimum": 0, + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", + "examples": [1024] + }, + "type": { + "type": "string", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", + "examples": ["text/csv"] + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "examples": ["workspace/example/data.csv"] + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the Project root.", + "maxLength": 4096 + }, + "uploadedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the file was uploaded.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last content or metadata write.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The file owner is a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical owner of the shared file." + }, + "uploadedBy": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "size", + "type", + "key", + "folderPath", + "uploadedAt", + "updatedAt", + "deletedAt", + "owner", + "uploadedBy" + ], + "additionalProperties": false, + "title": "Project file" + }, + "V2ProjectFileMetadataResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ProjectFile" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project file metadata response", + "description": "Metadata for one authorized Project file." + }, + "RenameProjectFileRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New file name, including its extension." + } + }, + "required": ["name"], + "additionalProperties": false, + "title": "Rename Project File request", + "description": "The new file name." + }, + "V2MoveProjectFileItemsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "movedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of files moved." + }, + "movedFolders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of folders moved." + }, + "movedFileIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of moved files." + }, + "movedFolderIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of moved folders." + } + }, + "required": ["movedFiles", "movedFolders", "movedFileIds", "movedFolderIds"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project file operation response", + "description": "Counts and identifiers of moved items." + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "MoveProjectFileItemsRequest": { + "type": "object", + "properties": { + "fileIds": { + "default": [], + "description": "Identifiers of the files to move.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + }, + "folderIds": { + "default": [], + "description": "Identifiers of folders to move with their contents.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + }, + "targetFolderPath": { + "description": "Existing destination folder path within the Project. Omit to move items to the Project root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Move Project File Items request", + "description": "Selected files and folders and their destination." + }, + "V2ArchiveProjectFileItemsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deletedItems": { + "type": "object", + "properties": { + "files": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected files." + }, + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected folders." + } + }, + "required": ["files", "folders"], + "additionalProperties": false, + "description": "Counts of affected file items." + }, + "affectedIds": { + "type": "object", + "properties": { + "fileIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of affected files." + }, + "folderIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of affected folders." + } + }, + "required": ["fileIds", "folderIds"], + "additionalProperties": false, + "description": "Identifiers of affected file items." + } + }, + "required": ["deletedItems", "affectedIds"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project file operation response", + "description": "Counts and identifiers of archived items." + }, + "ArchiveProjectFileItemsRequest": { + "type": "object", + "properties": { + "fileIds": { + "default": [], + "description": "Identifiers of the files to archive.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + }, + "folderIds": { + "default": [], + "description": "Identifiers of folders to archive recursively, including their files and descendants.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "Archive Project File Items request", + "description": "Files and folders to archive." + }, + "RestoreProjectFileRequest": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "title": "Restore Project file request", + "description": "An empty object; the file is identified by its path parameters." + }, + "CreateProjectFileRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File name, including its extension. Path separators and dot segments are rejected." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MIME type. When omitted, it is inferred from the file extension." + }, + "content": { + "default": "", + "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.", + "type": "string", + "maxLength": 70000000 + }, + "encoding": { + "default": "utf-8", + "description": "Encoding of the content field.", + "type": "string", + "enum": ["utf-8", "base64"] + }, + "folderPath": { + "description": "Canonical containing-folder path. Omit for the Project root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["name"], + "additionalProperties": false, + "title": "Create Project file request", + "description": "Name, content, encoding, and containing folder within the Project." + }, + "UpdateProjectFileContentRequest": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 70000000, + "description": "Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`." + }, + "encoding": { + "default": "utf-8", + "description": "Encoding of the content field.", + "type": "string", + "enum": ["utf-8", "base64"] + }, + "expectedRevision": { + "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "type": "string", + "minLength": 1 + } + }, + "required": ["content"], + "additionalProperties": false, + "title": "Project content replacement", + "description": "Complete replacement bytes and an optional optimistic concurrency revision." + }, + "V2ProjectFileListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ProjectFile" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Project file list response", + "description": "A page of files owned by the requested Project." + }, + "ProjectFileVersion": { + "title": "Project file version", + "description": "One recorded version of a shared Project file.", + "$ref": "#/components/schemas/V2FileVersion" + }, + "V2FileVersion": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "title": "File version", + "description": "One recorded version of the content of a workspace file." + }, + "V2ProjectFileVersionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectFileVersion" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Project File Versions response", + "description": "A cursor-paginated page of Project file versions." + }, + "V2ProjectFileVersionResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": [ + "upload", + "user", + "api", + "copilot", + "workflow", + "collab", + "revert", + "unknown" + ], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "description": "Response data.", + "$ref": "#/components/schemas/ProjectFileVersion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Project File Version response", + "description": "Metadata for the selected version." + }, + "V2ProjectFileVersionRevertResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "reverted": { + "type": "boolean", + "description": "False if the selected version is already current." + }, + "file": { + "$ref": "#/components/schemas/V2ProjectFile" + }, + "version": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": [ + "upload", + "user", + "api", + "copilot", + "workflow", + "collab", + "revert", + "unknown" + ], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "description": "The current version after the revert.", + "$ref": "#/components/schemas/ProjectFileVersion" + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" + } + }, + "required": ["reverted", "file", "version"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revert Project File Version response", + "description": "The file and current version after the revert." + }, + "RevertProjectFileVersionRequest": { + "type": "object", + "properties": { + "expectedCurrentVersion": { + "description": "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.", + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "expectedRevision": { + "description": "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.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false, + "title": "Revert Project file version request", + "description": "Optional revision or current-version preconditions." + }, + "V2ProjectFileVersionDeleteResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File whose version was deleted." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number that was deleted." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "The version is no longer available; stored-object cleanup is retried asynchronously when needed." + } + }, + "required": ["fileId", "version", "deleted"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete Project File Version response", + "description": "Deletion acknowledgement for the superseded version." + }, + "V2ProjectFileFolderListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable folder identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "name": { + "type": "string", + "description": "Folder name." + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the root." + }, + "path": { + "type": "string", + "description": "Display path with slash characters in folder names escaped." + }, + "sortOrder": { + "type": "number", + "description": "Position within its parent folder." + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Archive time, or null for an active folder." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the folder was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the folder was last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The folder is owned by a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical Project owner of the folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Project folder list response", + "description": "Folders in their existing manual order; nextCursor is always null." + }, + "V2ProjectFileFolderResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable folder identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "name": { + "type": "string", + "description": "Folder name." + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the root." + }, + "path": { + "type": "string", + "description": "Display path with slash characters in folder names escaped." + }, + "sortOrder": { + "type": "number", + "description": "Position within its parent folder." + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Archive time, or null for an active folder." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the folder was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the folder was last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The folder is owned by a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical Project owner of the folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project folder response", + "description": "One folder with canonical ownership and creator attribution." + }, + "CreateProjectFileFolderRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name for the new folder." + }, + "parentId": { + "description": "Parent folder identifier; omit or use null for the root.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + }, + "required": ["name"], + "additionalProperties": false, + "title": "Create Project folder request", + "description": "The folder name and optional parent identifier." + }, + "UpdateProjectFileFolderRequest": { + "type": "object", + "properties": { + "name": { + "description": "New folder name; omit to leave unchanged.", + "type": "string", + "minLength": 1 + }, + "parentId": { + "description": "New parent folder identifier; null moves to the root, omission leaves the parent unchanged.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "sortOrder": { + "description": "New manual position; omit to leave unchanged.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false, + "title": "Update Project folder request", + "description": "The fields to change on the folder." + }, + "V2RestoreProjectFileFolderResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable folder identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "name": { + "type": "string", + "description": "Folder name." + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the root." + }, + "path": { + "type": "string", + "description": "Display path with slash characters in folder names escaped." + }, + "sortOrder": { + "type": "number", + "description": "Position within its parent folder." + }, + "deletedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Archive time, or null for an active folder." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the folder was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Time the folder was last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The folder is owned by a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical Project owner of the folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false, + "description": "Restored Project folder." + }, + "restoredItems": { + "type": "object", + "properties": { + "files": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected files." + }, + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected folders." + } + }, + "required": ["files", "folders"], + "additionalProperties": false, + "description": "Counts of affected file items." + } + }, + "required": ["folder", "restoredItems"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Restore Project folder response", + "description": "The restored folder and affected item counts." + }, + "RestoreProjectFileFolderRequest": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "title": "Restore Project folder request", + "description": "An empty object; the folder is identified in the path." + }, + "V2ProjectFileUpload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Upload session identifier." + }, + "status": { + "type": "string", + "enum": [ + "uploading", + "completing", + "finalizing", + "completed", + "failed", + "aborting", + "aborted", + "expired" + ], + "description": "Current upload session status." + }, + "name": { + "type": "string", + "description": "File name supplied when the session was created." + }, + "contentType": { + "type": "string", + "description": "MIME type supplied when the session was created." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Expected file size in bytes." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 time when the upload session expires." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Failure message, or null when no failure has occurred." + }, + "file": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2ProjectFile" + }, + { + "type": "null" + } + ], + "description": "Registered Project file after finalization, or null before registration or after archival." + } + }, + "required": ["id", "status", "name", "contentType", "size", "expiresAt", "error", "file"], + "additionalProperties": false, + "title": "Project file upload session" + }, + "V2PutUploadTransfer": { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "put", + "description": "Upload strategy discriminator." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the upload request." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." + } + }, + "required": ["method", "url", "headers", "expiresAt"], + "additionalProperties": false, + "title": "Direct upload transfer", + "description": "Instructions for uploading bytes to one signed URL." + }, + "V2MultipartUploadTransfer": { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "multipart", + "description": "Upload strategy discriminator." + }, + "partSize": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Required size of each non-final part in bytes." + }, + "partCount": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 640, + "description": "Total number of upload parts." + } + }, + "required": ["method", "partSize", "partCount"], + "additionalProperties": false, + "title": "Multipart upload transfer", + "description": "Instructions for splitting bytes into a multipart upload." }, - "Locked": { - "description": "The resource is temporarily locked or unavailable; retry the request.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "The file or its search index is temporarily locked; retry the request" + "V2CreateProjectFileUploadData": { + "type": "object", + "properties": { + "session": { + "description": "New Project upload session.", + "$ref": "#/components/schemas/V2ProjectFileUpload" + }, + "uploadToken": { + "type": "string", + "minLength": 1, + "description": "Signed control token required by later upload-session requests." + }, + "transfer": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2PutUploadTransfer" + }, + { + "$ref": "#/components/schemas/V2MultipartUploadTransfer" } - } + ], + "description": "Instructions for transferring the file bytes." } - } + }, + "required": ["session", "uploadToken", "transfer"], + "additionalProperties": false, + "title": "Create Project file upload data" }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "V2CreateProjectFileUploadResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CreateProjectFileUploadData" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" - } - } - } - } - } + "required": ["data"], + "additionalProperties": false, + "title": "Create Project File Upload response", + "description": "The authorized upload state or transfer instructions." }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" + "ProjectUploadCreateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File name, including its extension." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MIME type of the uploaded file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 5368709120, + "description": "Exact file size in bytes." + }, + "folderPath": { + "description": "Canonical destination folder path. Specify either folderId or folderPath, not both.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "folderId": { + "description": "Destination folder identifier; omit or use null for the Project root.", + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "null" } - } + ] } - } + }, + "required": ["name", "contentType", "size"], + "additionalProperties": false, + "title": "Create Project File Upload request", + "description": "The parameters for this upload operation." }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "V2ProjectFileUploadResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ProjectFileUpload" } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "required": ["data"], + "additionalProperties": false, + "title": "Project file upload response", + "description": "The authorized upload state or transfer instructions." + }, + "V2UploadPartUrl": { + "type": "object", + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Multipart part number." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } - } + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the part upload." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for the signed URL." } - } - } - }, - "schemas": { - "V2ActionableForbiddenDetails": { + }, + "required": ["partNumber", "url", "headers", "expiresAt"], + "additionalProperties": false, + "title": "Upload part URL", + "description": "A signed URL and required headers for one multipart upload part." + }, + "V2PartUrlsData": { "type": "object", "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" + "parts": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2UploadPartUrl" + }, + "description": "Signed URLs for requested parts." } }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." + "required": ["parts"], + "additionalProperties": false, + "title": "Upload part URLs", + "description": "Signed transfer URLs for the requested multipart upload parts." }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "SSO_DISABLED", - "SSO_DOMAIN_NOT_VERIFIED", - "SSO_PROVIDER_LIMIT_REACHED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "ACCESS_REQUESTS_DISABLED", - "ACCESS_REQUEST_ORGANIZATION_REQUIRED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" - ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." + "V2ProjectFileUploadPartUrlsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PartUrlsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Project File Upload Part URLs response", + "description": "The authorized upload state or transfer instructions." }, - "V2Error": { + "ProjectFileUploadPartUrlsRequest": { "type": "object", "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] - } + "partNumbers": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + "description": "Multipart part numbers for which signed URLs should be created." } }, - "required": ["error"], + "required": ["partNumbers"], "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." - } - } - ] - }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "title": "Get Project File Upload Part URLs request", + "description": "The parameters for this upload operation." }, "V2File": { "type": "object", @@ -4283,67 +8145,6 @@ "title": "File upload session", "description": "Current state of a resumable workspace-file upload session." }, - "V2PutUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "put", - "description": "Upload strategy discriminator." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the upload request." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." - } - }, - "required": ["method", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Direct upload transfer", - "description": "Instructions for uploading bytes to one signed URL." - }, - "V2MultipartUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "multipart", - "description": "Upload strategy discriminator." - }, - "partSize": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Required size of each non-final part in bytes." - }, - "partCount": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 640, - "description": "Total number of upload parts." - } - }, - "required": ["method", "partSize", "partCount"], - "additionalProperties": false, - "title": "Multipart upload transfer", - "description": "Instructions for splitting bytes into a multipart upload." - }, "V2CreateFileUploadData": { "type": "object", "properties": { @@ -4436,59 +8237,6 @@ "title": "File upload response", "description": "Current upload-session state." }, - "V2UploadPartUrl": { - "type": "object", - "properties": { - "partNumber": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "Multipart part number." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the part upload." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for the signed URL." - } - }, - "required": ["partNumber", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Upload part URL", - "description": "A signed URL and required headers for one multipart upload part." - }, - "V2PartUrlsData": { - "type": "object", - "properties": { - "parts": { - "maxItems": 100, - "type": "array", - "items": { - "$ref": "#/components/schemas/V2UploadPartUrl" - }, - "description": "Signed URLs for requested parts." - } - }, - "required": ["parts"], - "additionalProperties": false, - "title": "Upload part URLs", - "description": "Signed transfer URLs for the requested multipart upload parts." - }, "CreateFileUploadPartUrlsResponse": { "type": "object", "properties": { @@ -4644,123 +8392,6 @@ "title": "File text response", "description": "Text extracted from a workspace file." }, - "V2FileVersion": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "File this version belongs to." - }, - "version": { - "type": "integer", - "minimum": 1, - "maximum": 2147483647, - "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", - "examples": [3] - }, - "isCurrent": { - "type": "boolean", - "description": "Whether this version holds the current content of the file." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Size in bytes of the stored content of this version." - }, - "contentType": { - "type": "string", - "description": "MIME type of the stored content of this version." - }, - "source": { - "type": "string", - "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], - "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." - }, - "authors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "User identifier." - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - }, - { - "type": "null" - } - ], - "description": "Current email address of the user, or null when the account no longer exists." - } - }, - "required": ["id", "email"], - "additionalProperties": false - }, - "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." - }, - "restoredFromVersion": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 2147483647 - }, - { - "type": "null" - } - ], - "description": "For a `revert` version, the version whose content it restored; otherwise null." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this content became current.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", - "format": "date-time", - "examples": ["2026-01-15T10:38:00Z"] - }, - "supersededAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", - "format": "date-time", - "examples": ["2026-01-16T09:00:00Z"] - } - }, - "required": [ - "fileId", - "version", - "isCurrent", - "size", - "contentType", - "source", - "authors", - "restoredFromVersion", - "createdAt", - "updatedAt", - "supersededAt" - ], - "additionalProperties": false, - "title": "File version", - "description": "One recorded version of the content of a workspace file." - }, "V2FileVersionListResponse": { "type": "object", "properties": { @@ -5381,7 +9012,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.ts b/apps/realtime/src/access-revalidation.ts index 95fb65ff1db..5905fd401ba 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, @@ -329,7 +330,7 @@ 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), 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-editor.integration.ts b/apps/realtime/src/handlers/file-doc-editor.integration.ts index 4d7c4ee267d..a289624744e 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', 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..6a6502bb771 --- /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 { + requiresCurrentActor: 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: { + requiresCurrentActor: false, + 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: { + requiresCurrentActor: true, + 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..5235735d3d9 100644 --- a/apps/realtime/src/handlers/file-doc-store.test.ts +++ b/apps/realtime/src/handlers/file-doc-store.test.ts @@ -467,7 +467,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 +500,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 +774,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 +790,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 +847,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 +863,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 +885,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..e10b69a86f0 100644 --- a/apps/realtime/src/handlers/file-doc-store.ts +++ b/apps/realtime/src/handlers/file-doc-store.ts @@ -608,11 +608,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 c7510db74f0..5d316725b91 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, @@ -375,7 +375,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]( { @@ -388,7 +389,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'), @@ -793,7 +794,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') @@ -948,19 +949,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() }) @@ -978,8 +991,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 diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index ec1a64791c0..d5d23da97d0 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -42,12 +42,15 @@ 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 * as decoding from 'lib0/decoding' @@ -57,13 +60,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, @@ -123,7 +121,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 @@ -146,13 +144,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 @@ -160,8 +158,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 /** @@ -238,19 +234,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'` / @@ -347,9 +330,9 @@ function broadcastLocal( */ function schedulePersist(name: string, room: FileDocRoom): void { if ( - room.projectId + fileDocOwnerAdapter(room.owner).requiresCurrentActor ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.workspaceId || !room.lastEditorUserId + : !room.owner || !room.lastEditorUserId ) return const now = Date.now() @@ -389,20 +372,19 @@ 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 + (fileDocOwnerAdapter(room.owner).requiresCurrentActor ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.workspaceId || !room.lastEditorUserId) + : !room.owner || !room.lastEditorUserId) ) 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 @@ -417,26 +399,30 @@ 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 = + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { + snapshotEditor = room.lastEditorUserId && room.lastEditorConnectionId ? { userId: room.lastEditorUserId, connectionId: room.lastEditorConnectionId } : null } return Y.encodeStateAsUpdate(room.doc) } - if (room.projectId) projectEditor = localEditor + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) snapshotEditor = localEditor return localState } try { - if (room.projectId) { + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { const snapshot = await store.getStreamSnapshot(name, generation) - projectEditor = snapshot?.editor ?? null + snapshotEditor = snapshot?.editor ?? null return snapshot?.docState ?? null } return (await store.getStreamState(name)) ?? localState } catch (streamError) { - if (room.projectId || streamError instanceof FileDocInvalidatedError) throw streamError + if ( + fileDocOwnerAdapter(room.owner).requiresCurrentActor || + 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 @@ -487,17 +473,15 @@ 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 (fileDocOwnerAdapter(room.owner).requiresCurrentActor && !snapshotEditor) return + const actor = fileDocOwnerAdapter(room.owner).requiresCurrentActor + ? snapshotEditor + : userId + ? { userId, connectionId: room.lastEditorConnectionId } + : null + const result = actor + ? await fileDocOwnerAdapter(room.owner).persist(room, actor, docState, ifMatch) + : null if (!result) return if (result.status === 'missing') return // the file was deleted; nothing to write if (result.status === 'deferred') { @@ -659,16 +643,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`) } @@ -679,14 +661,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 @@ -707,7 +684,6 @@ class FileDocNotFoundError extends Error {} async function runServerSeed( name: string, room: FileDocRoom, - workspaceId: string | null, actor?: FileDocEditor ): Promise { const store = getFileDocStore() @@ -717,7 +693,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 @@ -733,32 +709,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. @@ -778,7 +747,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) } @@ -819,12 +788,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)) } @@ -852,11 +823,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) @@ -871,15 +842,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, @@ -887,14 +860,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) @@ -1009,8 +979,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 @@ -1022,14 +992,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, @@ -1159,19 +1127,19 @@ function isFileDocWriteAllowed( name: string ): boolean | Promise { const userId = socket.userId - const fileId = fileDocRooms.get(name)?.fileId - if (!userId || !fileId) return false + const document = fileDocRooms.get(name) + if (!userId || !document) return false - const projectId = fileDocRooms.get(name)?.projectId - const room = fileDocRoom(fileId, projectId) - if (projectId) + const { fileId, owner } = document + const room = fileDocRoom({ fileId, owner }) + if (fileDocOwnerAdapter(owner).requiresCurrentActor) 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, + ...fileDocOwnerWireFields(owner), canWrite, } satisfies FileDocPermission) if (permission === null) @@ -1219,7 +1187,11 @@ 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 + if ( + !fileDocOwnerAdapter(room.owner).requiresCurrentActor && + !isFileDocWriteAllowed(socket, io, name) + ) + return const bytes = toFileDocBytes(data) if (!bytes) return @@ -1243,12 +1215,12 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn } const decoder = decoding.createDecoder(bytes) const messageType = decoding.readVarUint(decoder) - if (room.projectId) { + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { const userId = socket.userId if (!userId) return const permission = await resolveCurrentRoomPermission( userId, - fileDocRoom(room.fileId, room.projectId), + fileDocRoom(room), 'read', socket.id ) @@ -1256,7 +1228,7 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn if (permission === null) { evictSocketFromRoom( socket, - fileDocRoom(room.fileId, room.projectId), + fileDocRoom(room), 'Your access to this document has been revoked', io ) @@ -1278,7 +1250,8 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn // 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 + if (editor && !fileDocOwnerAdapter(room.owner).requiresCurrentActor) + 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 @@ -1351,15 +1324,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' || @@ -1374,12 +1342,17 @@ 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 } @@ -1534,7 +1507,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 @@ -1566,13 +1540,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) ) { @@ -1591,7 +1559,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. @@ -1603,7 +1571,7 @@ export function setupWorkspaceFileDocHandlers( generation = joinGeneration.get(socket.id) ?? 0 } - const admissionName = fileDocAdmissionRoom(fileId, projectId) + const admissionName = fileDocAdmissionRoom(target) const authorizeJoin = () => resolveRoomJoinAuth({ @@ -1623,19 +1591,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, @@ -1666,10 +1648,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. @@ -1700,7 +1679,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 @@ -1761,7 +1740,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 @@ -1837,7 +1816,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, userName, avatarUrl }) @@ -1854,7 +1833,7 @@ export function setupWorkspaceFileDocHandlers( // Attribution for the server-side persist, refreshed to the actual editor on each edit in // `handleMessage`. - if (!projectId) entry.lastEditorUserId = userId + if (!fileDocOwnerAdapter(owner).requiresCurrentActor) 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 @@ -1865,9 +1844,14 @@ 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), + ...(store.enabled || fileDocOwnerAdapter(owner).requiresCurrentActor + ? { acknowledgedUpdates: true as const } + : {}), + ...(fileDocOwnerAdapter(owner).requiresCurrentActor + ? { + canWrite: finalPermission === 'write' || finalPermission === 'admin', + } : {}), }) // Server-authenticated roster → everyone in the room, including this joiner. @@ -1916,7 +1900,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. @@ -1963,9 +1948,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..3298293d29b 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,7 +264,7 @@ 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 + if (isProjectRoom(room)) throw error const lastKnown = roleCache.get(key) return lastKnown !== undefined ? lastKnown.role : fallbackRole } 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 a438007ef4c..8d92165cef7 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..69c3f353d37 --- /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 }) => params, + 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..1fa726fa6ab --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts @@ -0,0 +1,55 @@ +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 { readProjectFileContent, 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: readProjectFileContent.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: readProjectFileContent, + 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..419b68d8c19 --- /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 { readProjectFileVersionContent } from '@/lib/projects/files/application' +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileVersionContentContract, + auth: internalSessionAuth, + headSafe: false, + operation: readProjectFileVersionContent.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: readProjectFileVersionContent, + 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..58814a29bae --- /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-utils' +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..54057fb7d9d --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts @@ -0,0 +1,46 @@ +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 { readProjectFileContent, 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: readProjectFileContent, + 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 }, + 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..292f50d7582 --- /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 { readProjectFileVersionContent } 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: readProjectFileVersionContent, + 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..2e29ba81b6d --- /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-utils' +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..5983a8cd741 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 } } @@ -113,6 +121,74 @@ function syncStep1Frame(doc: Y.Doc): Uint8Array { } describe('FileDocProvider', () => { + 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 +244,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 +280,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 +288,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 +296,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 +317,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 +394,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 +511,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 +602,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 +637,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 +716,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 +750,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 +780,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 +820,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 +872,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 +915,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 +943,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 +989,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 +1057,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 +1119,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 +1158,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 +1203,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 +1243,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 +1286,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 +1314,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 +1367,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 +1397,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 +1426,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 +1456,8 @@ describe('FileDocProvider', () => { socket, 'file-1', firstDoc, - new awarenessProtocol.Awareness(firstDoc) + new awarenessProtocol.Awareness(firstDoc), + workspaceScope ) acceptJoin(fire, firstDoc.clientID) @@ -1328,7 +1466,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 +1513,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 +1556,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 +1594,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 +1776,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 +1805,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', () => { 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..5c152303184 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,10 @@ export class FileDocProvider extends ObservableV2 { >() synced = false + canWrite = true + private readonly owner: FileDocOwner + private readonly scopedPermissions: boolean + 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 +190,15 @@ 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.scopedPermissions = this.owner.entityType !== 'workspace' + this.canWrite = !this.scopedPermissions + 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 +215,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 +229,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 +262,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 +328,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 +406,12 @@ 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 + if (this.scopedPermissions) this.setCanWrite(data.canWrite === true) this.clearJoinAckTimer() this.joinPending = false this.joinRetryAttempt = 0 @@ -405,6 +427,12 @@ export class FileDocProvider extends ObservableV2 { return } const recoveryDocId = data.docId + if (this.scopedPermissions && !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 +440,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) }) } @@ -516,6 +544,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 +567,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 +581,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 +634,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') } @@ -693,7 +723,13 @@ 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.scopedPermissions && !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. @@ -799,13 +835,15 @@ export class FileDocProvider extends ObservableV2 { 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,6 +874,10 @@ export class FileDocProvider extends ObservableV2 { return } + if (this.scopedPermissions && ack.code === 'ACCESS_REVOKED') { + this.setCanWrite(false) + return + } if (!ack.retryable) { const message = ack.code === 'ACCESS_REVOKED' @@ -919,7 +961,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 +1074,45 @@ 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.scopedPermissions + 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 || !this.scopedPermissions) return + this.setCanWrite(data.canWrite) + if (data.canWrite) this.sendInFlightUpdate() + } + + private setCanWrite(canWrite: boolean) { + if (this.canWrite === canWrite) return + this.canWrite = canWrite + if (!canWrite) this.clearUpdateTimers() + if (this.scopedPermissions && !canWrite) { + 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 +1159,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..5dfb35cf237 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 @@ -105,7 +105,7 @@ 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, }) setProvider(fileProvider) 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..b7b33f04320 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. */ 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 9e8a8003ca5..3aba0aab4e3 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -168,7 +168,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 @@ -345,7 +345,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/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index d0b268056d6..eb87c216976 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -36,6 +36,10 @@ import { v2UpdateFileContentContract, v2UpsertFileShareContract, } from '@/lib/api/contracts/v2/files' +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' import { documentedSchema, type ErrorResponseId, @@ -154,6 +158,10 @@ function auditOperation( } const declaredRoutes = [ + ...projectFileOpenApiRoutes, + ...projectFileVersionOpenApiRoutes, + ...projectFileFolderOpenApiRoutes, + ...projectFileUploadOpenApiRoutes, defineOpenApiRoute( v2ListFilesContract, filesOperation({ @@ -1396,7 +1404,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Files & Audit Logs', description: - 'Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.', + 'Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -1413,7 +1421,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ { name: 'Files', description: - 'Create, upload, download, organize, share, version, and delete workspace files.', + 'Create, upload, download, organize, share, version, and delete workspace and Project files.', }, { name: 'Audit Logs', 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..17bbe2ae39f --- /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..ba82761c002 --- /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. The original API credential and upload-token are required on every control request. ${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, 'PayloadTooLarge', 'UnsupportedMediaType'], + 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, 'PayloadTooLarge', 'UnsupportedMediaType'], + 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', 'UnsupportedMediaType'], + 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/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..3f7d7efc596 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-files.ts @@ -0,0 +1,124 @@ +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, + 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, + 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 e8655454a6d..c046bd127be 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. The original API credential and upload-token are required on every control request. 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:write`.', + 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', @@ -1999,6 +2143,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 the complete set in 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', @@ -2196,6 +2369,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', @@ -2391,6 +2573,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', @@ -2455,6 +2646,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', @@ -2498,6 +2711,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', @@ -2807,6 +3031,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/collab-state.ts b/apps/sim/lib/collab-doc/collab-state.ts index a1e065b9249..9af5c13e919 100644 --- a/apps/sim/lib/collab-doc/collab-state.ts +++ b/apps/sim/lib/collab-doc/collab-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/collab-doc/persist.ts b/apps/sim/lib/collab-doc/persist.ts index eec7af8dc97..62440403799 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 00456bb5918..9b450670415 100644 --- a/apps/sim/lib/collab-doc/seed.ts +++ b/apps/sim/lib/collab-doc/seed.ts @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id' import * as Y from 'yjs' 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/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 ebff468165c..4e46ab926bc 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/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/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index 64684a33b44..8094322a259 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -43,6 +43,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/output.mdx', 'cli/permission-groups.mdx', 'cli/profiles.mdx', + 'cli/projects.mdx', 'cli/reference.mdx', 'cli/sandboxes.mdx', 'cli/scripting.mdx', 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 e6c59213fc3..432198e9bee 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, @@ -149,6 +151,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() @@ -1338,3 +1358,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 70af2249d1b..425082d2152 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..ef8a869fe54 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts @@ -0,0 +1,1097 @@ +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 { 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) + }) + + 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 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..17256e413eb --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/browser.integration.ts @@ -0,0 +1,299 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { db } 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' + +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( + '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([]) + }) + 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..6167cbf95b1 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -0,0 +1,1478 @@ +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 { 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 { OrchestrationError } from '@/lib/core/orchestration/types' +import { processOutboxEventById } from '@/lib/core/outbox/service' +import { + createProjectFile, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { createProjectFileFolder } from '@/lib/projects/files/application/folders' +import * as storageCleanup from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/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/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/workspace-file-versions' + ) + 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/workspace-file-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') + } + ) + + 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.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() +}) 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..092d0dd0544 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/documents.integration.ts @@ -0,0 +1,472 @@ +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' + +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('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 d5e4f7355b7..97b767da8a8 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,6 +37,7 @@ 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/workspace-file-storage-cleanup-outbox' import * as storageService from '@/lib/uploads/core/storage-service' import { UPLOAD_URL_TTL_MS } from '@/lib/uploads/upload-session/provider' @@ -42,6 +45,8 @@ import { PROJECT_FILE_UPLOAD_BINDING_KEY } from '@/lib/uploads/upload-session/ty 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[] }[] = [] @@ -66,6 +71,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() @@ -400,7 +409,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') @@ -493,7 +512,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 ea4947e9ccb..89f50681038 100644 --- a/apps/sim/lib/projects/files/__integration__/retention.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/retention.integration.ts @@ -30,13 +30,11 @@ 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 { authorizeProject } from '@/lib/projects/application/authorization' -import { projectOperations } from '@/lib/projects/application/operations' +import { loadProjectAccess } from '@/lib/projects/application/authorization' import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { deleteFile, downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' import type { FileOwner } from '@/lib/workspace-files/ownership' @@ -372,18 +370,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 @@ -640,18 +627,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 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 restore.promise 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..1596bb35c38 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts @@ -0,0 +1,651 @@ +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/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.getProjectFileUploadSession, + 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() +}) 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..28ee6b947a9 --- /dev/null +++ b/apps/sim/lib/projects/files/api/content-presenter.ts @@ -0,0 +1,19 @@ +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, + content, +}: { + file: OwnedFileRecord + content: Buffer +}) { + return presentFileDelivery({ + body: content, + filename: file.name, + contentType: file.type, + contentLength: content.length, + 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..206edfe9bd4 --- /dev/null +++ b/apps/sim/lib/projects/files/api/version-presenters.ts @@ -0,0 +1,22 @@ +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +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, + content, +}: { + file: OwnedFileRecord + version: WorkspaceFileVersionRecord + content: Buffer +}) { + return presentFileDelivery({ + body: content, + filename: file.name, + contentType: version.contentType, + contentLength: content.length, + 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..13431e568de --- /dev/null +++ b/apps/sim/lib/projects/files/application/authorized-use-case.ts @@ -0,0 +1,142 @@ +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 + 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 } + }) + } 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..e5b6a65fe6c --- /dev/null +++ b/apps/sim/lib/projects/files/application/content.ts @@ -0,0 +1,306 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { PreparedCollabDocState } from '@/lib/collab-doc/collab-state' +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 { 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/workspace-file-manager' +import { + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + getBoundWorkspaceFileSecretProvenanceByMetadata, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { downloadFile } 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-utils' +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 + 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 +} + +export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async prepare({ + input, + context, + }: Omit, 'tx'>): Promise { + const file = requireFile(context) + const content = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: input.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + }) + return { key: file.key, revision: file.contentUpdatedAt, content } + }, + async execute({ + 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() + ? ((await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get(file.id) ?? { + status: 'unknown' as const, + }) + : undefined + return { + ...(await mapProjectFileResult(tx, context, file)), + content: prepared.content, + ...(provenance ? { secretProvenance: provenance } : {}), + } + }, + afterSuccess: ({ result }) => + reportWorkspaceFileDelivery(result.secretProvenance, result.file.id), +}) diff --git a/apps/sim/lib/projects/files/application/document-lifecycle.ts b/apps/sim/lib/projects/files/application/document-lifecycle.ts index 2335d586dcb..f836c8479a1 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/collab-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..9c3b503929a --- /dev/null +++ b/apps/sim/lib/projects/files/application/documents.ts @@ -0,0 +1,244 @@ +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import * as Y from 'yjs' +import { + assertCollabDocStateSize, + CollabDocStateConflictError, + hashMarkdown, + loadCollabDocState, + type PreparedCollabDocState, + saveCollabDocStateInTx, +} from '@/lib/collab-doc/collab-state' +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 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-utils' + +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, + FileDocSeed +> = { + operation: projectFileOperations.readContent, + delegationAudience: projectFileOperations.readContent.delegationAudience, + authorize: async (args) => { + await getProjectFileDocAccess.execute(args) + }, + async execute(args) { + await getProjectFileDocAccess.execute(args) + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const read = await readProjectFileContent.execute({ + ...args, + input: { ...args.input, maxBytes: MAX_SEED_BYTES }, + }) + if (!isMarkdownFile(read.file)) { + throw new OrchestrationError( + 'validation', + 'Collaborative documents must be Markdown files' + ) + } + const cached = await loadCollabDocState(args.input.fileId) + const update = prepareFileDocSeed(args.input.fileId, read.content, cached) + 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, + }, + }, + }) + 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..42a27c90291 --- /dev/null +++ b/apps/sim/lib/projects/files/application/index.ts @@ -0,0 +1,31 @@ +export { getProjectFileListAccess } from './collection-access' +export { createProjectFile, readProjectFileContent, 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, + listProjectFileVersions, + readProjectFileVersion, + readProjectFileVersionContent, + 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..9dfd0cf83fb --- /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: 'write', + target: 'project', + oauthScope: 'api:write', + }), + 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..b87955454f5 --- /dev/null +++ b/apps/sim/lib/projects/files/application/read.ts @@ -0,0 +1,140 @@ +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 { 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) { + 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, + 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 + : [] + return { ...page, files, 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..7e4a26b9714 --- /dev/null +++ b/apps/sim/lib/projects/files/application/uploads.ts @@ -0,0 +1,384 @@ +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/workspace-file-folder-manager' +import { + adoptVerifiedUploadSession, + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/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 }) => { + 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 }, + }) + 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..076c6efc215 --- /dev/null +++ b/apps/sim/lib/projects/files/application/versions.ts @@ -0,0 +1,438 @@ +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 { 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 { + 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/workspace-file-manager' +import { + snapshotWorkspaceFileSecretProvenanceInTx, + type WorkspaceFileSecretProvenanceSnapshot, + workspaceFileSecretProvenanceFromSnapshot, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + enqueueWorkspaceFileStorageCleanups, + processWorkspaceFileStorageCleanupsNow, +} from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { + deleteWorkspaceFileVersionInTx, + getCurrentWorkspaceFileVersion, + getWorkspaceFileVersion, + getWorkspaceFileVersionProvenance, + queryWorkspaceFileVersions, + type WorkspaceFileVersionRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { downloadFile } 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-utils' +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 { + 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 { + 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 }) + 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: ({ result }) => + 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) + }, +} + +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..1a3d823c6b8 --- /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/workspace-file-folder-manager' +import { mapFileRecord } from '@/lib/uploads/contexts/workspace/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 a001f752b9d..f4bf6dbe044 100644 --- a/apps/sim/lib/projects/files/prefix-cleanup.ts +++ b/apps/sim/lib/projects/files/prefix-cleanup.ts @@ -80,7 +80,7 @@ export async function recoverProjectStorageReconciliation(now = new Date()): Pro } /** 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..1a7bd1c61be 100644 --- a/apps/sim/lib/realtime/notify.test.ts +++ b/apps/sim/lib/realtime/notify.test.ts @@ -5,6 +5,11 @@ import { applyEditToLiveFileDoc, invalidateLiveFileDoc } from '@/lib/realtime/no 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 +18,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 +30,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 +45,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 +61,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 +69,18 @@ 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({ ...target, 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') }) }) diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts index b06ff437082..6f51feb764d 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, + ...fileDocOwnerWireFields(target.owner), + }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) if (!response.ok) { @@ -226,16 +253,15 @@ 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({ ...target, version, ...fileDocOwnerWireFields(target.owner) }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) await response.body?.cancel().catch(() => {}) @@ -244,9 +270,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 +280,9 @@ 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({ ...target, ...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/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 0038402b6c3..a39be499910 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -666,7 +666,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> { @@ -910,7 +910,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> { @@ -1018,7 +1018,7 @@ export async function moveWorkspaceFileItems(params: { ) } -async function moveFileItems( +export async function moveFileItems( params: { owner: EditableFileOwner fileIds?: string[] @@ -1255,7 +1255,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 @@ -1421,7 +1421,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { ) } -async function archiveFileItems( +export async function archiveFileItems( params: { owner: EditableFileOwner fileIds?: string[] @@ -1519,7 +1519,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 } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 92cf3008363..25aea625343 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/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,6 @@ 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/workspace-file-folder-manager' -import { enqueueWorkspaceFileLiveDocReconciliation } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { applyWorkspaceFileSecretProvenancePolicyInTx, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, @@ -98,7 +98,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-utils' import type { ServableFile } from '@/lib/uploads/utils/file-utils.server' import { @@ -138,6 +143,7 @@ import { getWorkspaceFileFolderPath, listFileFolders, listWorkspaceFileFolders, + loadActiveFileFolderPathIndex, normalizeWorkspaceFileItemName, resolveFileFolderTarget, resolveWorkspaceFileFolderTarget, @@ -203,7 +209,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 @@ -477,7 +483,7 @@ export async function allocateUniqueWorkspaceFileName( return withCopySuffix(baseName, generateShortId(8)) } -interface StagedFileContent { +export interface StagedFileContent { readonly owner: Readonly readonly key: string readonly name: string @@ -487,8 +493,9 @@ interface StagedFileContent { } const stagedFileContents = new WeakSet() +const adoptedUploadContents = new WeakSet() -interface PlannedFileIdentity { +export interface PlannedFileIdentity { readonly id: string } @@ -501,8 +508,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 @@ -568,8 +603,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) @@ -626,7 +663,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 @@ -1448,7 +1485,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 @@ -1824,7 +1861,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 @@ -2005,6 +2042,76 @@ 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 = normalizeWorkspaceFileReferenceSegments(localReference) + } 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[] @@ -2399,7 +2506,7 @@ export class ContentVersionConflictError extends Error { } } -interface CommitFileContentOptions { +export interface CommitFileContentOptions { commitOwner?: (tx: DbOrTx) => Promise owner: EditableFileOwner fileId: string @@ -2411,7 +2518,7 @@ interface CommitFileContentOptions { } /** Commits bytes, history and provenance after the caller has locked its canonical billing payer. */ -async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { +export async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { const { owner, fileId, staged } = options if (owner.entityType === 'workspace') await lockWorkspaceProject(tx, owner.entityId) if (staged.contentHash === null) @@ -2619,7 +2726,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 +2847,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 +3044,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/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index 38d8389cc6e..8b63cf6b11b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -59,9 +59,9 @@ vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock) vi.mock('@/lib/projects/membership', () => projectMembershipMock) -vi.mock('@/lib/uploads/contexts/workspace/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 3ad543f2c90..deac18a80e4 100644 --- a/apps/sim/lib/uploads/server/content-effects.test.ts +++ b/apps/sim/lib/uploads/server/content-effects.test.ts @@ -4,8 +4,8 @@ const mocks = vi.hoisted(() => ({ cleanup: vi.fn(), reconcile: vi.fn() })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox', () => ({ processWorkspaceFileStorageCleanupsNow: mocks.cleanup, })) -vi.mock('@/lib/uploads/contexts/workspace/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 4ab1822747f..5ad416a25f8 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/workspace-file-live-doc-outbox' import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/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..0e3b4a667e4 --- /dev/null +++ b/apps/sim/lib/uploads/server/delivery.ts @@ -0,0 +1,142 @@ +import { ensureFileNameExtension } from '@/lib/uploads/utils/file-utils' + +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/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/workspace-file-live-doc-outbox.test.ts rename to apps/sim/lib/uploads/server/live-doc-outbox.test.ts index 750b238e9e5..9441aeae4b2 100644 --- a/apps/sim/lib/uploads/contexts/workspace/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 { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' -import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' +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/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/workspace-file-live-doc-outbox.ts rename to apps/sim/lib/uploads/server/live-doc-outbox.ts index 287f8bb8ec5..811de7db861 100644 --- a/apps/sim/lib/uploads/contexts/workspace/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-utils' -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..372315b79f1 --- /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/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 02b4ae48143..57d3ed45fa0 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/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-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 00e8ad82e8c..43b15b67df9 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.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 56bb9b37fc8..c68ebf593a4 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-delivery-observer.ts b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts new file mode 100644 index 00000000000..e2d90ab7873 --- /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/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 7bea84114cc..eeb875cfb3e 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 20acf946c24..6c154fafa95 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/workspace-file-versions' -import { hasObjectNotFoundCause } from '@/lib/uploads/core/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { requireAdmittedWorkspaceFile } from '@/lib/workspace-files/application/admit-file-content' @@ -36,7 +35,7 @@ import { type DownloadWorkspaceFileStreamResult, streamWorkspaceFileRecord, } from '@/lib/workspace-files/application/download-workspace-file' -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' @@ -45,6 +44,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' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' const logger = createLogger('WorkspaceFileVersions') @@ -65,7 +72,8 @@ export interface ListWorkspaceFileVersionsInput extends FileVersionTarget { } export interface ListWorkspaceFileVersionsResult { - versions: WorkspaceFileVersionRecord[] + revision?: string + versions: AuthoredFileVersion[] nextKeys: CursorKey[] | null } @@ -80,7 +88,7 @@ export interface ReadWorkspaceFileVersionTextResult extends ReadWorkspaceFileTex version: WorkspaceFileVersionRecord } -export interface DownloadWorkspaceFileVersionResult extends DownloadWorkspaceFileStreamResult { +interface DownloadWorkspaceFileVersionResult extends DownloadWorkspaceFileStreamResult { version: WorkspaceFileVersionRecord } @@ -139,21 +147,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. @@ -189,7 +182,11 @@ export const listWorkspaceFileVersions = defineAuthorizedWorkspaceFileUseCase({ limit: input.limit, after: input.after, }) - return { versions, nextKeys } + return { + versions: await projectFileVersionAuthors(versions), + nextKeys, + ...workspaceFileRevisionField(file), + } }, }) @@ -222,7 +219,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, @@ -235,7 +232,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 }), @@ -249,7 +246,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 } @@ -269,6 +266,16 @@ 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) + await loadVersion(await loadActiveFile(context), args.input.version) + }, +} + async function executeRevertWorkspaceFileVersion({ input, context, @@ -289,22 +296,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 } @@ -317,7 +313,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 bdcd52b5b3a..4f6b740eb97 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..dddd41474bd --- /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/workspace-file-versions' +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..31a1c1d76ec --- /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/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/application/workspace-file-context.ts b/apps/sim/lib/workspace-files/application/workspace-file-context.ts index 4b150e251ec..12889441758 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-context.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-context.ts @@ -8,6 +8,7 @@ import { type WorkspaceFileLifecycleContext, } from '@/lib/uploads/contexts/workspace/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/browser-query.ts b/apps/sim/lib/workspace-files/browser-query.ts new file mode 100644 index 00000000000..cc5206dd096 --- /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-utils' +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 btrim(content_type) not in ('', 'application/octet-stream', 'binary/octet-stream') + then btrim(content_type) 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..7d2a7914351 --- /dev/null +++ b/apps/sim/scripts/test-project-file-history-e2e.ts @@ -0,0 +1,466 @@ +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_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() } +} +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( + 'oversized historical bytes return 413 without changing the current head', + async () => { + const bytes = Buffer.alloc(100 * 1024 * 1024 + 1) + 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}` + 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) { + statuses.push((await request('GET', `${path}/versions/1/content`, undefined, auth)).status) + statuses.push( + ( + await request( + 'POST', + `${path}/versions/1/revert`, + { expectedRevision: small.revision }, + auth + ) + ).status + ) + } + assert.deepEqual(statuses, [413, 413, 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 e6dcbf2424f..28f0ee09da8 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -50,6 +50,8 @@ "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"], "sandbox-tasks/index.ts": ["files"], "components/mcp/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/platform-authz/src/room-policy.ts b/packages/platform-authz/src/room-policy.ts index ba91920c852..831451e5852 100644 --- a/packages/platform-authz/src/room-policy.ts +++ b/packages/platform-authz/src/room-policy.ts @@ -21,6 +21,7 @@ import { isPermissionType, type PermissionType, permissionSatisfies } from './pr 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', 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 891a106eee4..d124a9f85f8 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 { @@ -738,6 +779,41 @@ export const CLI_CONTRACT: CliContract = { }, }, createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, + 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')], @@ -881,6 +957,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 }, @@ -1481,32 +1577,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', @@ -1517,23 +1593,37 @@ 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', describe: 'Permanently delete a previous version of a file', confirm: 'This permanently deletes the version and its stored content.', }, + 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. @@ -1653,13 +1743,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. @@ -1669,13 +1753,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, }, /** @@ -2143,6 +2221,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 6c049087164..6a68b1d3382 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', @@ -15757,6 +16646,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 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.', + }, + 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', @@ -16395,6 +17360,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]', @@ -17506,6 +18484,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]', @@ -19640,6 +20688,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. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + }, + 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', @@ -20572,6 +21743,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', @@ -20967,6 +22160,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', @@ -21104,6 +22322,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', @@ -21325,6 +22558,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', @@ -21403,6 +22662,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', @@ -22476,6 +23760,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 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`.', + }, + 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/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/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-file-names.baseline.json b/scripts/check-file-names.baseline.json index 9294e57c23b..a1f6aa134b7 100644 --- a/scripts/check-file-names.baseline.json +++ b/scripts/check-file-names.baseline.json @@ -96,8 +96,6 @@ "stutter\tapps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts", - "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.test.ts", - "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-manager-download.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-manager-page.test.ts", diff --git a/scripts/check-test-types.baseline.json b/scripts/check-test-types.baseline.json index c2fcfd7a8c7..eefd52cf989 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 }, @@ -981,16 +978,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 434fdeadc3a..c7f8c0c2129 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-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index fc6cce672f0..824b434519d 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -717,6 +717,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` From 29b8b4a86e5dde2d28a5bbee2bd68635344cb68c Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 12:58:33 -0700 Subject: [PATCH 03/10] feat(files): unify document read subscriptions and write capabilities --- apps/docs/content/docs/cli/projects.mdx | 10 +- apps/docs/content/docs/cli/reference.mdx | 10 +- apps/docs/openapi-v2-files-audit.json | 204 ++++++++- apps/realtime/src/access-revalidation.test.ts | 34 +- apps/realtime/src/access-revalidation.ts | 23 +- .../file-doc-capabilities.integration.ts | 425 ++++++++++++++++++ apps/realtime/src/handlers/file-doc-owner.ts | 6 +- apps/realtime/src/handlers/file-doc.test.ts | 26 +- apps/realtime/src/handlers/file-doc.ts | 227 ++++------ apps/realtime/src/middleware/permissions.ts | 7 +- .../collaboration/file-doc-provider.test.ts | 40 ++ .../collaboration/file-doc-provider.ts | 30 +- .../use-file-doc-collaboration.ts | 28 +- .../editor-lifecycle.test.tsx | 83 +++- .../rich-markdown-editor.tsx | 22 +- packages/platform-authz/src/room-policy.ts | 6 +- packages/sim-cli/src/generated/v2-api.ts | 10 +- 17 files changed, 932 insertions(+), 259 deletions(-) create mode 100644 apps/realtime/src/handlers/file-doc-capabilities.integration.ts diff --git a/apps/docs/content/docs/cli/projects.mdx b/apps/docs/content/docs/cli/projects.mdx index faf2cb751fa..acab786b036 100644 --- a/apps/docs/content/docs/cli/projects.mdx +++ b/apps/docs/content/docs/cli/projects.mdx @@ -68,7 +68,7 @@ sim projects files list [options] | `--no-recursive` | No | Send --recursive as false. | | `--scope ` | No | 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. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Defaults to "uploadedAt". Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | +| `--sort-by ` | No | Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`. Defaults to "uploadedAt". Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Defaults to "asc". Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -180,7 +180,7 @@ sim projects files create [options] | --- | --- | --- | | `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | | `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | -| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. Defaults to "". | +| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads. Defaults to "". | | `--encoding ` | No | Encoding of the content field. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -309,7 +309,7 @@ sim projects files versions revert [options] | Option | Required | Description | | --- | --- | --- | | `--expected-current-version ` | No | 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. | -| `--expected-revision ` | No | 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. | +| `--expected-revision ` | No | 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. | @@ -513,9 +513,9 @@ sim projects files set-content [options] | Option | Required | Description | | --- | --- | --- | -| `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | +| `--content ` | Yes | Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content. | | `--encoding ` | No | Content encoding. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | -| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | +| `--expected-revision ` | No | Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index df43d1c1d02..d79989145b2 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -4804,7 +4804,7 @@ sim projects files create [options] | --- | --- | --- | | `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | | `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | -| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. Defaults to "". | +| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads. Defaults to "". | | `--encoding ` | No | Encoding of the content field. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -5044,7 +5044,7 @@ sim projects files versions revert [options] | Option | Required | Description | | --- | --- | --- | | `--expected-current-version ` | No | 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. | -| `--expected-revision ` | No | 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. | +| `--expected-revision ` | No | 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. | @@ -5133,7 +5133,7 @@ sim projects files list [options] | `--no-recursive` | No | Send --recursive as false. | | `--scope ` | No | 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. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | -| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Defaults to "uploadedAt". Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | +| `--sort-by ` | No | Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`. Defaults to "uploadedAt". Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Defaults to "asc". Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -5251,9 +5251,9 @@ sim projects files set-content [options] | Option | Required | Description | | --- | --- | --- | -| `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | +| `--content ` | Yes | Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content. | | `--encoding ` | No | Content encoding. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | -| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | +| `--expected-revision ` | No | Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on. | diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index b3d92e5e4b0..1e59da7d70a 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -47,6 +47,14 @@ "x-sim-operation": "project_files.rename", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -145,6 +153,14 @@ "x-sim-operation": "project_files.move_items", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -232,6 +248,14 @@ "x-sim-operation": "project_files.archive_items", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -319,6 +343,14 @@ "x-sim-operation": "project_files.restore", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -417,6 +449,14 @@ "x-sim-operation": "project_files.create", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -502,6 +542,14 @@ "x-sim-operation": "project_files.list", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -576,10 +624,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", "schema": { "default": "uploadedAt", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", "type": "string", "enum": ["name", "size", "uploadedAt", "updatedAt"] } @@ -675,6 +723,14 @@ "x-sim-operation": "project_files.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -758,6 +814,14 @@ "x-sim-operation": "project_files.update_content", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -856,6 +920,14 @@ "x-sim-operation": "project_files.read_metadata", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -934,6 +1006,14 @@ "x-sim-operation": "project_files.versions.list", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -1060,6 +1140,14 @@ "x-sim-operation": "project_files.versions.read", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -1148,6 +1236,14 @@ "x-sim-operation": "project_files.versions.delete", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1241,6 +1337,14 @@ "x-sim-operation": "project_files.versions.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -1338,6 +1442,14 @@ "x-sim-operation": "project_files.versions.revert", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1444,10 +1556,18 @@ "get": { "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. Returns the complete set in 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`.", + "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`.", "x-sim-operation": "project_file_folders.list", "x-oauth-scope": "api:read", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:read"] + } + ], "parameters": [ { "name": "projectId", @@ -1525,6 +1645,14 @@ "x-sim-operation": "project_file_folders.create", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1612,6 +1740,14 @@ "x-sim-operation": "project_file_folders.update", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1710,6 +1846,14 @@ "x-sim-operation": "project_file_folders.restore", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1808,6 +1952,14 @@ "x-sim-operation": "project_file_uploads.create", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1895,6 +2047,14 @@ "x-sim-operation": "project_file_uploads.read", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -1991,6 +2151,14 @@ "x-sim-operation": "project_file_uploads.cancel", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -2089,6 +2257,14 @@ "x-sim-operation": "project_file_uploads.complete", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -2187,6 +2363,14 @@ "x-sim-operation": "project_file_uploads.parts", "x-oauth-scope": "api:write", "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], "parameters": [ { "name": "projectId", @@ -7339,7 +7523,7 @@ }, "content": { "default": "", - "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.", + "description": "Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads.", "type": "string", "maxLength": 70000000 }, @@ -7365,7 +7549,7 @@ "content": { "type": "string", "maxLength": 70000000, - "description": "Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`." + "description": "Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content." }, "encoding": { "default": "utf-8", @@ -7374,7 +7558,7 @@ "enum": ["utf-8", "base64"] }, "expectedRevision": { - "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "description": "Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on.", "type": "string", "minLength": 1 } @@ -7403,7 +7587,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there are no more pages. Do not construct a cursor yourself." } }, "required": ["data", "nextCursor"], @@ -7552,7 +7736,7 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there are no more pages. Do not construct a cursor yourself." } }, "required": ["data", "nextCursor"], @@ -7859,7 +8043,7 @@ "maximum": 2147483647 }, "expectedRevision": { - "description": "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.", + "description": "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.", "type": "string", "minLength": 1 } @@ -8011,7 +8195,7 @@ "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "description": "Always `null`. This list has no `cursor` or `limit` parameters; see the operation for inventory limits." } }, "required": ["data", "nextCursor"], 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 5905fd401ba..c31b1ed914b 100644 --- a/apps/realtime/src/access-revalidation.ts +++ b/apps/realtime/src/access-revalidation.ts @@ -36,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] @@ -337,8 +331,14 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR ]) // {@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, @@ -350,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-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-owner.ts b/apps/realtime/src/handlers/file-doc-owner.ts index 6a6502bb771..2cdd68e9417 100644 --- a/apps/realtime/src/handlers/file-doc-owner.ts +++ b/apps/realtime/src/handlers/file-doc-owner.ts @@ -14,7 +14,7 @@ interface PersistenceActor { } interface FileDocOwnerAdapter { - requiresCurrentActor: boolean + allowLocalSnapshotFallback: boolean tracksLifecycle: boolean seed( target: FileDocTarget, @@ -47,7 +47,7 @@ function projectRequest(target: FileDocTarget, actor?: PersistenceActor) { const OWNER_ADAPTERS: Record = { workspace: { - requiresCurrentActor: false, + allowLocalSnapshotFallback: true, tracksLifecycle: false, seed: (target) => fetchFileDocSeed(requireOwner(target, 'workspace'), target.fileId), persist: (target, actor, state, version) => @@ -60,7 +60,7 @@ const OWNER_ADAPTERS: Record = ), }, project: { - requiresCurrentActor: true, + allowLocalSnapshotFallback: false, tracksLifecycle: true, seed: (target, actor) => fetchProjectFileDocSeed(projectRequest(target, actor)), persist: (target, actor, state, version) => diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 5ce1367862f..538025450ca 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -526,7 +526,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)) ) @@ -604,8 +604,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)) ) @@ -650,7 +650,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)) ) @@ -687,7 +687,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)) ) @@ -749,8 +749,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, @@ -800,7 +800,7 @@ describe('setupWorkspaceFileDocHandlers', () => { commitRoomPermission( 'user-1', { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' }, - 'read', + null, beginRoomPermissionRead() ) } else if (access === 'expired') { @@ -865,7 +865,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( @@ -929,7 +929,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)) ) @@ -941,7 +941,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( @@ -1025,7 +1025,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) => @@ -1116,7 +1116,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 d0840b7574c..dc3b0ef7309 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -53,6 +53,7 @@ import { 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' @@ -329,12 +330,7 @@ function broadcastLocal( * last-disconnect is separate ({@link flushPersist} with `final`). */ function schedulePersist(name: string, room: FileDocRoom): void { - if ( - fileDocOwnerAdapter(room.owner).requiresCurrentActor - ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.owner || !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) @@ -372,9 +368,7 @@ 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 || - (fileDocOwnerAdapter(room.owner).requiresCurrentActor - ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.owner || !room.lastEditorUserId) + (!getFileDocStore().enabled && (!room.lastEditorUserId || !room.lastEditorConnectionId)) ) return const store = getFileDocStore() @@ -399,27 +393,22 @@ 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 (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { - snapshotEditor = - 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 (fileDocOwnerAdapter(room.owner).requiresCurrentActor) snapshotEditor = localEditor + snapshotEditor = localEditor return localState } try { - if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { - const snapshot = await store.getStreamSnapshot(name, generation) - snapshotEditor = 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 ( - fileDocOwnerAdapter(room.owner).requiresCurrentActor || + !fileDocOwnerAdapter(room.owner).allowLocalSnapshotFallback || streamError instanceof FileDocInvalidatedError ) throw streamError @@ -427,7 +416,8 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro // 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), }) @@ -473,16 +463,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 (fileDocOwnerAdapter(room.owner).requiresCurrentActor && !snapshotEditor) return - const actor = fileDocOwnerAdapter(room.owner).requiresCurrentActor - ? snapshotEditor - : userId - ? { userId, connectionId: room.lastEditorConnectionId } - : null - const result = actor - ? await fileDocOwnerAdapter(room.owner).persist(room, actor, 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 @@ -1141,71 +1128,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 document = fileDocRooms.get(name) - if (!userId || !document) return false - - const { fileId, owner } = document - const room = fileDocRoom({ fileId, owner }) - if (fileDocOwnerAdapter(owner).requiresCurrentActor) - 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, - ...fileDocOwnerWireFields(owner), - 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 + 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) } /** @@ -1225,11 +1172,6 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn if (!name) return const room = fileDocRooms.get(name) if (!room) return - if ( - !fileDocOwnerAdapter(room.owner).requiresCurrentActor && - !isFileDocWriteAllowed(socket, io, name) - ) - return const bytes = toFileDocBytes(data) if (!bytes) return @@ -1253,43 +1195,40 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn } const decoder = decoding.createDecoder(bytes) const messageType = decoding.readVarUint(decoder) - if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { - const userId = socket.userId - if (!userId) return - const permission = await resolveCurrentRoomPermission( - userId, - fileDocRoom(room), - '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), - '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 && !fileDocOwnerAdapter(room.owner).requiresCurrentActor) - 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 @@ -1304,7 +1243,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 @@ -1396,8 +1335,9 @@ async function handleClientUpdate( } 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) @@ -1514,7 +1454,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. * @@ -1734,9 +1674,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 } @@ -1754,7 +1692,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], @@ -1794,7 +1732,6 @@ export function setupWorkspaceFileDocHandlers( ) return } - finalPermission = currentAccess.workspacePermission } /** Adapter joins can wait; recheck access and liveness before ownership or synchronization. */ if (!canRegisterJoin()) return @@ -1880,10 +1817,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 (!fileDocOwnerAdapter(owner).requiresCurrentActor) 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. @@ -1894,14 +1827,10 @@ export function setupWorkspaceFileDocHandlers( version: joinedVersion, schemaVersion: FILE_DOC_SCHEMA_VERSION, ...fileDocOwnerWireFields(entry.owner), - ...(store.enabled || fileDocOwnerAdapter(owner).requiresCurrentActor - ? { acknowledgedUpdates: true as const } - : {}), - ...(fileDocOwnerAdapter(owner).requiresCurrentActor - ? { - canWrite: finalPermission === 'write' || finalPermission === 'admin', - } - : {}), + 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) diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 3298293d29b..de9773d11c0 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -264,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 (isProjectRoom(room)) 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 } } @@ -277,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/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 5983a8cd741..63140ee7673 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 @@ -101,6 +101,7 @@ function acceptJoin( clientId, docId, acknowledgedUpdates: acknowledgedUpdates ? true : undefined, + canWrite: true, }) } @@ -1945,3 +1946,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 5c152303184..0313e08e83f 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 @@ -138,9 +138,9 @@ export class FileDocProvider extends ObservableV2 { >() synced = false - canWrite = true + canWrite = false + private permissionKnown = false private readonly owner: FileDocOwner - private readonly scopedPermissions: boolean private readonly membershipKey: string /** * The current readiness failure, or `null`. Retryable timeouts clear once authoritative sync @@ -195,8 +195,6 @@ export class FileDocProvider extends ObservableV2 { super() this.owner = scope.owner - this.scopedPermissions = this.owner.entityType !== 'workspace' - this.canWrite = !this.scopedPermissions this.membershipKey = roomName(fileDocRoom({ fileId, owner: this.owner })) this.journal = new PendingFileDocUpdateJournal({ ...scope, fileId: this.fileId }) this.registerActiveProvider() @@ -411,7 +409,12 @@ export class FileDocProvider extends ObservableV2 { (data.clientId !== undefined && data.clientId !== this.doc.clientID) ) return - if (this.scopedPermissions) this.setCanWrite(data.canWrite === true) + /** 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 @@ -427,7 +430,7 @@ export class FileDocProvider extends ObservableV2 { return } const recoveryDocId = data.docId - if (this.scopedPermissions && !this.canWrite) { + if (!this.canWrite) { void this.journal.discard(recoveryDocId).then(() => { this.finishAcceptJoin(data, generation, null) }) @@ -725,7 +728,7 @@ export class FileDocProvider extends ObservableV2 { /** A terminal document cannot publish; inbound and recovery updates must not echo. */ if ( this.fatal || - (this.scopedPermissions && !this.canWrite) || + (this.permissionKnown && !this.canWrite) || origin === this || origin === RECOVERY_ORIGIN ) @@ -874,15 +877,13 @@ export class FileDocProvider extends ObservableV2 { return } - if (this.scopedPermissions && ack.code === 'ACCESS_REVOKED') { + 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 } @@ -1081,7 +1082,7 @@ export class FileDocProvider extends ObservableV2 { }): boolean { const target = parseFileDocTarget(data) if (!target || target.fileId !== this.fileId) return false - if (!target.owner) return !this.scopedPermissions + if (!target.owner) return this.owner.entityType === 'workspace' return ( target.owner.entityType === this.owner.entityType && target.owner.entityId === this.owner.entityId @@ -1089,16 +1090,17 @@ export class FileDocProvider extends ObservableV2 { } private handlePermission = (data: FileDocPermission) => { - if (!this.matchesTarget(data) || data.fileId !== this.fileId || !this.scopedPermissions) return + if (!this.matchesTarget(data) || data.fileId !== this.fileId) return this.setCanWrite(data.canWrite) if (data.canWrite) this.sendInFlightUpdate() } private setCanWrite(canWrite: boolean) { + this.permissionKnown = true if (this.canWrite === canWrite) return this.canWrite = canWrite if (!canWrite) this.clearUpdateTimers() - if (this.scopedPermissions && !canWrite) { + if (!canWrite) { this.pendingUpdatesDrained = true this.pendingUpdateBatch = [] this.inFlightUpdate = null 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 5dfb35cf237..b9e8d37f600 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 { 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,7 +41,7 @@ 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 @@ -59,11 +62,10 @@ export function useFileDocCollaboration({ enabled, }: UseFileDocCollaborationParams): FileDocCollaboration | null { const { socket } = useSocket() + const queryClient = useQueryClient() // 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 +99,7 @@ export function useFileDocCollaboration({ }, []) const [provider, setProvider] = useState(null) + const [canWrite, setCanWrite] = useState(false) useEffect(() => { if (!enabled || !socket) return @@ -108,12 +111,26 @@ export function useFileDocCollaboration({ owner: { entityType: 'workspace', entityId: workspaceId }, userId, }) + setCanWrite(fileProvider.canWrite) + const reloadAfterWriteLoss = async () => { + const filters = [ + { 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 +189,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 95d66c457af..719ac872221 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 @@ -7,6 +7,7 @@ import { authClientMock } 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' @@ -24,8 +25,9 @@ import { LoadedRichMarkdownEditor } from '@/app/workspace/[workspaceId]/files/co nextNavigationMockFns.mockUsePathname.mockReturnValue('/workspace/workspace-1/files') -const { collaborationRef, uploadFile } = vi.hoisted(() => ({ +const { collaborationRef, realCollaboration, uploadFile } = vi.hoisted(() => ({ collaborationRef: { current: null as unknown }, + realCollaboration: { enabled: false }, uploadFile: vi.fn(), })) @@ -54,8 +56,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 }) }), @@ -204,6 +213,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(() => {}) @@ -226,6 +236,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, @@ -327,11 +338,77 @@ describe('loaded rich editor lifecycle', () => { } ) + 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, @@ -371,6 +448,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, @@ -411,6 +489,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 cfed0501286..3a65274999b 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 @@ -486,7 +486,7 @@ export function LoadedRichMarkdownEditor({ * 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). @@ -494,7 +494,6 @@ export function LoadedRichMarkdownEditor({ const [collaborationEnabled] = useState( () => collaborative && - canEdit && !streamingAtMount && (settled?.verdict ?? false) && Boolean(userId) && @@ -519,7 +518,6 @@ export function LoadedRichMarkdownEditor({ collaborationEnabled ? 'connecting' : 'ready' ) const collabReady = collabStatus === 'ready' - const isEditable = canEdit && !isStreaming && (settled?.verdict ?? false) && collabReady const collaboration = useFileDocCollaboration({ workspaceId, @@ -528,6 +526,8 @@ export function LoadedRichMarkdownEditor({ userName, enabled: collaborationEnabled, }) + 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 — @@ -1039,6 +1039,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. @@ -1246,7 +1256,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 @@ -1259,7 +1269,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, @@ -1268,7 +1278,7 @@ export function LoadedRichMarkdownEditor({ settled, collaboration, isStreaming, - canEdit, + effectiveCanEdit, autoFocus, disableStreamingAutoScroll, collaborationEnabled, diff --git a/packages/platform-authz/src/room-policy.ts b/packages/platform-authz/src/room-policy.ts index 831451e5852..6ac7f1146ea 100644 --- a/packages/platform-authz/src/room-policy.ts +++ b/packages/platform-authz/src/room-policy.ts @@ -13,8 +13,8 @@ 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`). */ @@ -24,7 +24,7 @@ export const ROOM_MEMBERSHIP_ACTIONS = { [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/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 7fd137359ec..bfd85472a1c 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -16670,7 +16670,7 @@ export const V2_OPERATIONS = { kind: 'string', default: '', describe: - 'Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.', + 'Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads.', }, encoding: { kind: 'enum', @@ -20765,7 +20765,7 @@ export const V2_OPERATIONS = { values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, default: 'uploadedAt', describe: - 'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + 'Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.', }, sortOrder: { kind: 'enum', @@ -22708,7 +22708,7 @@ export const V2_OPERATIONS = { 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.', + '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.', }, }, }, @@ -23802,7 +23802,7 @@ export const V2_OPERATIONS = { kind: 'string', required: true, describe: - 'Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`.', + 'Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content.', }, encoding: { kind: 'enum', @@ -23813,7 +23813,7 @@ export const V2_OPERATIONS = { expectedRevision: { kind: 'string', describe: - 'Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.', + 'Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on.', }, }, }, From c7a8ea9eb68983fa0f30e8eab395ea244f29ab3a Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 14:37:12 -0700 Subject: [PATCH 04/10] fix(files): resolve capability type and artifact checks --- .../collaboration/use-file-doc-collaboration.ts | 4 ++-- apps/sim/lib/api/mcp/generated/v2-operations.ts | 2 +- packages/db/package.json | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) 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 b9e8d37f600..9126dad5c3d 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,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { FILE_DOC_EVENTS, type FileDocPresence } from '@sim/realtime-protocol/file-doc' -import { useQueryClient } from '@tanstack/react-query' +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' @@ -113,7 +113,7 @@ export function useFileDocCollaboration({ }) setCanWrite(fileProvider.canWrite) const reloadAfterWriteLoss = async () => { - const filters = [ + const filters: QueryFilters[] = [ { queryKey: workspaceFilesKeys.contentFile(workspaceId, fileId) }, { queryKey: workspaceFilesKeys.record(workspaceId, fileId) }, { queryKey: workspaceFilesKeys.workspaceLists(workspaceId) }, diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 21fdcc052a2..86b0cb1430d 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -2148,7 +2148,7 @@ export const V2_MCP_OPERATIONS = { 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 the complete set in 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`.', + '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), 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": { From d193f3e270dfbfa7f9590c259cce419a24de7c4e Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 14:45:08 -0700 Subject: [PATCH 05/10] fix(docs): hide unreleased Project file references --- apps/docs/content/docs/cli/commands.mdx | 1 - apps/docs/content/docs/cli/meta.json | 1 - apps/docs/content/docs/cli/projects.mdx | 587 -- apps/docs/content/docs/cli/reference.mdx | 581 -- apps/docs/openapi-v2-files-audit.json | 8583 +++++------------ .../api/contracts/v2/openapi/files-audit.ts | 8 - .../v2/openapi/unpublished-project-files.ts | 16 + .../lib/mothership/generated/docs-manifest.ts | 1 - scripts/check-openapi-specs.ts | 7 + scripts/generate-cli-docs.ts | 3 +- 10 files changed, 2409 insertions(+), 7379 deletions(-) delete mode 100644 apps/docs/content/docs/cli/projects.mdx create mode 100644 apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index da4eeffa01d..e998b13dc7b 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -57,7 +57,6 @@ See [output formats](/cli/output) for the JSON default and display formats. | [`sim meta`](/cli/meta) | Inspect API availability and the calling credential’s type and expiry. | | [`sim organizations`](/cli/organizations) | Manage organization membership, invitations, usage, access requests, and SSO. | | [`sim permission-groups`](/cli/permission-groups) | Manage organization permission groups, their configuration, and member assignments. | -| [`sim projects`](/cli/projects) | Manage projects | | [`sim sandboxes`](/cli/sandboxes) | Manage reusable dependency sets for Function blocks. | | [`sim secrets`](/cli/secrets) | Set and manage workspace or personal secrets. | | [`sim selectors`](/cli/selectors) | Discover destination options for registered fields, such as calendars, channels, and tables. | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 30ee29465d3..14ef23a60dd 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -30,7 +30,6 @@ "meta", "organizations", "permission-groups", - "projects", "sandboxes", "secrets", "selectors", diff --git a/apps/docs/content/docs/cli/projects.mdx b/apps/docs/content/docs/cli/projects.mdx deleted file mode 100644 index acab786b036..00000000000 --- a/apps/docs/content/docs/cli/projects.mdx +++ /dev/null @@ -1,587 +0,0 @@ ---- -title: Projects -description: Manage projects ---- - -import { CommandTable } from '@/components/ui/command-table' - -Manage projects - -These commands accept the [global options](/cli/commands#global-options). - -## List project file folders - -OAuth login or personal API key required. - -```bash -sim projects files folders list [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--scope ` | No | Folder lifecycle scope. Defaults to "active". Accepted values: `active`, `archived`, `all`. | - - - -API request and response schemas: [listProjectFileFolders](/api-reference/files/listProjectFileFolders). - -## List project files - -OAuth login or personal API key required. - -```bash -sim projects files list [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | -| `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | 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. Accepted values: `active`, `archived`. | -| `--search ` | No | Case-insensitive substring match against the file name. | -| `--sort-by ` | No | Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`. Defaults to "uploadedAt". Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Defaults to "asc". Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | - - - -API request and response schemas: [listProjectFiles](/api-reference/files/listProjectFiles). - -## List the recorded versions of a shared project file - -OAuth login or personal API key required. - -```bash -sim projects files versions list [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--sort-by ` | No | Field used to sort the result. Defaults to "version". Accepted values: `version`. | -| `--sort-order ` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | - - - -API request and response schemas: [listProjectFileVersions](/api-reference/files/listProjectFileVersions). - -## Show project file metadata and ownership - -OAuth login or personal API key required. - -```bash -sim projects files describe -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -API request and response schemas: [getProjectFileMetadata](/api-reference/files/getProjectFileMetadata). - -## Show the metadata of one version of a shared project file - -OAuth login or personal API key required. - -```bash -sim projects files versions describe -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | -| `version` | Yes | Version number. | - - - -API request and response schemas: [getProjectFileVersion](/api-reference/files/getProjectFileVersion). - -## Create project file - -OAuth login or personal API key required. - -```bash -sim projects files create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | -| `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | -| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads. Defaults to "". | -| `--encoding ` | No | Encoding of the content field. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | - - - -API request and response schemas: [createProjectFile](/api-reference/files/createProjectFile). - -## Create project file folder - -OAuth login or personal API key required. - -```bash -sim projects files folders create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | Yes | Name for the new folder. | -| `--parent-id ` | No | Parent folder identifier; omit or use null for the root. (--parent-id null sends the word, not JSON null). | - - - -API request and response schemas: [createProjectFileFolder](/api-reference/files/createProjectFileFolder). - -## Upload a shared project file - -```bash -sim projects files upload [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project that will own the file | -| `path` | Yes | Local file to upload | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--folder ` | No | Folder path as shown in the app; defaults to the Project root. | -| `--name ` | No | Store it under a different name. | - - - -API request and response schemas: [createProjectFileUpload](/api-reference/files/createProjectFileUpload), [getProjectFileUploadPartUrls](/api-reference/files/getProjectFileUploadPartUrls), [completeProjectFileUpload](/api-reference/files/completeProjectFileUpload), [abortProjectFileUpload](/api-reference/files/abortProjectFileUpload). - -## Update project file folder - -OAuth login or personal API key required. - -```bash -sim projects files folders update [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `folderId` | Yes | Folder identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | No | New folder name; omit to leave unchanged. | -| `--parent-id ` | No | New parent folder identifier; null moves to the root, omission leaves the parent unchanged. (--parent-id null sends the word, not JSON null). | -| `--sort-order ` | No | New manual position; omit to leave unchanged. | - - - -API request and response schemas: [updateProjectFileFolder](/api-reference/files/updateProjectFileFolder). - -## Make a previous version of a shared project file current again - -OAuth login or personal API key required. - -```bash -sim projects files versions revert [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | -| `version` | Yes | Version number. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--expected-current-version ` | No | 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. | -| `--expected-revision ` | No | 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. | - - - -API request and response schemas: [revertProjectFileVersion](/api-reference/files/revertProjectFileVersion). - -## Permanently delete a previous version of a shared project file - -OAuth login or personal API key required. - -This permanently deletes the version and its stored content. - -```bash -sim projects files versions delete [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | -| `version` | Yes | Version number. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `-y, --yes` | Yes | Confirm this operation. | - - - -API request and response schemas: [deleteProjectFileVersion](/api-reference/files/deleteProjectFileVersion). - -## Restore project file folder - -OAuth login or personal API key required. - -```bash -sim projects files folders restore -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `folderId` | Yes | Folder identifier within the Project. | - - - -API request and response schemas: [restoreProjectFileFolder](/api-reference/files/restoreProjectFileFolder). - -## Restore project file - -OAuth login or personal API key required. - -```bash -sim projects files restore -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -API request and response schemas: [restoreProjectFile](/api-reference/files/restoreProjectFile). - -## Archive project file items - -OAuth login or personal API key required. - -This archives the selected Project files and folders, including folder contents. - -```bash -sim projects files archive [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--file-ids ` | No | Identifiers of the files to archive. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--folder-ids ` | No | Identifiers of folders to archive recursively, including their files and descendants. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this operation. | - - - -API request and response schemas: [archiveProjectFileItems](/api-reference/files/archiveProjectFileItems). - -## Move project file items - -OAuth login or personal API key required. - -```bash -sim projects files move [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--file-ids ` | No | Identifiers of the files to move. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--folder-ids ` | No | Identifiers of folders to move with their contents. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--to ` | No | Destination folder path; omit for root. | - - - -API request and response schemas: [moveProjectFileItems](/api-reference/files/moveProjectFileItems). - -## Rename project file - -OAuth login or personal API key required. - -```bash -sim projects files rename [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | Yes | New file name, including its extension. | - - - -API request and response schemas: [renameProjectFile](/api-reference/files/renameProjectFile). - -## Replace a shared project file’s contents - -OAuth login or personal API key required. - -```bash -sim projects files set-content [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--content ` | Yes | Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content. | -| `--encoding ` | No | Content encoding. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | -| `--expected-revision ` | No | Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on. | - - - -API request and response schemas: [updateProjectFileContent](/api-reference/files/updateProjectFileContent). - -## Download a project file’s stored source to stdout or a local file - -OAuth login or personal API key required. - -```bash -sim projects files source [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project that owns the file | -| `fileId` | Yes | File whose stored source to read | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `-o, --output-file ` | No | Write content to a file instead of stdout. | -| `--force` | No | Overwrite --output-file if it already exists. | - - - -API request and response schemas: [readProjectFileContent](/api-reference/files/readProjectFileContent). - -## Download a project file version’s stored source to stdout or a local file - -OAuth login or personal API key required. - -```bash -sim projects files versions source [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project that owns the file | -| `fileId` | Yes | File identifier. | -| `version` | Yes | Version number. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `-o, --output-file ` | No | Write content to a file instead of stdout. | -| `--force` | No | Overwrite --output-file if it already exists. | - - - -API request and response schemas: [readProjectFileVersionContent](/api-reference/files/readProjectFileVersionContent). diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index d79989145b2..27318d9e39e 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -4742,587 +4742,6 @@ sim permission-groups update [options] API request and response schemas: [updatePermissionGroup](/api-reference/permission-groups/updatePermissionGroup). -## sim projects - -### sim projects files archive - -Archive project file items (OAuth login or personal API key required) - -This archives the selected Project files and folders, including folder contents. - -```bash -sim projects files archive [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--file-ids ` | No | Identifiers of the files to archive. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--folder-ids ` | No | Identifiers of folders to archive recursively, including their files and descendants. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `-y, --yes` | Yes | Confirm this operation. | - - - -API request and response schemas: [archiveProjectFileItems](/api-reference/files/archiveProjectFileItems). - -### sim projects files create - -Create project file (OAuth login or personal API key required) - -```bash -sim projects files create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | -| `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | -| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads. Defaults to "". | -| `--encoding ` | No | Encoding of the content field. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | - - - -API request and response schemas: [createProjectFile](/api-reference/files/createProjectFile). - -### sim projects files folders create - -Create project file folder (OAuth login or personal API key required) - -```bash -sim projects files folders create [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | Yes | Name for the new folder. | -| `--parent-id ` | No | Parent folder identifier; omit or use null for the root. (--parent-id null sends the word, not JSON null). | - - - -API request and response schemas: [createProjectFileFolder](/api-reference/files/createProjectFileFolder). - -### sim projects files folders list - -List project file folders (OAuth login or personal API key required) - -```bash -sim projects files folders list [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--scope ` | No | Folder lifecycle scope. Defaults to "active". Accepted values: `active`, `archived`, `all`. | - - - -API request and response schemas: [listProjectFileFolders](/api-reference/files/listProjectFileFolders). - -### sim projects files folders restore - -Restore project file folder (OAuth login or personal API key required) - -```bash -sim projects files folders restore -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `folderId` | Yes | Folder identifier within the Project. | - - - -API request and response schemas: [restoreProjectFileFolder](/api-reference/files/restoreProjectFileFolder). - -### sim projects files folders update - -Update project file folder (OAuth login or personal API key required) - -```bash -sim projects files folders update [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `folderId` | Yes | Folder identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | No | New folder name; omit to leave unchanged. | -| `--parent-id ` | No | New parent folder identifier; null moves to the root, omission leaves the parent unchanged. (--parent-id null sends the word, not JSON null). | -| `--sort-order ` | No | New manual position; omit to leave unchanged. | - - - -API request and response schemas: [updateProjectFileFolder](/api-reference/files/updateProjectFileFolder). - -### sim projects files versions delete - -Permanently delete a previous version of a shared project file (OAuth login or personal API key required) - -This permanently deletes the version and its stored content. - -```bash -sim projects files versions delete [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | -| `version` | Yes | Version number. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `-y, --yes` | Yes | Confirm this operation. | - - - -API request and response schemas: [deleteProjectFileVersion](/api-reference/files/deleteProjectFileVersion). - -### sim projects files versions describe - -Show the metadata of one version of a shared project file (OAuth login or personal API key required) - -```bash -sim projects files versions describe -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | -| `version` | Yes | Version number. | - - - -API request and response schemas: [getProjectFileVersion](/api-reference/files/getProjectFileVersion). - -### sim projects files versions list - -List the recorded versions of a shared project file (OAuth login or personal API key required) - -```bash -sim projects files versions list [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--sort-by ` | No | Field used to sort the result. Defaults to "version". Accepted values: `version`. | -| `--sort-order ` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | - - - -API request and response schemas: [listProjectFileVersions](/api-reference/files/listProjectFileVersions). - -### sim projects files versions revert - -Make a previous version of a shared project file current again (OAuth login or personal API key required) - -```bash -sim projects files versions revert [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | -| `version` | Yes | Version number. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--expected-current-version ` | No | 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. | -| `--expected-revision ` | No | 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. | - - - -API request and response schemas: [revertProjectFileVersion](/api-reference/files/revertProjectFileVersion). - -### sim projects files versions source - -Download a project file version’s stored source to stdout or a local file (OAuth login or personal API key required) - -```bash -sim projects files versions source [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project that owns the file | -| `fileId` | Yes | File identifier. | -| `version` | Yes | Version number. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `-o, --output-file ` | No | Write content to a file instead of stdout. | -| `--force` | No | Overwrite --output-file if it already exists. | - - - -API request and response schemas: [readProjectFileVersionContent](/api-reference/files/readProjectFileVersionContent). - -### sim projects files describe - -Show project file metadata and ownership (OAuth login or personal API key required) - -```bash -sim projects files describe -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -API request and response schemas: [getProjectFileMetadata](/api-reference/files/getProjectFileMetadata). - -### sim projects files list - -List project files (OAuth login or personal API key required) - -```bash -sim projects files list [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | -| `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | 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. Accepted values: `active`, `archived`. | -| `--search ` | No | Case-insensitive substring match against the file name. | -| `--sort-by ` | No | Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`. Defaults to "uploadedAt". Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | -| `--sort-order ` | No | Sort direction. Defaults to "asc". Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | - - - -API request and response schemas: [listProjectFiles](/api-reference/files/listProjectFiles). - -### sim projects files move - -Move project file items (OAuth login or personal API key required) - -```bash -sim projects files move [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--file-ids ` | No | Identifiers of the files to move. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--folder-ids ` | No | Identifiers of folders to move with their contents. Defaults to []. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--to ` | No | Destination folder path; omit for root. | - - - -API request and response schemas: [moveProjectFileItems](/api-reference/files/moveProjectFileItems). - -### sim projects files rename - -Rename project file (OAuth login or personal API key required) - -```bash -sim projects files rename [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--name ` | Yes | New file name, including its extension. | - - - -API request and response schemas: [renameProjectFile](/api-reference/files/renameProjectFile). - -### sim projects files restore - -Restore project file (OAuth login or personal API key required) - -```bash -sim projects files restore -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -API request and response schemas: [restoreProjectFile](/api-reference/files/restoreProjectFile). - -### sim projects files set-content - -Replace a shared project file’s contents (OAuth login or personal API key required) - -```bash -sim projects files set-content [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project identifier. | -| `fileId` | Yes | File identifier within the Project. | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--content ` | Yes | Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content. | -| `--encoding ` | No | Content encoding. Defaults to "utf-8". Accepted values: `utf-8`, `base64`. | -| `--expected-revision ` | No | Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on. | - - - -API request and response schemas: [updateProjectFileContent](/api-reference/files/updateProjectFileContent). - -### sim projects files upload - -Upload a shared project file - -```bash -sim projects files upload [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project that will own the file | -| `path` | Yes | Local file to upload | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `--folder ` | No | Folder path as shown in the app; defaults to the Project root. | -| `--name ` | No | Store it under a different name. | - - - -API request and response schemas: [createProjectFileUpload](/api-reference/files/createProjectFileUpload), [getProjectFileUploadPartUrls](/api-reference/files/getProjectFileUploadPartUrls), [completeProjectFileUpload](/api-reference/files/completeProjectFileUpload), [abortProjectFileUpload](/api-reference/files/abortProjectFileUpload). - -### sim projects files source - -Download a project file’s stored source to stdout or a local file (OAuth login or personal API key required) - -```bash -sim projects files source [options] -``` - -**Arguments** - - - -| Argument | Required | Description | -| --- | --- | --- | -| `projectId` | Yes | Project that owns the file | -| `fileId` | Yes | File whose stored source to read | - - - -**Options** - - - -| Option | Required | Description | -| --- | --- | --- | -| `-o, --output-file ` | No | Write content to a file instead of stdout. | -| `--force` | No | Overwrite --output-file if it already exists. | - - - -API request and response schemas: [readProjectFileContent](/api-reference/files/readProjectFileContent). - ## sim sandboxes Manage reusable dependency sets for Function blocks. diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index c4fe4b94ed4..fc2d2f62ac0 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -39,60 +39,143 @@ } ], "paths": { - "/api/v2/projects/{projectId}/files/{fileId}": { - "patch": { - "operationId": "renameProjectFile", - "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`.", - "x-sim-operation": "project_files.rename", - "x-oauth-scope": "api:write", + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List files", + "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.list", + "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], "parameters": [ { - "name": "projectId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Project identifier.", + "description": "Workspace whose files should be listed.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "description": "Workspace whose files should be listed." } }, { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier within the Project.", + "name": "folderPath", + "in": "query", + "required": false, + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "schema": { + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "default": "active", + "description": "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.", + "type": "string", + "enum": ["active", "archived"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the file name.", "schema": { + "description": "Case-insensitive substring match against the file name.", "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 200 } - } - ], - "requestBody": { - "required": true, - "description": "The new file name.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RenameProjectFileRequest" - } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", + "schema": { + "default": "uploadedAt", + "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "schema": { + "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "type": "integer", + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "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.", + "schema": { + "description": "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.", + "type": "string", + "minLength": 1 } } - }, + ], "responses": { "200": { - "description": "Metadata for the renamed file.", + "description": "A page of workspace files.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -107,7 +190,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" + "$ref": "#/components/schemas/V2FileListResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": [ + { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + ], + "nextCursor": null + } + } } } } @@ -124,15 +230,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -143,14 +243,12 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/projects/{projectId}/files/move": { + }, "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.move_items", + "operationId": "createFile", + "summary": "Create file", + "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.create", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -161,33 +259,30 @@ "oauthBearer": ["api:write"] } ], - "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - } - ], "requestBody": { "required": true, - "description": "Selected files and folders and their destination.", + "description": "Inline content and placement for a new workspace file.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveProjectFileItemsRequest" + "$ref": "#/components/schemas/CreateFileRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "notes.md", + "content": "# Notes" + } + } } } } }, "responses": { - "200": { - "description": "Counts and identifiers of moved items.", + "201": { + "description": "The created file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -202,7 +297,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2MoveProjectFileItemsResponse" + "$ref": "#/components/schemas/V2CreatedFileResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + } + } } } } @@ -240,12 +355,12 @@ } } }, - "/api/v2/projects/{projectId}/files/archive": { + "/api/v2/files/uploads": { "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.archive_items", + "operationId": "createFileUpload", + "summary": "Create file upload", + "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.create", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -256,33 +371,31 @@ "oauthBearer": ["api:write"] } ], - "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - } - ], "requestBody": { "required": true, - "description": "Files and folders to archive.", + "description": "File metadata required to create an upload session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveProjectFileItemsRequest" + "$ref": "#/components/schemas/CreateFileUploadRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "archive.zip", + "contentType": "application/zip", + "size": 1048576 + } + } } } } }, "responses": { - "200": { - "description": "Counts and identifiers of archived items.", + "201": { + "description": "The created upload session and transfer instructions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -297,7 +410,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ArchiveProjectFileItemsResponse" + "$ref": "#/components/schemas/CreateFileUploadResponse" } } } @@ -314,9 +427,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -335,60 +445,61 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/restore": { - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.restore", - "x-oauth-scope": "api:write", + "/api/v2/files/uploads/{uploadId}": { + "get": { + "operationId": "getFileUpload", + "summary": "Get file upload", + "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.upload.read", + "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], "parameters": [ { - "name": "projectId", + "name": "uploadId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "description": "Upload session identifier." } }, { - "name": "fileId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "File identifier within the Project.", + "description": "Workspace that owns the upload session.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "description": "Workspace that owns the upload session." } - } - ], - "requestBody": { - "required": true, - "description": "An empty object; the file is identified by its path parameters.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreProjectFileRequest" - } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { "200": { - "description": "Metadata for the restored file.", + "description": "Current upload-session state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -403,7 +514,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -420,15 +531,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -439,14 +541,12 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/projects/{projectId}/files": { - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.create", + }, + "delete": { + "operationId": "abortFileUpload", + "summary": "Abort file upload", + "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.cancel", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -459,31 +559,43 @@ ], "parameters": [ { - "name": "projectId", + "name": "uploadId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "description": "Upload session identifier." } - } - ], - "requestBody": { - "required": true, - "description": "Name, content, encoding, and containing folder within the Project.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProjectFileRequest" - } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the upload session.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { - "201": { - "description": "The created file and its content revision.", + "200": { + "description": "The aborted upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -498,7 +610,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -518,12 +630,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -534,144 +640,82 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "get": { - "operationId": "listProjectFiles", - "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`.", - "x-sim-operation": "project_files.list", - "x-oauth-scope": "api:read", + } + }, + "/api/v2/files/uploads/{uploadId}/parts": { + "post": { + "operationId": "createFileUploadPartUrls", + "summary": "Create file upload part URLs", + "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.parts", + "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:read"] + "oauthBearer": ["api:write"] } ], "parameters": [ { - "name": "projectId", + "name": "uploadId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." - } - }, - { - "name": "folderPath", - "in": "query", - "required": false, - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "schema": { - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "schema": { - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "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.", - "schema": { - "default": "active", - "description": "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.", - "type": "string", - "enum": ["active", "archived"] + "description": "Upload session identifier." } }, { - "name": "search", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Case-insensitive substring match against the file name.", + "required": true, + "description": "Workspace that owns the upload session.", "schema": { - "description": "Case-insensitive substring match against the file name.", "type": "string", "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", - "schema": { - "default": "uploadedAt", - "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", - "type": "string", - "enum": ["name", "size", "uploadedAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.", - "schema": { - "default": 100, - "description": "Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.", - "type": "integer", - "minimum": 1, - "maximum": 1000 + "maxLength": 128, + "description": "Workspace that owns the upload session." } }, { - "name": "cursor", - "in": "query", - "required": false, - "description": "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.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "description": "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.", "type": "string", - "minLength": 1 + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], + "requestBody": { + "required": true, + "description": "Multipart part numbers requiring signed URLs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileUploadPartUrlsRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "partNumbers": [1, 2] + } + } + } + } + } + }, "responses": { "200": { - "description": "A page of Project files.", + "description": "Signed URLs for the requested upload parts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -686,7 +730,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileListResponse" + "$ref": "#/components/schemas/CreateFileUploadPartUrlsResponse" } } } @@ -703,6 +747,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -715,151 +768,61 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/content": { - "get": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "project_files.read_content", - "x-oauth-scope": "api:read", + "/api/v2/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeFileUpload", + "summary": "Complete file upload", + "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.complete", + "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:read"] + "oauthBearer": ["api:write"] } ], "parameters": [ { - "name": "projectId", + "name": "uploadId", "in": "path", "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier within the Project.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." - } - } - ], - "responses": { - "200": { - "description": "The current stored content bytes.", - "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "Content-Length": { - "$ref": "#/components/headers/Content-Length" - } - }, - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } + "description": "Upload session identifier." } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "put": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.update_content", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ { - "name": "projectId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Project identifier.", + "description": "Workspace that owns the upload session.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "description": "Workspace that owns the upload session." } }, { - "name": "fileId", - "in": "path", + "name": "upload-token", + "in": "header", "required": true, - "description": "File identifier within the Project.", + "description": "Signed upload control token returned when the upload session was created.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "description": "Signed upload control token returned when the upload session was created." } } ], - "requestBody": { - "required": true, - "description": "Complete replacement bytes and an optional optimistic concurrency revision.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProjectFileContentRequest" - } - } - } - }, "responses": { "200": { - "description": "The updated file and its content revision.", + "description": "The completed or finalizing upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -874,7 +837,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -894,12 +857,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -912,12 +869,12 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/metadata": { + "/api/v2/files/{fileId}/text": { "get": { - "operationId": "getProjectFileMetadata", - "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`.", - "x-sim-operation": "project_files.read_metadata", + "operationId": "readFileText", + "summary": "Read file text", + "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip file to unpack archives or Download file for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -930,31 +887,69 @@ ], "parameters": [ { - "name": "projectId", + "name": "fileId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 4096, + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." } }, { - "name": "fileId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "File identifier within the Project.", + "description": "Workspace that owns the file.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 } } ], "responses": { "200": { - "description": "Project file metadata.", + "description": "The extracted text and its extraction-quality flags.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -969,7 +964,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" + "$ref": "#/components/schemas/FileTextResponse" } } } @@ -986,6 +981,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -998,12 +999,12 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/versions": { + "/api/v2/files/{fileId}/versions": { "get": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "project_files.versions.list", + "operationId": "listFileVersions", + "summary": "List file versions", + "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.list", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -1016,25 +1017,28 @@ ], "parameters": [ { - "name": "projectId", + "name": "fileId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { - "name": "fileId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "File identifier within the Project.", + "description": "Workspace that owns the file.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "description": "Workspace that owns the file." } }, { @@ -1088,7 +1092,7 @@ ], "responses": { "200": { - "description": "A cursor-paginated page of Project file versions.", + "description": "A page of file versions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1103,7 +1107,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileVersionListResponse" + "$ref": "#/components/schemas/V2FileVersionListResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": [ + { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 3, + "isCurrent": true, + "size": 1024, + "contentType": "text/csv", + "source": "api", + "authors": [ + { + "id": "usr_4kJ9mN2pQ7rS", + "email": "jane@example.com" + } + ], + "restoredFromVersion": null, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "supersededAt": null + } + ], + "nextCursor": null + } + } } } } @@ -1132,12 +1164,12 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}": { + "/api/v2/files/{fileId}/versions/{version}": { "get": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "project_files.versions.read", + "operationId": "getFileVersion", + "summary": "Get file version", + "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -1149,26 +1181,17 @@ } ], "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier within the Project.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { @@ -1182,11 +1205,23 @@ "maximum": 2147483647, "description": "Version number." } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } } ], "responses": { "200": { - "description": "Metadata for the selected version.", + "description": "The file version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1201,22 +1236,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileVersionResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" + "$ref": "#/components/schemas/V2FileVersionResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 3, + "isCurrent": true, + "size": 1024, + "contentType": "text/csv", + "source": "api", + "authors": [ + { + "id": "usr_4kJ9mN2pQ7rS", + "email": "jane@example.com" + } + ], + "restoredFromVersion": null, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "supersededAt": null + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1230,10 +1290,10 @@ } }, "delete": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.versions.delete", + "operationId": "deleteFileVersion", + "summary": "Delete file version", + "description": "Permanently remove an earlier version from history and schedule deletion of its stored content. Storage cleanup may retry in the background. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.delete", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -1245,26 +1305,17 @@ } ], "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier within the Project.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { @@ -1278,11 +1329,23 @@ "maximum": 2147483647, "description": "Version number." } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } } ], "responses": { "200": { - "description": "Deletion acknowledgement for the superseded version.", + "description": "Deletion confirmation.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1297,7 +1360,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileVersionDeleteResponse" + "$ref": "#/components/schemas/V2FileVersionDeleteResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 2, + "deleted": true + } + } + } } } } @@ -1329,12 +1404,12 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/content": { + "/api/v2/files/{fileId}/versions/{version}/text": { "get": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "project_files.versions.read_content", + "operationId": "readFileVersionText", + "summary": "Read file version text", + "description": "Extract the text of one version, exactly as Read file text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -1346,26 +1421,17 @@ } ], "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier within the Project.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { @@ -1379,11 +1445,59 @@ "maximum": 2147483647, "description": "Version number." } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } } ], "responses": { "200": { - "description": "The selected version’s stored source bytes.", + "description": "The extracted text of the version and its extraction-quality flags.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1396,10 +1510,9 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/FileVersionTextResponse" } } } @@ -1434,43 +1547,34 @@ } } }, - "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/revert": { - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_files.versions.revert", - "x-oauth-scope": "api:write", + "/api/v2/files/{fileId}/versions/{version}/content": { + "get": { + "operationId": "downloadFileVersion", + "summary": "Download file version", + "description": "Download the bytes of one version, served exactly as Download file serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length` and cannot determine the download size.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.download", + "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier within the Project.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "File identifier within the Project." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { @@ -1484,23 +1588,33 @@ "maximum": 2147483647, "description": "Version number." } - } - ], - "requestBody": { - "required": true, - "description": "Optional revision or current-version preconditions.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RevertProjectFileVersionRequest" - } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } } - }, + ], "responses": { "200": { - "description": "The file and current version after the revert.", + "description": "The version bytes.", "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1512,9 +1626,10 @@ } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileVersionRevertResponse" + "type": "string", + "format": "binary" } } } @@ -1537,9 +1652,6 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1552,53 +1664,75 @@ } } }, - "/api/v2/projects/{projectId}/files/folders": { - "get": { - "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. 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`.", - "x-sim-operation": "project_file_folders.list", - "x-oauth-scope": "api:read", + "/api/v2/files/{fileId}/versions/{version}/revert": { + "post": { + "operationId": "revertFileVersion", + "summary": "Revert file version", + "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MiB returns `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.revert", + "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:read"] + "oauthBearer": ["api:write"] } ], "parameters": [ { - "name": "projectId", + "name": "fileId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { - "name": "scope", - "in": "query", - "required": false, - "description": "Folder lifecycle scope.", + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", "schema": { - "default": "active", - "description": "Folder lifecycle scope.", - "type": "string", - "enum": ["active", "archived", "all"] + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } } ], - "responses": { - "200": { - "description": "The complete set of folders in the selected lifecycle scope.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "requestBody": { + "required": true, + "description": "Workspace scope and an optional current-version precondition.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertFileVersionRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "expectedCurrentVersion": 4 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The file and its current version after the revert.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, "X-RateLimit-Remaining": { "$ref": "#/components/headers/X-RateLimit-Remaining" @@ -1610,7 +1744,49 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileFolderListResponse" + "$ref": "#/components/schemas/V2FileVersionRevertResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "reverted": true, + "file": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + }, + "version": { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "version": 5, + "isCurrent": true, + "size": 1024, + "contentType": "text/csv", + "source": "revert", + "authors": [ + { + "id": "usr_4kJ9mN2pQ7rS", + "email": "jane@example.com" + } + ], + "restoredFromVersion": 3, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "supersededAt": null + }, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" + } + } + } } } } @@ -1627,6 +1803,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1637,50 +1822,68 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_file_folders.create", - "x-oauth-scope": "api:write", + } + }, + "/api/v2/files/bulk-download": { + "get": { + "operationId": "bulkDownloadFiles", + "summary": "Bulk download files", + "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length` and cannot determine the download size.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], "parameters": [ { - "name": "projectId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Project identifier.", + "description": "Workspace containing the selection.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "description": "Workspace containing the selection." } - } - ], - "requestBody": { - "required": true, - "description": "The folder name and optional parent identifier.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProjectFileFolderRequest" - } + }, + { + "name": "fileIds", + "in": "query", + "required": false, + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "schema": { + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "type": "string" + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "schema": { + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "type": "string" } } - }, + ], "responses": { - "201": { - "description": "The created folder and its owner and creator attribution.", + "200": { + "description": "The selected files as a zip archive.", "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1692,9 +1895,10 @@ } }, "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileFolderResponse" + "type": "string", + "format": "binary" } } } @@ -1714,12 +1918,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1732,12 +1930,12 @@ } } }, - "/api/v2/projects/{projectId}/files/folders/{folderId}": { - "patch": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_file_folders.update", + "/api/v2/files/{fileId}/unzip": { + "post": { + "operationId": "unzipFile", + "summary": "Unzip file", + "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.extract_archive", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -1750,42 +1948,33 @@ ], "parameters": [ { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, - { - "name": "folderId", + "name": "fileId", "in": "path", "required": true, - "description": "Folder identifier within the Project.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Folder identifier within the Project." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], "requestBody": { "required": true, - "description": "The fields to change on the folder.", + "description": "Workspace scope for the archive.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateProjectFileFolderRequest" + "$ref": "#/components/schemas/UnzipFileBody" } } } }, "responses": { "200": { - "description": "The updated folder.", + "description": "Counts and destination folder for the unpacked archive.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1800,7 +1989,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileFolderResponse" + "$ref": "#/components/schemas/FileUnzipResponse" } } } @@ -1838,61 +2027,62 @@ } } }, - "/api/v2/projects/{projectId}/files/folders/{folderId}/restore": { - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_file_folders.restore", - "x-oauth-scope": "api:write", + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download file", + "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length` and cannot determine the download size.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], "parameters": [ { - "name": "projectId", + "name": "fileId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { - "name": "folderId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Folder identifier within the Project.", + "description": "Workspace that owns the file.", "schema": { "type": "string", "minLength": 1, - "description": "Folder identifier within the Project." + "maxLength": 128, + "description": "Workspace that owns the file." } } ], - "requestBody": { - "required": true, - "description": "An empty object; the folder is identified in the path.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreProjectFileFolderRequest" - } - } - } - }, "responses": { "200": { - "description": "The restored folder and affected item counts.", + "description": "The file bytes.", "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1904,9 +2094,10 @@ } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/V2RestoreProjectFileFolderResponse" + "type": "string", + "format": "binary" } } } @@ -1929,9 +2120,6 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1942,14 +2130,12 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/projects/{projectId}/files/uploads": { - "post": { - "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. The original API credential and upload-token are required on every control request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_file_uploads.create", + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete file", + "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List files with `scope=archived` finds it; Restore file recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -1962,31 +2148,34 @@ ], "parameters": [ { - "name": "projectId", + "name": "fileId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "description": "Workspace that owns the file." } } ], - "requestBody": { - "required": true, - "description": "The parameters for this upload operation.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectUploadCreateRequest" - } - } - } - }, "responses": { - "201": { - "description": "Create Project File Upload result.", + "200": { + "description": "Deletion confirmation.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2001,7 +2190,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2CreateProjectFileUploadResponse" + "$ref": "#/components/schemas/V2DeleteFileResponse" } } } @@ -2018,17 +2207,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" + "429": { + "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" @@ -2037,14 +2217,12 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/projects/{projectId}/files/uploads/{uploadId}": { - "get": { - "operationId": "getProjectFileUpload", - "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:write`.", - "x-sim-operation": "project_file_uploads.read", + }, + "patch": { + "operationId": "renameFile", + "summary": "Rename file", + "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.rename", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -2057,42 +2235,42 @@ ], "parameters": [ { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, - { - "name": "uploadId", + "name": "fileId", "in": "path", "required": true, - "description": "Upload session identifier within the Project.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier within the Project." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], + "requestBody": { + "required": true, + "description": "Workspace scope and new file name.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameFileRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "renamed.csv" + } + } + } + } + } + }, "responses": { "200": { - "description": "Get Project File Upload result.", + "description": "The renamed file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2107,7 +2285,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileUploadResponse" + "$ref": "#/components/schemas/V2FileResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + } + } } } } @@ -2143,12 +2341,14 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "abortProjectFileUpload", - "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`.", - "x-sim-operation": "project_file_uploads.cancel", + } + }, + "/api/v2/files/{fileId}/restore": { + "post": { + "operationId": "restoreFile", + "summary": "Restore file", + "description": "Restore a soft-deleted file to its original folder, or the workspace root if that folder was archived. Name collisions add a `_restored` suffix; read `folderPath` and `name` from the response. Already-active files return unchanged, making retries safe. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.restore", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -2161,42 +2361,41 @@ ], "parameters": [ { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, - { - "name": "uploadId", + "name": "fileId", "in": "path", "required": true, - "description": "Upload session identifier within the Project.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier within the Project." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archived file.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + } + } + } + }, "responses": { "200": { - "description": "Abort Project File Upload result.", + "description": "The file as it exists after the restore.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2211,7 +2410,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileUploadResponse" + "$ref": "#/components/schemas/V2RestoreFileResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data_restored.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + } + } } } } @@ -2249,60 +2468,64 @@ } } }, - "/api/v2/projects/{projectId}/files/uploads/{uploadId}/complete": { - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_file_uploads.complete", - "x-oauth-scope": "api:write", + "/api/v2/files/{fileId}/metadata": { + "get": { + "operationId": "getFile", + "summary": "Get file metadata", + "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List file versions and is the precondition Revert file version accepts.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_metadata", + "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], "parameters": [ { - "name": "projectId", + "name": "fileId", "in": "path", "required": true, - "description": "Project identifier.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Project identifier." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { - "name": "uploadId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "Upload session identifier within the Project.", + "description": "Workspace that owns the file.", "schema": { "type": "string", "minLength": 1, - "description": "Upload session identifier within the Project." + "maxLength": 128, + "description": "Workspace that owns the file." } }, { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "schema": { + "default": "active", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "enum": ["active", "archived"] } } ], "responses": { "200": { - "description": "Complete Project File Upload result.", + "description": "File metadata and public-share state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2317,7 +2540,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2ProjectFileUploadResponse" + "$ref": "#/components/schemas/V2FileMetadataResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null, + "share": null, + "currentVersion": 1, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" + } + } + }, + "example2": { + "summary": "Response example 2", + "value": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-16T09:12:00Z", + "deletedAt": null, + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + }, + "currentVersion": 3, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg" + } + } + } } } } @@ -2334,134 +2611,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/projects/{projectId}/files/uploads/{uploadId}/parts": { - "post": { - "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 keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "project_file_uploads.parts", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "projectId", - "in": "path", - "required": true, - "description": "Project identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Project identifier." - } - }, - { - "name": "uploadId", - "in": "path", - "required": true, - "description": "Upload session identifier within the Project.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier within the Project." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." - } - } - ], - "requestBody": { - "required": true, - "description": "The parameters for this upload operation.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectFileUploadPartUrlsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Get Project File Upload Part URLs result.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ProjectFileUploadPartUrlsResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" + "429": { + "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" @@ -2472,14 +2623,14 @@ } } }, - "/api/v2/files": { + "/api/v2/audit-logs": { "get": { - "operationId": "listFiles", - "summary": "List files", - "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.list", + "operationId": "listAuditLogs", + "summary": "List audit logs", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.list", "x-oauth-scope": "api:read", - "tags": ["Files"], + "tags": ["Audit Logs"], "security": [ { "apiKey": [] @@ -2490,108 +2641,92 @@ ], "parameters": [ { - "name": "workspaceId", + "name": "action", "in": "query", - "required": true, - "description": "Workspace whose files should be listed.", + "required": false, + "description": "Filter by exact action name.", "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose files should be listed." + "description": "Filter by exact action name.", + "type": "string" } }, { - "name": "folderPath", + "name": "resourceType", "in": "query", "required": false, - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", "schema": { - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" + "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", + "type": "string" } }, { - "name": "recursive", + "name": "resourceId", "in": "query", "required": false, - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "description": "Filter by exact resource identifier.", "schema": { - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], + "description": "Filter by exact resource identifier.", "type": "string" } }, { - "name": "scope", + "name": "workspaceId", "in": "query", "required": false, - "description": "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.", + "description": "Filter to actions in one workspace.", "schema": { - "default": "active", - "description": "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.", + "description": "Filter to actions in one workspace.", "type": "string", - "enum": ["active", "archived"] + "minLength": 1, + "maxLength": 128 } }, { - "name": "search", + "name": "startDate", "in": "query", "required": false, - "description": "Case-insensitive substring match against the file name.", + "description": "Include audit entries created at or after this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", "schema": { - "description": "Case-insensitive substring match against the file name.", + "description": "Include audit entries created at or after this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", "type": "string", - "minLength": 1, - "maxLength": 200 + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" } }, { - "name": "sortBy", + "name": "endDate", "in": "query", "required": false, - "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", + "description": "Include audit entries created at or before this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", "schema": { - "default": "uploadedAt", - "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", + "description": "Include audit entries created at or before this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", "type": "string", - "enum": ["name", "size", "uploadedAt", "updatedAt"] + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" } }, { - "name": "sortOrder", + "name": "includeDeparted", "in": "query", "required": false, - "description": "Sort direction.", + "description": "Include actions by users who have left the organization.", "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] + "description": "Include actions by users who have left the organization.", + "type": "boolean" } }, { "name": "limit", "in": "query", "required": false, - "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { - "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "default": 50, + "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", - "default": 100 + "minimum": 1, + "maximum": 100 } }, { @@ -2604,11 +2739,34 @@ "type": "string", "minLength": 1 } + }, + { + "name": "organizationId", + "in": "query", + "required": false, + "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "schema": { + "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "actorEmail", + "in": "query", + "required": false, + "description": "Filter by actor email address.", + "schema": { + "description": "Filter by actor email address.", + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } } ], "responses": { "200": { - "description": "A page of workspace files.", + "description": "A page of audit-log entries.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2623,7 +2781,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileListResponse" + "$ref": "#/components/schemas/V2AuditLogListResponse" }, "examples": { "example1": { @@ -2631,24 +2789,27 @@ "value": { "data": [ { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - } - ], - "nextCursor": null - } - } - } - } + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + } } }, "400": { @@ -2660,12 +2821,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2676,46 +2831,51 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "post": { - "operationId": "createFile", - "summary": "Create file", - "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.create", - "x-oauth-scope": "api:write", - "tags": ["Files"], + } + }, + "/api/v2/audit-logs/{auditLogId}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get audit log", + "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.read_detail", + "x-oauth-scope": "api:read", + "tags": ["Audit Logs"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:write"] + "oauthBearer": ["api:read"] } ], - "requestBody": { - "required": true, - "description": "Inline content and placement for a new workspace file.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFileRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "notes.md", - "content": "# Notes" - } - } - } + "parameters": [ + { + "name": "auditLogId", + "in": "path", + "required": true, + "description": "Audit-log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Audit-log entry identifier." + } + }, + { + "name": "organizationId", + "in": "query", + "required": false, + "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "schema": { + "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "type": "string", + "minLength": 1 } } - }, + ], "responses": { - "201": { - "description": "The created file.", + "200": { + "description": "The requested audit-log entry.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2730,24 +2890,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2CreatedFileResponse" + "$ref": "#/components/schemas/V2AuditLogResponse" }, "examples": { "example1": { "summary": "Response example 1", "value": { "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" } } } @@ -2767,15 +2930,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2788,12 +2942,12 @@ } } }, - "/api/v2/files/uploads": { + "/api/v2/files/move": { "post": { - "operationId": "createFileUpload", - "summary": "Create file upload", - "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.create", + "operationId": "moveFileItems", + "summary": "Move files", + "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.move", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -2806,20 +2960,19 @@ ], "requestBody": { "required": true, - "description": "File metadata required to create an upload session.", + "description": "Files and destination selected for a bulk move.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadRequest" + "$ref": "#/components/schemas/MoveFileItemsRequest" }, "examples": { "example1": { "summary": "Request example 1", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "archive.zip", - "contentType": "application/zip", - "size": 1048576 + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], + "targetFolderPath": "/Archive" } } } @@ -2827,8 +2980,8 @@ } }, "responses": { - "201": { - "description": "The created upload session and transfer instructions.", + "200": { + "description": "Count of moved files.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2843,7 +2996,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadResponse" + "$ref": "#/components/schemas/V2MoveFileItemsResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "movedItems": { + "files": 1 + } + } + } + } } } } @@ -2860,6 +3025,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -2878,12 +3046,12 @@ } } }, - "/api/v2/files/uploads/{uploadId}": { + "/api/v2/files/{fileId}/share": { "get": { - "operationId": "getFileUpload", - "summary": "Get file upload", - "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.upload.read", + "operationId": "getFileShare", + "summary": "Get file share", + "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.share.read", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -2896,43 +3064,34 @@ ], "parameters": [ { - "name": "uploadId", + "name": "fileId", "in": "path", "required": true, - "description": "Upload session identifier.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Upload session identifier." + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace that owns the upload session.", + "description": "Workspace that owns the file.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "Workspace that owns the file." } } ], "responses": { "200": { - "description": "Current upload-session state.", + "description": "Current nullable file-share state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2947,7 +3106,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2GetFileShareResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + }, + "example2": { + "summary": "Response example 2", + "value": { + "data": null + } + } } } } @@ -2975,11 +3158,11 @@ } } }, - "delete": { - "operationId": "abortFileUpload", - "summary": "Abort file upload", - "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.cancel", + "patch": { + "operationId": "upsertFileShare", + "summary": "Enable or disable file share", + "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.share.update", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -2992,43 +3175,50 @@ ], "parameters": [ { - "name": "uploadId", + "name": "fileId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], + "requestBody": { + "required": true, + "description": "Desired public-share state and access policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertFileShareRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "public" + } + }, + "example2": { + "summary": "Request example 2", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": false + } + } + } + } + } + }, "responses": { "200": { - "description": "The aborted upload session.", + "description": "The updated file share.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3043,7 +3233,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2UpsertFileShareResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } } } } @@ -3060,8 +3268,11 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -3075,12 +3286,12 @@ } } }, - "/api/v2/files/uploads/{uploadId}/parts": { - "post": { - "operationId": "createFileUploadPartUrls", - "summary": "Create file upload part URLs", - "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.parts", + "/api/v2/files/{fileId}/content": { + "patch": { + "operationId": "editFileContent", + "summary": "Edit file content", + "description": "Edit part of a UTF-8 file; use Replace file content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -3093,53 +3304,48 @@ ], "parameters": [ { - "name": "uploadId", + "name": "fileId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], "requestBody": { "required": true, - "description": "Multipart part numbers requiring signed URLs.", + "description": "Workspace scope and the change to apply.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadPartUrlsRequest" + "$ref": "#/components/schemas/EditFileContentRequest" }, "examples": { "example1": { "summary": "Request example 1", "value": { - "partNumbers": [1, 2] + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "edit": { + "mode": "search_replace", + "search": "- based in NYC", + "content": "- based in SF" + } + } + }, + "example2": { + "summary": "Request example 2", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "edit": { + "mode": "insert_after", + "anchor": "## Preferences", + "content": "- prefers async updates" + } } } } @@ -3148,7 +3354,7 @@ }, "responses": { "200": { - "description": "Signed URLs for the requested upload parts.", + "description": "The edited file and its new line count.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3163,7 +3369,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadPartUrlsResponse" + "$ref": "#/components/schemas/V2EditedFileResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "file": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + }, + "lineCount": 5 + } + } + } } } } @@ -3189,6 +3418,9 @@ "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3199,14 +3431,12 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/uploads/{uploadId}/complete": { - "post": { - "operationId": "completeFileUpload", - "summary": "Complete file upload", - "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.complete", + }, + "put": { + "operationId": "updateFileContent", + "summary": "Replace file content", + "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -3219,43 +3449,42 @@ ], "parameters": [ { - "name": "uploadId", + "name": "fileId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "File identifier.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } } ], + "requestBody": { + "required": true, + "description": "Workspace scope and complete replacement content.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFileContentRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "replacement text" + } + } + } + } + } + }, "responses": { "200": { - "description": "The completed or finalizing upload session.", + "description": "The updated file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3270,7 +3499,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2WrittenFileResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" + } + } + } } } } @@ -3290,6 +3540,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3302,12 +3558,12 @@ } } }, - "/api/v2/files/{fileId}/text": { + "/api/v2/files/search": { "get": { - "operationId": "readFileText", - "summary": "Read file text", - "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip file to unpack archives or Download file for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.read_content", + "operationId": "searchFileContent", + "summary": "Search file content", + "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.search_content", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -3320,69 +3576,92 @@ ], "parameters": [ { - "name": "fileId", - "in": "path", + "name": "workspaceId", + "in": "query", "required": true, - "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", + "description": "Workspace to search.", "schema": { "type": "string", "minLength": 1, - "maxLength": 4096, - "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." + "maxLength": 128, + "description": "Workspace to search." } }, { - "name": "workspaceId", + "name": "query", "in": "query", "required": true, - "description": "Workspace that owns the file.", + "description": "Regular expression, or exact text when `mode` is `exact`.", "schema": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Regular expression, or exact text when `mode` is `exact`.", + "minLength": 3, + "maxLength": 512 } }, { - "name": "maxBytes", + "name": "mode", "in": "query", "required": false, - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "description": "How `query` is read.", "schema": { - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "default": "regex", + "description": "How `query` is read.", + "type": "string", + "enum": ["exact", "regex"] + } + }, + { + "name": "maxResults", + "in": "query", + "required": false, + "description": "Maximum matching lines to return.", + "schema": { + "default": 50, + "description": "Maximum matching lines to return.", "type": "integer", "minimum": 1, - "maximum": 26214400 + "maximum": 200 } }, { - "name": "offset", + "name": "folderPaths", "in": "query", "required": false, - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", "schema": { - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "type": "string" } }, { - "name": "limit", + "name": "includeSubfolders", "in": "query", "required": false, - "description": "How many lines to return from `offset`. Absent reads to the end.", + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", "schema": { - "description": "How many lines to return from `offset`. Absent reads to the end.", - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" } } ], "responses": { "200": { - "description": "The extracted text and its extraction-quality flags.", + "description": "Matching lines and the index coverage they were drawn from.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3397,7 +3676,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileTextResponse" + "$ref": "#/components/schemas/V2FileSearchResultsResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "results": [ + { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "lineNumber": 4, + "text": "- based in NYC" + } + ], + "count": 1, + "truncated": false, + "complete": true, + "indexStatus": { + "readyFiles": 12, + "pendingFiles": 0, + "failedFiles": 0, + "skippedFiles": 0, + "partialFiles": 0 + } + } + } + } } } } @@ -3414,12 +3719,109 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/bulk-delete": { + "post": { + "operationId": "bulkDeleteFiles", + "summary": "Delete files", + "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore file to recover each file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], + "requestBody": { + "required": true, + "description": "Workspace and files selected for deletion.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteFilesRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Count of deleted files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteFilesResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "deletedItems": { + "files": 1 + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3432,12 +3834,12 @@ } } }, - "/api/v2/files/{fileId}/versions": { + "/api/v2/files/folders": { "get": { - "operationId": "listFileVersions", - "summary": "List file versions", - "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.list", + "operationId": "listFilesFolders", + "summary": "List folders", + "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore folder. Returns one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.folders.list", "x-oauth-scope": "api:read", "tags": ["Files"], "security": [ @@ -3449,83 +3851,116 @@ } ], "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace that owns the file.", + "description": "Workspace whose folders should be listed.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Workspace whose folders should be listed." } }, { - "name": "sortBy", + "name": "parentPath", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "schema": { - "default": "version", - "description": "Field used to sort the result.", - "type": "string", - "enum": ["version"] + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" } }, { - "name": "sortOrder", + "name": "search", "in": "query", "required": false, - "description": "Sort direction.", + "description": "Case-insensitive substring match against the folder name.", "schema": { - "default": "desc", + "description": "Case-insensitive substring match against the folder name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", "description": "Sort direction.", "type": "string", "enum": ["asc", "desc"] } }, { - "name": "limit", + "name": "scope", "in": "query", "required": false, - "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "schema": { - "default": 50, - "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 + "default": "active", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] } }, { - "name": "cursor", + "name": "recursive", "in": "query", "required": false, - "description": "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.", + "description": "Whether parentPath includes every descendant instead of direct children only.", "schema": { - "description": "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.", - "type": "string", - "minLength": 1 + "description": "Whether parentPath includes every descendant instead of direct children only.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "depth", + "in": "query", + "required": false, + "description": "Deepest level below parentPath to include when recursive is true.", + "schema": { + "description": "Deepest level below parentPath to include when recursive is true.", + "type": "integer", + "minimum": 1, + "maximum": 64 } } ], "responses": { "200": { - "description": "A page of file versions.", + "description": "Workspace file folders.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3540,35 +3975,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionListResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": [ - { - "fileId": "wf_V1StGXR8z5jdHi6BmyT91", - "version": 3, - "isCurrent": true, - "size": 1024, - "contentType": "text/csv", - "source": "api", - "authors": [ - { - "id": "usr_4kJ9mN2pQ7rS", - "email": "jane@example.com" - } - ], - "restoredFromVersion": null, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "supersededAt": null - } - ], - "nextCursor": null - } - } + "$ref": "#/components/schemas/FileFolderListResponse" } } } @@ -3595,66 +4002,45 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/{fileId}/versions/{version}": { - "get": { - "operationId": "getFileVersion", - "summary": "Get file version", - "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.read", - "x-oauth-scope": "api:read", + }, + "post": { + "operationId": "createFilesFolder", + "summary": "Create folder", + "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.create", + "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:read"] + "oauthBearer": ["api:write"] } ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "requestBody": { + "required": true, + "description": "Workspace and canonical path for a new folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileFolderRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "path": "/Engineering" + } + } + } } } - ], + }, "responses": { - "200": { - "description": "The file version.", + "201": { + "description": "The created folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3669,32 +4055,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "fileId": "wf_V1StGXR8z5jdHi6BmyT91", - "version": 3, - "isCurrent": true, - "size": 1024, - "contentType": "text/csv", - "source": "api", - "authors": [ - { - "id": "usr_4kJ9mN2pQ7rS", - "email": "jane@example.com" - } - ], - "restoredFromVersion": null, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "supersededAt": null - } - } - } + "$ref": "#/components/schemas/FileFolderResponse" } } } @@ -3711,6 +4072,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3722,11 +4092,11 @@ } } }, - "delete": { - "operationId": "deleteFileVersion", - "summary": "Delete file version", - "description": "Permanently remove an earlier version from history and schedule deletion of its stored content. Storage cleanup may retry in the background. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.versions.delete", + "patch": { + "operationId": "relocateFilesFolder", + "summary": "Rename or move folder", + "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.update", "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ @@ -3737,48 +4107,30 @@ "oauthBearer": ["api:write"] } ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "requestBody": { + "required": true, + "description": "Current and destination canonical paths for a folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateFileFolderRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "path": "/Engineering", + "destinationPath": "/Archive/Engineering" + } + } + } } } - ], + }, "responses": { "200": { - "description": "Deletion confirmation.", + "description": "The relocated folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3793,19 +4145,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionDeleteResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "fileId": "wf_V1StGXR8z5jdHi6BmyT91", - "version": 2, - "deleted": true - } - } - } + "$ref": "#/components/schemas/FileFolderResponse" } } } @@ -3825,6 +4165,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3835,102 +4181,74 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/{fileId}/versions/{version}/text": { - "get": { - "operationId": "readFileVersionText", - "summary": "Read file version text", - "description": "Extract the text of one version, exactly as Read file text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.read_content", - "x-oauth-scope": "api:read", + }, + "delete": { + "operationId": "deleteFilesFolder", + "summary": "Delete folder", + "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore folder to recover the archived contents.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.delete", + "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:read"] + "oauthBearer": ["api:write"] } ], "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace that owns the file.", + "description": "Workspace containing the folder.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "maxBytes", - "in": "query", - "required": false, - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "schema": { - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "type": "integer", - "minimum": 1, - "maximum": 26214400 + "description": "Workspace containing the folder." } }, { - "name": "offset", + "name": "path", "in": "query", - "required": false, - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "required": true, + "description": "Path of the folder to delete.", "schema": { - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "description": "Path of the folder to delete.", + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, { - "name": "limit", + "name": "recursive", "in": "query", "required": false, - "description": "How many lines to return from `offset`. Absent reads to the end.", + "description": "Delete the folder's contents and nested folders too. Required for a non-empty folder. Only the listed case-sensitive spellings are accepted.", "schema": { - "description": "How many lines to return from `offset`. Absent reads to the end.", - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "description": "Delete the folder's contents and nested folders too. Required for a non-empty folder. Only the listed case-sensitive spellings are accepted.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "default": "false", + "type": "string" } } ], "responses": { "200": { - "description": "The extracted text of the version and its extraction-quality flags.", + "description": "Folder deletion confirmation and deleted item counts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3945,7 +4263,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileVersionTextResponse" + "$ref": "#/components/schemas/DeleteFileFolderResponse" } } } @@ -3965,9 +4283,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3980,74 +4295,37 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}/content": { - "get": { - "operationId": "downloadFileVersion", - "summary": "Download file version", - "description": "Download the bytes of one version, served exactly as Download file serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length` and cannot determine the download size.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.download", - "x-oauth-scope": "api:read", + "/api/v2/files/folders/restore": { + "post": { + "operationId": "restoreFilesFolder", + "summary": "Restore folder", + "description": "Restore a folder and the files and subfolders archived with it. Use the path from List folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.restore", + "x-oauth-scope": "api:write", "tags": ["Files"], "security": [ { "apiKey": [] }, { - "oauthBearer": ["api:read"] + "oauthBearer": ["api:write"] } ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "requestBody": { + "required": true, + "description": "Workspace scope and archived folder path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileFolderRequest" + } } } - ], + }, "responses": { "200": { - "description": "The version bytes.", + "description": "The restored folder and what it brought back.", "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "Content-Length": { - "$ref": "#/components/headers/Content-Length" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -4059,10 +4337,9 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/FileFolderRestoreResponse" } } } @@ -4085,6 +4362,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -4096,4727 +4376,401 @@ } } } - }, - "/api/v2/files/{fileId}/versions/{version}/revert": { - "post": { - "operationId": "revertFileVersion", - "summary": "Revert file version", - "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MiB returns `413`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.versions.revert", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and an optional current-version precondition.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RevertFileVersionRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "expectedCurrentVersion": 4 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The file and its current version after the revert.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileVersionRevertResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "reverted": true, - "file": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - }, - "version": { - "fileId": "wf_V1StGXR8z5jdHi6BmyT91", - "version": 5, - "isCurrent": true, - "size": 1024, - "contentType": "text/csv", - "source": "revert", - "authors": [ - { - "id": "usr_4kJ9mN2pQ7rS", - "email": "jane@example.com" - } - ], - "restoredFromVersion": 3, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "supersededAt": null - }, - "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" - } - } - } - } - } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "oauth2", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow with PKCE (S256), sent as `Authorization: Bearer`. Each operation declares the scope it requires: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role.", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize", + "tokenUrl": "https://www.sim.ai/api/auth/oauth2/token", + "refreshUrl": "https://www.sim.ai/api/auth/oauth2/token", + "scopes": { + "api:read": "Read your workspaces, workflows, files, tables, and logs", + "api:write": "Read, create, change, run, and delete resources in your workspaces", + "offline_access": "Stay signed in without asking again" } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" } } } }, - "/api/v2/files/bulk-download": { - "get": { - "operationId": "bulkDownloadFiles", - "summary": "Bulk download files", - "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length` and cannot determine the download size.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.download", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace containing the selection.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the selection." - } - }, - { - "name": "fileIds", - "in": "query", - "required": false, - "description": "File identifiers to include, comma-separated. At most 100 entries.", - "schema": { - "description": "File identifiers to include, comma-separated. At most 100 entries.", - "type": "string" - } - }, - { - "name": "folderPaths", - "in": "query", - "required": false, - "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", - "schema": { - "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "The selected files as a zip archive.", - "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/unzip": { - "post": { - "operationId": "unzipFile", - "summary": "Unzip file", - "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.extract_archive", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope for the archive.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnzipFileBody" - } - } - } - }, - "responses": { - "200": { - "description": "Counts and destination folder for the unpacked archive.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileUnzipResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}": { - "get": { - "operationId": "downloadFile", - "summary": "Download file", - "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length` and cannot determine the download size.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.download", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - } - ], - "responses": { - "200": { - "description": "The file bytes.", - "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "Content-Length": { - "$ref": "#/components/headers/Content-Length" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } + "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." } }, - "delete": { - "operationId": "deleteFile", - "summary": "Delete file", - "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List files with `scope=archived` finds it; Restore file recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.delete", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - } - ], - "responses": { - "200": { - "description": "Deletion confirmation.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2DeleteFileResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "patch": { - "operationId": "renameFile", - "summary": "Rename file", - "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.rename", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and new file name.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RenameFileRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "renamed.csv" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The renamed file.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/restore": { - "post": { - "operationId": "restoreFile", - "summary": "Restore file", - "description": "Restore a soft-deleted file to its original folder, or the workspace root if that folder was archived. Name collisions add a `_restored` suffix; read `folderPath` and `name` from the response. Already-active files return unchanged, making retries safe. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.restore", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope for the archived file.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreFileRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The file as it exists after the restore.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2RestoreFileResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data_restored.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/metadata": { - "get": { - "operationId": "getFile", - "summary": "Get file metadata", - "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List file versions and is the precondition Revert file version accepts.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.read_metadata", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", - "schema": { - "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", - "type": "string", - "enum": ["active", "archived"] - } - } - ], - "responses": { - "200": { - "description": "File metadata and public-share state.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileMetadataResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null, - "share": null, - "currentVersion": 1, - "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" - } - } - }, - "example2": { - "summary": "Response example 2", - "value": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-16T09:12:00Z", - "deletedAt": null, - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] - }, - "currentVersion": 3, - "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg" - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/audit-logs": { - "get": { - "operationId": "listAuditLogs", - "summary": "List audit logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "audit_logs.list", - "x-oauth-scope": "api:read", - "tags": ["Audit Logs"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "action", - "in": "query", - "required": false, - "description": "Filter by exact action name.", - "schema": { - "description": "Filter by exact action name.", - "type": "string" - } - }, - { - "name": "resourceType", - "in": "query", - "required": false, - "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", - "schema": { - "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", - "type": "string" - } - }, - { - "name": "resourceId", - "in": "query", - "required": false, - "description": "Filter by exact resource identifier.", - "schema": { - "description": "Filter by exact resource identifier.", - "type": "string" - } - }, - { - "name": "workspaceId", - "in": "query", - "required": false, - "description": "Filter to actions in one workspace.", - "schema": { - "description": "Filter to actions in one workspace.", - "type": "string", - "minLength": 1, - "maxLength": 128 - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "Include audit entries created at or after this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", - "schema": { - "description": "Include audit entries created at or after this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Include audit entries created at or before this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", - "schema": { - "description": "Include audit entries created at or before this UTC ISO 8601 timestamp, such as `2026-08-06T00:00:00Z`. Dates without a time, timestamps with an offset instead of `Z`, and year `0000` are rejected.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - } - }, - { - "name": "includeDeparted", - "in": "query", - "required": false, - "description": "Include actions by users who have left the organization.", - "schema": { - "description": "Include actions by users who have left the organization.", - "type": "boolean" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "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.", - "schema": { - "description": "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.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "organizationId", - "in": "query", - "required": false, - "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", - "schema": { - "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "actorEmail", - "in": "query", - "required": false, - "description": "Filter by actor email address.", - "schema": { - "description": "Filter by actor email address.", - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" - } - } - ], - "responses": { - "200": { - "description": "A page of audit-log entries.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2AuditLogListResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": [ - { - "id": "audit_2c3d4e5f6g", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "actorName": "Jane Smith", - "actorEmail": "jane@example.com", - "action": "file.uploaded", - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "resourceName": "data.csv", - "description": "Uploaded file \"data.csv\" via API", - "metadata": { - "fileSize": 1024, - "fileType": "text/csv" - }, - "createdAt": "2026-01-15T10:30:00Z" - } - ], - "nextCursor": null - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/audit-logs/{auditLogId}": { - "get": { - "operationId": "getAuditLog", - "summary": "Get audit log", - "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "audit_logs.read_detail", - "x-oauth-scope": "api:read", - "tags": ["Audit Logs"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "auditLogId", - "in": "path", - "required": true, - "description": "Audit-log entry identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Audit-log entry identifier." - } - }, - { - "name": "organizationId", - "in": "query", - "required": false, - "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", - "schema": { - "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", - "type": "string", - "minLength": 1 - } - } - ], - "responses": { - "200": { - "description": "The requested audit-log entry.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2AuditLogResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "audit_2c3d4e5f6g", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "actorName": "Jane Smith", - "actorEmail": "jane@example.com", - "action": "file.uploaded", - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "resourceName": "data.csv", - "description": "Uploaded file \"data.csv\" via API", - "metadata": { - "fileSize": 1024, - "fileType": "text/csv" - }, - "createdAt": "2026-01-15T10:30:00Z" - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/move": { - "post": { - "operationId": "moveFileItems", - "summary": "Move files", - "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.move", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "requestBody": { - "required": true, - "description": "Files and destination selected for a bulk move.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MoveFileItemsRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], - "targetFolderPath": "/Archive" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Count of moved files.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2MoveFileItemsResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "movedItems": { - "files": 1 - } - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/share": { - "get": { - "operationId": "getFileShare", - "summary": "Get file share", - "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.share.read", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - } - ], - "responses": { - "200": { - "description": "Current nullable file-share state.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] - } - } - }, - "example2": { - "summary": "Response example 2", - "value": { - "data": null - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "patch": { - "operationId": "upsertFileShare", - "summary": "Enable or disable file share", - "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.share.update", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Desired public-share state and access policy.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertFileShareRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, - "authType": "public" - } - }, - "example2": { - "summary": "Request example 2", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": false - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The updated file share.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/content": { - "patch": { - "operationId": "editFileContent", - "summary": "Edit file content", - "description": "Edit part of a UTF-8 file; use Replace file content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.update_content", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and the change to apply.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EditFileContentRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "edit": { - "mode": "search_replace", - "search": "- based in NYC", - "content": "- based in SF" - } - } - }, - "example2": { - "summary": "Request example 2", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "edit": { - "mode": "insert_after", - "anchor": "## Preferences", - "content": "- prefers async updates" - } - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The edited file and its new line count.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2EditedFileResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "file": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - }, - "lineCount": 5 - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "put": { - "operationId": "updateFileContent", - "summary": "Replace file content", - "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.update_content", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and complete replacement content.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFileContentRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "content": "replacement text" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The updated file.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2WrittenFileResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/example/data.csv", - "folderPath": "/Engineering", - "uploadedByEmail": "jane@example.com", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null, - "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/search": { - "get": { - "operationId": "searchFileContent", - "summary": "Search file content", - "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.search_content", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace to search.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace to search." - } - }, - { - "name": "query", - "in": "query", - "required": true, - "description": "Regular expression, or exact text when `mode` is `exact`.", - "schema": { - "type": "string", - "description": "Regular expression, or exact text when `mode` is `exact`.", - "minLength": 3, - "maxLength": 512 - } - }, - { - "name": "mode", - "in": "query", - "required": false, - "description": "How `query` is read.", - "schema": { - "default": "regex", - "description": "How `query` is read.", - "type": "string", - "enum": ["exact", "regex"] - } - }, - { - "name": "maxResults", - "in": "query", - "required": false, - "description": "Maximum matching lines to return.", - "schema": { - "default": 50, - "description": "Maximum matching lines to return.", - "type": "integer", - "minimum": 1, - "maximum": 200 - } - }, - { - "name": "folderPaths", - "in": "query", - "required": false, - "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", - "schema": { - "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", - "type": "string" - } - }, - { - "name": "includeSubfolders", - "in": "query", - "required": false, - "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "schema": { - "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Matching lines and the index coverage they were drawn from.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileSearchResultsResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "results": [ - { - "fileId": "wf_V1StGXR8z5jdHi6BmyT91", - "lineNumber": 4, - "text": "- based in NYC" - } - ], - "count": 1, - "truncated": false, - "complete": true, - "indexStatus": { - "readyFiles": 12, - "pendingFiles": 0, - "failedFiles": 0, - "skippedFiles": 0, - "partialFiles": 0 - } - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/bulk-delete": { - "post": { - "operationId": "bulkDeleteFiles", - "summary": "Delete files", - "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore file to recover each file.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.delete", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "requestBody": { - "required": true, - "description": "Workspace and files selected for deletion.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkDeleteFilesRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"] - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Count of deleted files.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkDeleteFilesResponse" - }, - "examples": { - "example1": { - "summary": "Response example 1", - "value": { - "data": { - "deletedItems": { - "files": 1 - } - } - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/folders": { - "get": { - "operationId": "listFilesFolders", - "summary": "List folders", - "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore folder. Returns one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.folders.list", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:read"] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace whose folders should be listed.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." - } - }, - { - "name": "parentPath", - "in": "query", - "required": false, - "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", - "schema": { - "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder name.", - "schema": { - "description": "Case-insensitive substring match against the folder name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Do not rely on case-insensitive ordering when sorting by `name`.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", - "schema": { - "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", - "type": "string", - "enum": ["active", "archived"] - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Whether parentPath includes every descendant instead of direct children only.", - "schema": { - "description": "Whether parentPath includes every descendant instead of direct children only.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" - } - }, - { - "name": "depth", - "in": "query", - "required": false, - "description": "Deepest level below parentPath to include when recursive is true.", - "schema": { - "description": "Deepest level below parentPath to include when recursive is true.", - "type": "integer", - "minimum": 1, - "maximum": 64 - } - } - ], - "responses": { - "200": { - "description": "Workspace file folders.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileFolderListResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "post": { - "operationId": "createFilesFolder", - "summary": "Create folder", - "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.create", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "requestBody": { - "required": true, - "description": "Workspace and canonical path for a new folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFileFolderRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "path": "/Engineering" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "The created folder.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileFolderResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "patch": { - "operationId": "relocateFilesFolder", - "summary": "Rename or move folder", - "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.update", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "requestBody": { - "required": true, - "description": "Current and destination canonical paths for a folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RelocateFileFolderRequest" - }, - "examples": { - "example1": { - "summary": "Request example 1", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "path": "/Engineering", - "destinationPath": "/Archive/Engineering" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The relocated folder.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileFolderResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "delete": { - "operationId": "deleteFilesFolder", - "summary": "Delete folder", - "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore folder to recover the archived contents.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.delete", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace containing the folder.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the folder." - } - }, - { - "name": "path", - "in": "query", - "required": true, - "description": "Path of the folder to delete.", - "schema": { - "description": "Path of the folder to delete.", - "$ref": "#/components/schemas/NonRootFolderPathInput" - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Delete the folder's contents and nested folders too. Required for a non-empty folder. Only the listed case-sensitive spellings are accepted.", - "schema": { - "description": "Delete the folder's contents and nested folders too. Required for a non-empty folder. Only the listed case-sensitive spellings are accepted.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "default": "false", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder deletion confirmation and deleted item counts.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteFileFolderResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/folders/restore": { - "post": { - "operationId": "restoreFilesFolder", - "summary": "Restore folder", - "description": "Restore a folder and the files and subfolders archived with it. Use the path from List folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.restore", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "security": [ - { - "apiKey": [] - }, - { - "oauthBearer": ["api:write"] - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and archived folder path.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreFileFolderRequest" - } - } - } - }, - "responses": { - "200": { - "description": "The restored folder and what it brought back.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileFolderRestoreResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - }, - "oauthBearer": { - "type": "oauth2", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow with PKCE (S256), sent as `Authorization: Bearer`. Each operation declares the scope it requires: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role.", - "flows": { - "authorizationCode": { - "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize", - "tokenUrl": "https://www.sim.ai/api/auth/oauth2/token", - "refreshUrl": "https://www.sim.ai/api/auth/oauth2/token", - "scopes": { - "api:read": "Read your workspaces, workflows, files, tables, and logs", - "api:write": "Read, create, change, run, and delete resources in your workspaces", - "offline_access": "Stay signed in without asking again" - } - } - } - } - }, - "headers": { - "Content-Type": { - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", - "schema": { - "type": "string", - "title": "Content type", - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." - } - }, - "Content-Disposition": { - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", - "schema": { - "type": "string", - "title": "Content disposition", - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." - } - }, - "Content-Length": { - "description": "File size in bytes.", - "schema": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)$", - "title": "Content length", - "description": "File size in bytes." - } - }, - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } - }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } - } - } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" - } - } - } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } - } - } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } - } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "The request conflicts with the current resource state" - } - } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } - } - } - } - }, - "Locked": { - "description": "The resource is temporarily locked or unavailable; retry the request.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "The file or its search index is temporarily locked; retry the request" - } - } - } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" - } - } - } - } - } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } - } - } - } - }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. When present, `Retry-After` gives the minimum seconds to wait; add jitter before retrying. Check operation-specific guidance before repeating a change after an uncertain response.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } - } - } - } - } - }, - "schemas": { - "V2ActionableForbiddenDetails": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" - } - }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "SSO_DISABLED", - "SSO_DOMAIN_NOT_VERIFIED", - "SSO_PROVIDER_LIMIT_REACHED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "ACCESS_REQUESTS_DISABLED", - "ACCESS_REQUEST_ORGANIZATION_REQUIRED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" - ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." - }, - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] - } - }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." - } - }, - "required": ["error"], - "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." - } - } - ] - }, - "V2ProjectFile": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique file identifier.", - "examples": ["wf_V1StGXR8z5jdHi6BmyT91"] - }, - "name": { - "type": "string", - "description": "Original file name.", - "examples": ["data.csv"] - }, - "size": { - "type": "number", - "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", - "examples": [1024] - }, - "type": { - "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", - "examples": ["text/csv"] - }, - "key": { - "type": "string", - "description": "Storage key for the file.", - "examples": ["workspace/example/data.csv"] - }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path. `/` is the Project root.", - "maxLength": 4096 - }, - "uploadedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the file was uploaded.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp of the last content or metadata write.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "deletedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", - "format": "date-time", - "examples": ["2026-01-16T09:00:00Z"] - }, - "owner": { - "type": "object", - "properties": { - "entityType": { - "type": "string", - "const": "project", - "description": "The file owner is a Project." - }, - "entityId": { - "type": "string", - "minLength": 1, - "description": "Identifier of the owning Project." - } - }, - "required": ["entityType", "entityId"], - "additionalProperties": false, - "description": "Canonical owner of the shared file." - }, - "uploadedBy": { - "type": "string", - "minLength": 1, - "description": "Creator or successor identifier." - }, - "revision": { - "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", - "type": "string" - } - }, - "required": [ - "id", - "name", - "size", - "type", - "key", - "folderPath", - "uploadedAt", - "updatedAt", - "deletedAt", - "owner", - "uploadedBy" - ], - "additionalProperties": false, - "title": "Project file" - }, - "V2ProjectFileMetadataResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2ProjectFile" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Project file metadata response", - "description": "Metadata for one authorized Project file." - }, - "RenameProjectFileRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "New file name, including its extension." - } - }, - "required": ["name"], - "additionalProperties": false, - "title": "Rename Project File request", - "description": "The new file name." - }, - "V2MoveProjectFileItemsResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "movedFiles": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of files moved." - }, - "movedFolders": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of folders moved." - }, - "movedFileIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Identifiers of moved files." - }, - "movedFolderIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Identifiers of moved folders." - } - }, - "required": ["movedFiles", "movedFolders", "movedFileIds", "movedFolderIds"], - "additionalProperties": false, - "description": "Response data." - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Project file operation response", - "description": "Counts and identifiers of moved items." - }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" - }, - "MoveProjectFileItemsRequest": { - "type": "object", - "properties": { - "fileIds": { - "default": [], - "description": "Identifiers of the files to move.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string" - } - }, - "folderIds": { - "default": [], - "description": "Identifiers of folders to move with their contents.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string" - } - }, - "targetFolderPath": { - "description": "Existing destination folder path within the Project. Omit to move items to the Project root.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "additionalProperties": false, - "title": "Move Project File Items request", - "description": "Selected files and folders and their destination." - }, - "V2ArchiveProjectFileItemsResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "deletedItems": { - "type": "object", - "properties": { - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of affected files." - }, - "folders": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of affected folders." - } - }, - "required": ["files", "folders"], - "additionalProperties": false, - "description": "Counts of affected file items." - }, - "affectedIds": { - "type": "object", - "properties": { - "fileIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Identifiers of affected files." - }, - "folderIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Identifiers of affected folders." - } - }, - "required": ["fileIds", "folderIds"], - "additionalProperties": false, - "description": "Identifiers of affected file items." - } - }, - "required": ["deletedItems", "affectedIds"], - "additionalProperties": false, - "description": "Response data." - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Project file operation response", - "description": "Counts and identifiers of archived items." - }, - "ArchiveProjectFileItemsRequest": { - "type": "object", - "properties": { - "fileIds": { - "default": [], - "description": "Identifiers of the files to archive.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string" - } - }, - "folderIds": { - "default": [], - "description": "Identifiers of folders to archive recursively, including their files and descendants.", - "maxItems": 1000, - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "title": "Archive Project File Items request", - "description": "Files and folders to archive." - }, - "RestoreProjectFileRequest": { - "type": "object", - "properties": {}, - "additionalProperties": false, - "title": "Restore Project file request", - "description": "An empty object; the file is identified by its path parameters." - }, - "CreateProjectFileRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "File name, including its extension. Path separators and dot segments are rejected." - }, - "contentType": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "MIME type. When omitted, it is inferred from the file extension." - }, - "content": { - "default": "", - "description": "Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads.", - "type": "string", - "maxLength": 70000000 - }, - "encoding": { - "default": "utf-8", - "description": "Encoding of the content field.", - "type": "string", - "enum": ["utf-8", "base64"] - }, - "folderPath": { - "description": "Canonical containing-folder path. Omit for the Project root.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["name"], - "additionalProperties": false, - "title": "Create Project file request", - "description": "Name, content, encoding, and containing folder within the Project." - }, - "UpdateProjectFileContentRequest": { - "type": "object", - "properties": { - "content": { - "type": "string", - "maxLength": 70000000, - "description": "Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content." - }, - "encoding": { - "default": "utf-8", - "description": "Encoding of the content field.", - "type": "string", - "enum": ["utf-8", "base64"] - }, - "expectedRevision": { - "description": "Revision from Get file metadata or an earlier write; the request is refused with `409` when the content moved on.", - "type": "string", - "minLength": 1 - } - }, - "required": ["content"], - "additionalProperties": false, - "title": "Project content replacement", - "description": "Complete replacement bytes and an optional optimistic concurrency revision." - }, - "V2ProjectFileListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2ProjectFile" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there are no more pages. Do not construct a cursor yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Project file list response", - "description": "A page of files owned by the requested Project." - }, - "ProjectFileVersion": { - "title": "Project file version", - "description": "One recorded version of a shared Project file.", - "$ref": "#/components/schemas/V2FileVersion" - }, - "V2FileVersion": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "File this version belongs to." - }, - "version": { - "type": "integer", - "minimum": 1, - "maximum": 2147483647, - "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", - "examples": [3] - }, - "isCurrent": { - "type": "boolean", - "description": "Whether this version holds the current content of the file." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Size in bytes of the stored content of this version." - }, - "contentType": { - "type": "string", - "description": "MIME type of the stored content of this version." - }, - "source": { - "type": "string", - "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], - "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." - }, - "authors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "User identifier." - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - }, - { - "type": "null" - } - ], - "description": "Current email address of the user, or null when the account no longer exists." - } - }, - "required": ["id", "email"], - "additionalProperties": false - }, - "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." - }, - "restoredFromVersion": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 2147483647 - }, - { - "type": "null" - } - ], - "description": "For a `revert` version, the version whose content it restored; otherwise null." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this content became current.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", - "format": "date-time", - "examples": ["2026-01-15T10:38:00Z"] - }, - "supersededAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", - "format": "date-time", - "examples": ["2026-01-16T09:00:00Z"] - } - }, - "required": [ - "fileId", - "version", - "isCurrent", - "size", - "contentType", - "source", - "authors", - "restoredFromVersion", - "createdAt", - "updatedAt", - "supersededAt" - ], - "additionalProperties": false, - "title": "File version", - "description": "One recorded version of the content of a workspace file." - }, - "V2ProjectFileVersionListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProjectFileVersion" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there are no more pages. Do not construct a cursor yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List Project File Versions response", - "description": "A cursor-paginated page of Project file versions." - }, - "V2ProjectFileVersionResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "File this version belongs to." - }, - "version": { - "type": "integer", - "minimum": 1, - "maximum": 2147483647, - "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", - "examples": [3] - }, - "isCurrent": { - "type": "boolean", - "description": "Whether this version holds the current content of the file." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Size in bytes of the stored content of this version." - }, - "contentType": { - "type": "string", - "description": "MIME type of the stored content of this version." - }, - "source": { - "type": "string", - "enum": [ - "upload", - "user", - "api", - "copilot", - "workflow", - "collab", - "revert", - "unknown" - ], - "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." - }, - "authors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "User identifier." - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - }, - { - "type": "null" - } - ], - "description": "Current email address of the user, or null when the account no longer exists." - } - }, - "required": ["id", "email"], - "additionalProperties": false - }, - "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." - }, - "restoredFromVersion": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 2147483647 - }, - { - "type": "null" - } - ], - "description": "For a `revert` version, the version whose content it restored; otherwise null." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this content became current.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", - "format": "date-time", - "examples": ["2026-01-15T10:38:00Z"] - }, - "supersededAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", - "format": "date-time", - "examples": ["2026-01-16T09:00:00Z"] - } - }, - "required": [ - "fileId", - "version", - "isCurrent", - "size", - "contentType", - "source", - "authors", - "restoredFromVersion", - "createdAt", - "updatedAt", - "supersededAt" - ], - "additionalProperties": false, - "description": "Response data.", - "$ref": "#/components/schemas/ProjectFileVersion" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get Project File Version response", - "description": "Metadata for the selected version." - }, - "V2ProjectFileVersionRevertResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "reverted": { - "type": "boolean", - "description": "False if the selected version is already current." - }, - "file": { - "$ref": "#/components/schemas/V2ProjectFile" - }, - "version": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "File this version belongs to." - }, - "version": { - "type": "integer", - "minimum": 1, - "maximum": 2147483647, - "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", - "examples": [3] - }, - "isCurrent": { - "type": "boolean", - "description": "Whether this version holds the current content of the file." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Size in bytes of the stored content of this version." - }, - "contentType": { - "type": "string", - "description": "MIME type of the stored content of this version." - }, - "source": { - "type": "string", - "enum": [ - "upload", - "user", - "api", - "copilot", - "workflow", - "collab", - "revert", - "unknown" - ], - "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." - }, - "authors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "User identifier." - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - }, - { - "type": "null" - } - ], - "description": "Current email address of the user, or null when the account no longer exists." - } - }, - "required": ["id", "email"], - "additionalProperties": false - }, - "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." - }, - "restoredFromVersion": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 2147483647 - }, - { - "type": "null" - } - ], - "description": "For a `revert` version, the version whose content it restored; otherwise null." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this content became current.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", - "format": "date-time", - "examples": ["2026-01-15T10:38:00Z"] - }, - "supersededAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", - "format": "date-time", - "examples": ["2026-01-16T09:00:00Z"] - } - }, - "required": [ - "fileId", - "version", - "isCurrent", - "size", - "contentType", - "source", - "authors", - "restoredFromVersion", - "createdAt", - "updatedAt", - "supersededAt" - ], - "additionalProperties": false, - "description": "The current version after the revert.", - "$ref": "#/components/schemas/ProjectFileVersion" - }, - "revision": { - "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", - "type": "string" - } - }, - "required": ["reverted", "file", "version"], - "additionalProperties": false, - "description": "Response data." - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Revert Project File Version response", - "description": "The file and current version after the revert." + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } }, - "RevertProjectFileVersionRequest": { - "type": "object", - "properties": { - "expectedCurrentVersion": { - "description": "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.", - "type": "integer", - "minimum": 1, - "maximum": 2147483647 - }, - "expectedRevision": { - "description": "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.", - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false, - "title": "Revert Project file version request", - "description": "Optional revision or current-version preconditions." + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } }, - "V2ProjectFileVersionDeleteResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "File whose version was deleted." - }, - "version": { - "type": "integer", - "minimum": 1, - "maximum": 2147483647, - "description": "Version number that was deleted." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "The version is no longer available; stored-object cleanup is retried asynchronously when needed." - } - }, - "required": ["fileId", "version", "deleted"], - "additionalProperties": false, - "description": "Response data." - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete Project File Version response", - "description": "Deletion acknowledgement for the superseded version." + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } }, - "V2ProjectFileFolderListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Stable folder identifier." - }, - "userId": { - "type": "string", - "minLength": 1, - "description": "Creator or successor identifier." - }, - "name": { - "type": "string", - "description": "Folder name." - }, - "parentId": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ], - "description": "Parent folder identifier, or null at the root." - }, - "path": { - "type": "string", - "description": "Display path with slash characters in folder names escaped." - }, - "sortOrder": { - "type": "number", - "description": "Position within its parent folder." - }, - "deletedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "Archive time, or null for an active folder." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the folder was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the folder was last changed." - }, - "owner": { - "type": "object", - "properties": { - "entityType": { - "type": "string", - "const": "project", - "description": "The folder is owned by a Project." - }, - "entityId": { - "type": "string", - "minLength": 1, - "description": "Identifier of the owning Project." - } - }, - "required": ["entityType", "entityId"], - "additionalProperties": false, - "description": "Canonical Project owner of the folder." - } - }, - "required": [ - "id", - "userId", - "name", - "parentId", - "path", - "sortOrder", - "deletedAt", - "createdAt", - "updatedAt", - "owner" - ], - "additionalProperties": false - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Always `null`. This list has no `cursor` or `limit` parameters; see the operation for inventory limits." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Project folder list response", - "description": "Folders in their existing manual order; nextCursor is always null." + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } }, - "V2ProjectFileFolderResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Stable folder identifier." - }, - "userId": { - "type": "string", - "minLength": 1, - "description": "Creator or successor identifier." - }, - "name": { - "type": "string", - "description": "Folder name." - }, - "parentId": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ], - "description": "Parent folder identifier, or null at the root." - }, - "path": { - "type": "string", - "description": "Display path with slash characters in folder names escaped." - }, - "sortOrder": { - "type": "number", - "description": "Position within its parent folder." - }, - "deletedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "Archive time, or null for an active folder." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the folder was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the folder was last changed." - }, - "owner": { - "type": "object", - "properties": { - "entityType": { - "type": "string", - "const": "project", - "description": "The folder is owned by a Project." - }, - "entityId": { - "type": "string", - "minLength": 1, - "description": "Identifier of the owning Project." - } - }, - "required": ["entityType", "entityId"], - "additionalProperties": false, - "description": "Canonical Project owner of the folder." - } - }, - "required": [ - "id", - "userId", - "name", - "parentId", - "path", - "sortOrder", - "deletedAt", - "createdAt", - "updatedAt", - "owner" - ], - "additionalProperties": false, - "description": "Response data." - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Project folder response", - "description": "One folder with canonical ownership and creator attribution." + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } }, - "CreateProjectFileFolderRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Name for the new folder." - }, - "parentId": { - "description": "Parent folder identifier; omit or use null for the root.", - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ] - } - }, - "required": ["name"], - "additionalProperties": false, - "title": "Create Project folder request", - "description": "The folder name and optional parent identifier." + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } }, - "UpdateProjectFileFolderRequest": { - "type": "object", - "properties": { - "name": { - "description": "New folder name; omit to leave unchanged.", - "type": "string", - "minLength": 1 - }, - "parentId": { - "description": "New parent folder identifier; null moves to the root, omission leaves the parent unchanged.", - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ] - }, - "sortOrder": { - "description": "New manual position; omit to leave unchanged.", - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } } - }, - "additionalProperties": false, - "title": "Update Project folder request", - "description": "The fields to change on the folder." + } }, - "V2RestoreProjectFileFolderResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Stable folder identifier." - }, - "userId": { - "type": "string", - "minLength": 1, - "description": "Creator or successor identifier." - }, - "name": { - "type": "string", - "description": "Folder name." - }, - "parentId": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ], - "description": "Parent folder identifier, or null at the root." - }, - "path": { - "type": "string", - "description": "Display path with slash characters in folder names escaped." - }, - "sortOrder": { - "type": "number", - "description": "Position within its parent folder." - }, - "deletedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "Archive time, or null for an active folder." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the folder was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Time the folder was last changed." - }, - "owner": { - "type": "object", - "properties": { - "entityType": { - "type": "string", - "const": "project", - "description": "The folder is owned by a Project." - }, - "entityId": { - "type": "string", - "minLength": 1, - "description": "Identifier of the owning Project." - } - }, - "required": ["entityType", "entityId"], - "additionalProperties": false, - "description": "Canonical Project owner of the folder." - } - }, - "required": [ - "id", - "userId", - "name", - "parentId", - "path", - "sortOrder", - "deletedAt", - "createdAt", - "updatedAt", - "owner" - ], - "additionalProperties": false, - "description": "Restored Project folder." - }, - "restoredItems": { - "type": "object", - "properties": { - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of affected files." - }, - "folders": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of affected folders." - } - }, - "required": ["files", "folders"], - "additionalProperties": false, - "description": "Counts of affected file items." - } + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" }, - "required": ["folder", "restoredItems"], - "additionalProperties": false, - "description": "Response data." + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + } + } } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Restore Project folder response", - "description": "The restored folder and affected item counts." - }, - "RestoreProjectFileFolderRequest": { - "type": "object", - "properties": {}, - "additionalProperties": false, - "title": "Restore Project folder request", - "description": "An empty object; the folder is identified in the path." + } }, - "V2ProjectFileUpload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Upload session identifier." - }, - "status": { - "type": "string", - "enum": [ - "uploading", - "completing", - "finalizing", - "completed", - "failed", - "aborting", - "aborted", - "expired" - ], - "description": "Current upload session status." - }, - "name": { - "type": "string", - "description": "File name supplied when the session was created." - }, - "contentType": { - "type": "string", - "description": "MIME type supplied when the session was created." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Expected file size in bytes." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 time when the upload session expires." - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Failure message, or null when no failure has occurred." - }, - "file": { - "anyOf": [ - { - "$ref": "#/components/schemas/V2ProjectFile" - }, - { - "type": "null" + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } } - ], - "description": "Registered Project file after finalization, or null before registration or after archival." + } } - }, - "required": ["id", "status", "name", "contentType", "size", "expiresAt", "error", "file"], - "additionalProperties": false, - "title": "Project file upload session" + } }, - "V2PutUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "put", - "description": "Upload strategy discriminator." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" }, - "description": "Headers that must be included with the upload request." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } } - }, - "required": ["method", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Direct upload transfer", - "description": "Instructions for uploading bytes to one signed URL." + } }, - "V2MultipartUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "multipart", - "description": "Upload strategy discriminator." - }, - "partSize": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Required size of each non-final part in bytes." - }, - "partCount": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 640, - "description": "Total number of upload parts." + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "The request conflicts with the current resource state" + } + } } - }, - "required": ["method", "partSize", "partCount"], - "additionalProperties": false, - "title": "Multipart upload transfer", - "description": "Instructions for splitting bytes into a multipart upload." + } }, - "V2CreateProjectFileUploadData": { - "type": "object", - "properties": { - "session": { - "description": "New Project upload session.", - "$ref": "#/components/schemas/V2ProjectFileUpload" - }, - "uploadToken": { - "type": "string", - "minLength": 1, - "description": "Signed control token required by later upload-session requests." - }, - "transfer": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2PutUploadTransfer" - }, - { - "$ref": "#/components/schemas/V2MultipartUploadTransfer" - } - ], - "description": "Instructions for transferring the file bytes." + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } } - }, - "required": ["session", "uploadToken", "transfer"], - "additionalProperties": false, - "title": "Create Project file upload data" + } }, - "V2CreateProjectFileUploadResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CreateProjectFileUploadData" + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create Project File Upload response", - "description": "The authorized upload state or transfer instructions." + } }, - "ProjectUploadCreateRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "File name, including its extension." - }, - "contentType": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "MIME type of the uploaded file." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 5368709120, - "description": "Exact file size in bytes." - }, - "folderPath": { - "description": "Canonical destination folder path. Specify either folderId or folderPath, not both.", - "$ref": "#/components/schemas/FolderPathInput" - }, - "folderId": { - "description": "Destination folder identifier; omit or use null for the Project root.", - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - { - "type": "null" + "Locked": { + "description": "The resource is temporarily locked or unavailable; retry the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "The file or its search index is temporarily locked; retry the request" } - ] + } } - }, - "required": ["name", "contentType", "size"], - "additionalProperties": false, - "title": "Create Project File Upload request", - "description": "The parameters for this upload operation." + } }, - "V2ProjectFileUploadResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2ProjectFileUpload" + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" } }, - "required": ["data"], - "additionalProperties": false, - "title": "Project file upload response", - "description": "The authorized upload state or transfer instructions." - }, - "V2UploadPartUrl": { - "type": "object", - "properties": { - "partNumber": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "Multipart part number." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" }, - "description": "Headers that must be included with the part upload." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 expiration time for the signed URL." + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } } - }, - "required": ["partNumber", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Upload part URL", - "description": "A signed URL and required headers for one multipart upload part." + } }, - "V2PartUrlsData": { - "type": "object", - "properties": { - "parts": { - "maxItems": 100, - "type": "array", - "items": { - "$ref": "#/components/schemas/V2UploadPartUrl" + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" }, - "description": "Signed URLs for requested parts." + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } } - }, - "required": ["parts"], - "additionalProperties": false, - "title": "Upload part URLs", - "description": "Signed transfer URLs for the requested multipart upload parts." + } }, - "V2ProjectFileUploadPartUrlsResponse": { + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. When present, `Retry-After` gives the minimum seconds to wait; add jitter before retrying. Check operation-specific guidance before repeating a change after an uncertain response.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2ActionableForbiddenDetails": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2PartUrlsData" + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" } }, - "required": ["data"], - "additionalProperties": false, - "title": "Get Project File Upload Part URLs response", - "description": "The authorized upload state or transfer instructions." + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." }, - "ProjectFileUploadPartUrlsRequest": { + "V2Error": { "type": "object", "properties": { - "partNumbers": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } }, - "description": "Multipart part numbers for which signed URLs should be created." + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." } }, - "required": ["partNumbers"], + "required": ["error"], "additionalProperties": false, - "title": "Get Project File Upload Part URLs request", - "description": "The parameters for this upload operation." + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" }, "V2File": { "type": "object", @@ -9191,6 +5145,67 @@ "title": "File upload session", "description": "Current state of a resumable workspace-file upload session." }, + "V2PutUploadTransfer": { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "put", + "description": "Upload strategy discriminator." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the upload request." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." + } + }, + "required": ["method", "url", "headers", "expiresAt"], + "additionalProperties": false, + "title": "Direct upload transfer", + "description": "Instructions for uploading bytes to one signed URL." + }, + "V2MultipartUploadTransfer": { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "multipart", + "description": "Upload strategy discriminator." + }, + "partSize": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Required size of each non-final part in bytes." + }, + "partCount": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 640, + "description": "Total number of upload parts." + } + }, + "required": ["method", "partSize", "partCount"], + "additionalProperties": false, + "title": "Multipart upload transfer", + "description": "Instructions for splitting bytes into a multipart upload." + }, "V2CreateFileUploadData": { "type": "object", "properties": { @@ -9283,6 +5298,59 @@ "title": "File upload response", "description": "Current upload-session state." }, + "V2UploadPartUrl": { + "type": "object", + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Multipart part number." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the part upload." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 expiration time for the signed URL." + } + }, + "required": ["partNumber", "url", "headers", "expiresAt"], + "additionalProperties": false, + "title": "Upload part URL", + "description": "A signed URL and required headers for one multipart upload part." + }, + "V2PartUrlsData": { + "type": "object", + "properties": { + "parts": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2UploadPartUrl" + }, + "description": "Signed URLs for requested parts." + } + }, + "required": ["parts"], + "additionalProperties": false, + "title": "Upload part URLs", + "description": "Signed transfer URLs for the requested multipart upload parts." + }, "CreateFileUploadPartUrlsResponse": { "type": "object", "properties": { @@ -9438,6 +5506,123 @@ "title": "File text response", "description": "Text extracted from a workspace file." }, + "V2FileVersion": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "title": "File version", + "description": "One recorded version of the content of a workspace file." + }, "V2FileVersionListResponse": { "type": "object", "properties": { diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index ca3fd2302e7..98d56f94aae 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -36,10 +36,6 @@ import { v2UpdateFileContentContract, v2UpsertFileShareContract, } from '@/lib/api/contracts/v2/files' -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' import { documentedSchema, type ErrorResponseId, @@ -158,10 +154,6 @@ function auditOperation( } const declaredRoutes = [ - ...projectFileOpenApiRoutes, - ...projectFileVersionOpenApiRoutes, - ...projectFileFolderOpenApiRoutes, - ...projectFileUploadOpenApiRoutes, defineOpenApiRoute( v2ListFilesContract, filesOperation({ 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/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index 8094322a259..64684a33b44 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -43,7 +43,6 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/output.mdx', 'cli/permission-groups.mdx', 'cli/profiles.mdx', - 'cli/projects.mdx', 'cli/reference.mdx', 'cli/sandboxes.mdx', 'cli/scripting.mdx', 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/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) From ecf33385ca2d147a2ea687bac3f3c3b0a4c56617 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 15:10:05 -0700 Subject: [PATCH 06/10] fix(files): address review admission and collaboration gaps --- apps/docs/openapi-v2-files-audit.json | 4 +- .../handlers/file-doc-editor.integration.ts | 35 +++- .../src/handlers/file-doc-store.test.ts | 56 +++++- apps/realtime/src/handlers/file-doc-store.ts | 8 +- .../[id]/files/[fileId]/content/route.ts | 9 +- .../versions/[version]/content/route.ts | 6 +- .../files/[fileId]/content/route.ts | 19 ++- .../versions/[version]/content/route.ts | 4 +- .../collaboration/file-doc-provider.test.ts | 70 ++++++++ .../collaboration/file-doc-provider.ts | 13 +- .../use-file-doc-collaboration.ts | 5 + .../editor-lifecycle.test.tsx | 159 +++++++++++++++++- .../rich-markdown-editor.tsx | 136 +++++++++++---- apps/sim/hooks/queries/workspace-files.ts | 1 + .../api/contracts/v2/openapi/files-audit.ts | 4 +- .../v2/openapi/project-file-folders.ts | 8 +- .../v2/openapi/project-file-uploads.ts | 10 +- .../lib/api/mcp/generated/v2-operations.ts | 20 +-- .../core/application/content-delivery.test.ts | 12 ++ .../lib/core/application/content-delivery.ts | 6 +- .../authorization.integration.ts | 67 ++++++++ .../__integration__/browser.integration.ts | 6 + .../__integration__/content.integration.ts | 156 +++++++++++++++++ .../__integration__/uploads.integration.ts | 25 ++- .../projects/files/api/content-presenter.ts | 10 +- .../projects/files/api/version-presenters.ts | 10 +- .../lib/projects/files/application/content.ts | 87 +++++++++- .../lib/projects/files/application/index.ts | 8 +- .../projects/files/application/operations.ts | 4 +- .../lib/projects/files/application/read.ts | 11 +- .../lib/projects/files/application/uploads.ts | 3 +- .../projects/files/application/versions.ts | 74 +++++++- apps/sim/lib/realtime/notify.test.ts | 45 ++++- apps/sim/lib/realtime/notify.ts | 15 +- .../workspace-file-folder-manager.ts | 37 +++- .../application/file-versions.ts | 5 +- apps/sim/lib/workspace-files/browser-query.ts | 4 +- .../scripts/test-project-file-history-e2e.ts | 60 ++++++- packages/sim-cli/src/generated/v2-api.ts | 18 +- packages/sim-cli/src/runtime/build.test.ts | 15 ++ packages/sim-cli/src/runtime/options.ts | 1 + scripts/generate-v2-cli-api.test.ts | 24 ++- scripts/generate-v2-cli-api.ts | 28 ++- scripts/generate-v2-mcp-operations.ts | 2 +- 44 files changed, 1166 insertions(+), 134 deletions(-) diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index fc2d2f62ac0..74208acff1b 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Files & Audit Logs", - "description": "Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.", + "description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Create, upload, download, organize, share, version, and delete workspace and Project files." + "description": "Create, upload, download, organize, share, version, and delete workspace files." }, { "name": "Audit Logs", diff --git a/apps/realtime/src/handlers/file-doc-editor.integration.ts b/apps/realtime/src/handlers/file-doc-editor.integration.ts index a289624744e..2be2cf58c87 100644 --- a/apps/realtime/src/handlers/file-doc-editor.integration.ts +++ b/apps/realtime/src/handlers/file-doc-editor.integration.ts @@ -202,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( @@ -226,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-store.test.ts b/apps/realtime/src/handlers/file-doc-store.test.ts index 5235735d3d9..d29807a2b48 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,57 @@ 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) + Y.applyUpdate(replica, snapshot!.docState) + expect(replica.getText('body').toString()).toBe('base-durable-writer-external') + await store.attachRoom(NAME, replica) + storeInternals(store).rooms.get(NAME)!.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(), diff --git a/apps/realtime/src/handlers/file-doc-store.ts b/apps/realtime/src/handlers/file-doc-store.ts index e10b69a86f0..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. */ 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 index 1fa726fa6ab..524ad9765ac 100644 --- a/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts @@ -10,20 +10,23 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { presentProjectFileContent } from '@/lib/projects/files/api' -import { readProjectFileContent, updateProjectFileContent } from '@/lib/projects/files/application' +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: readProjectFileContent.operation, + 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: readProjectFileContent, + useCase: downloadProjectFileContent, present: presentProjectFileContent, }) 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 index 419b68d8c19..587644c7b52 100644 --- 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 @@ -6,12 +6,12 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { presentProjectFileVersionContent } from '@/lib/projects/files/api' -import { readProjectFileVersionContent } from '@/lib/projects/files/application' +import { downloadProjectFileVersionContent } from '@/lib/projects/files/application' export const GET = defineInternalBinaryRoute({ contract: readProjectFileVersionContentContract, auth: internalSessionAuth, headSafe: false, - operation: readProjectFileVersionContent.operation, + operation: downloadProjectFileVersionContent.operation, rateLimit: internalRateLimits.none({ reason: 'Authenticated historical byte delivery follows the workspace download policy', }), @@ -21,6 +21,6 @@ export const GET = defineInternalBinaryRoute({ fileId: params.fileId, version: params.version, }), - useCase: readProjectFileVersionContent, + useCase: downloadProjectFileVersionContent, present: presentProjectFileVersionContent, }) 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 index 54057fb7d9d..8153a30ee77 100644 --- 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 @@ -11,7 +11,10 @@ import { } from '@/lib/api/server/routes' import { presentProjectFileContent } from '@/lib/projects/files/api' import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' -import { readProjectFileContent, updateProjectFileContent } from '@/lib/projects/files/application' +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' @@ -22,7 +25,7 @@ export const GET = defineV2BinaryRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), - useCase: readProjectFileContent, + useCase: downloadProjectFileContent, present: presentProjectFileContent, headSafe: false, }) @@ -34,6 +37,18 @@ export const PUT = defineV2JsonRoute({ 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, 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 index 292f50d7582..6f13ce8af36 100644 --- 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 @@ -6,7 +6,7 @@ import { v2RateLimits, } from '@/lib/api/server/routes' import { presentProjectFileVersionContent } from '@/lib/projects/files/api' -import { readProjectFileVersionContent } from '@/lib/projects/files/application' +import { downloadProjectFileVersionContent } from '@/lib/projects/files/application' import { projectFileOperations } from '@/lib/projects/files/application/operations' export const GET = defineV2BinaryRoute({ contract: v2ReadProjectFileVersionContentContract, @@ -20,6 +20,6 @@ export const GET = defineV2BinaryRoute({ fileId: params.fileId, version: params.version, }), - useCase: readProjectFileVersionContent, + useCase: downloadProjectFileVersionContent, present: presentProjectFileVersionContent, }) 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 63140ee7673..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 @@ -122,6 +122,76 @@ 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) 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 0313e08e83f..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 @@ -140,6 +140,7 @@ export class FileDocProvider extends ObservableV2 { synced = false canWrite = false private permissionKnown = false + private requiresReinitialization = false private readonly owner: FileDocOwner private readonly membershipKey: string /** @@ -455,6 +456,7 @@ export class FileDocProvider extends ObservableV2 { if ( this.disposed || this.fatal || + this.requiresReinitialization || !this.socket.connected || generation !== this.connectionGeneration || !this.joinHydrating @@ -695,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) @@ -728,6 +730,7 @@ export class FileDocProvider extends ObservableV2 { /** A terminal document cannot publish; inbound and recovery updates must not echo. */ if ( this.fatal || + this.requiresReinitialization || (this.permissionKnown && !this.canWrite) || origin === this || origin === RECOVERY_ORIGIN @@ -764,6 +767,7 @@ export class FileDocProvider extends ObservableV2 { } private queuePendingUpdate(update: Uint8Array): void { + if (this.requiresReinitialization) return this.pendingUpdateBatch.push(update) this.updateBeforeUnloadProtection() } @@ -779,6 +783,7 @@ export class FileDocProvider extends ObservableV2 { private async flushPendingUpdates(): Promise { if ( + this.requiresReinitialization || this.updateMode === 'negotiating' || this.pendingUpdateBatch.length === 0 || this.disposed || @@ -832,6 +837,7 @@ export class FileDocProvider extends ObservableV2 { const pending = this.inFlightUpdate const docId = this.docId() if ( + this.requiresReinitialization || !pending || !docId || this.updateMode !== 'acknowledged' || @@ -924,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 @@ -1096,11 +1103,13 @@ export class FileDocProvider extends ObservableV2 { } private setCanWrite(canWrite: boolean) { + if (this.requiresReinitialization) return this.permissionKnown = true if (this.canWrite === canWrite) return this.canWrite = canWrite - if (!canWrite) this.clearUpdateTimers() if (!canWrite) { + this.requiresReinitialization = true + this.clearUpdateTimers() this.pendingUpdatesDrained = true this.pendingUpdateBatch = [] this.inFlightUpdate = null 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 9126dad5c3d..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 @@ -45,6 +45,7 @@ interface UseFileDocCollaborationParams { * returns `null` and the editor stays fully local. */ enabled: boolean + onWriteAccessLost?: () => void } /** @@ -60,9 +61,12 @@ 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). @@ -113,6 +117,7 @@ export function useFileDocCollaboration({ }) setCanWrite(fileProvider.canWrite) const reloadAfterWriteLoss = async () => { + onWriteAccessLostRef.current?.() const filters: QueryFilters[] = [ { queryKey: workspaceFilesKeys.contentFile(workspaceId, fileId) }, { queryKey: workspaceFilesKeys.record(workspaceId, fileId) }, 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 4f0c7888d84..fe8341c872b 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,7 +3,7 @@ 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' @@ -15,20 +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, realCollaboration, 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( @@ -46,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, @@ -162,6 +167,7 @@ interface RenderOptions { file?: WorkspaceFileRecord onDeriveTitleFromHeading?: (fileId: string, headingText: string) => void collaborative?: boolean + recoveringCollaboration?: boolean downloadSourceRef?: { current: FileDownloadSource | null } isStreaming?: boolean streamIsIncremental?: boolean @@ -191,6 +197,7 @@ async function render( userId='user-1' userName='User' collaborative={options.collaborative} + recoveringCollaboration={options.recoveringCollaboration} enableFind={false} onChange={options.onChange ?? onChange} onEditSource={onEditSource} @@ -341,6 +348,150 @@ 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 firstBinding = vi.mocked(useFileDocCollaboration).mock.calls.at(-1)![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"]')! + 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() 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 176b5ace3cb..53aa995f4d5 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,7 +506,9 @@ 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) /** @@ -495,7 +523,7 @@ export function LoadedRichMarkdownEditor({ const [collaborationEnabled] = useState( () => collaborative && - !streamingAtMount && + (!streamingAtMount || recoveringCollaboration) && (settled?.verdict ?? false) && Boolean(userId) && (file.storageContext ?? 'workspace') === 'workspace' @@ -526,6 +554,7 @@ export function LoadedRichMarkdownEditor({ userId, userName, enabled: collaborationEnabled, + onWriteAccessLost, }) const effectiveCanEdit = canEdit && (!collaborationEnabled || collaboration?.canWrite === true) const isEditable = effectiveCanEdit && !isStreaming && (settled?.verdict ?? false) && collabReady @@ -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,14 @@ 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( + new Set<{ + editor: Editor + anchors: string[] + controller: AbortController + toastId?: ReturnType + }>() + ) /** * Uploads are sequential; every position is anchored before awaiting so queued images also follow @@ -642,39 +685,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 +1034,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 +1064,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 = diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index b7b33f04320..2a8b2bff54c 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -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/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 98d56f94aae..fe19ef283db 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -1396,7 +1396,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Files & Audit Logs', description: - 'Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.', + 'Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -1413,7 +1413,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ { name: 'Files', description: - 'Create, upload, download, organize, share, version, and delete workspace and Project files.', + 'Create, upload, download, organize, share, version, and delete workspace files.', }, { name: 'Audit Logs', 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 index 17bbe2ae39f..93ec5a77fec 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts @@ -21,7 +21,7 @@ export const projectFileFolderOpenApiRoutes = [ { applicationOperation: projectFileOperations.listFolders, operationId: 'listProjectFileFolders', - summary: 'List Project File Folders', + 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, @@ -56,7 +56,7 @@ export const projectFileFolderOpenApiRoutes = [ { applicationOperation: projectFileOperations.createFolder, operationId: 'createProjectFileFolder', - summary: 'Create Project File Folder', + 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, @@ -92,7 +92,7 @@ export const projectFileFolderOpenApiRoutes = [ { applicationOperation: projectFileOperations.updateFolder, operationId: 'updateProjectFileFolder', - summary: 'Update Project File Folder', + 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, @@ -125,7 +125,7 @@ export const projectFileFolderOpenApiRoutes = [ { applicationOperation: projectFileOperations.restoreFolder, operationId: 'restoreProjectFileFolder', - summary: 'Restore Project File Folder', + 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, 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 index ba82761c002..10f266c6020 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts @@ -20,7 +20,7 @@ export const projectFileUploadOpenApiRoutes = [ { applicationOperation: projectFileOperations.uploadCreate, operationId: 'createProjectFileUpload', - summary: 'Create Project File Upload', + summary: 'Create Project file upload', description: `Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. The original API credential and upload-token are required on every control request. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Files'], errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], @@ -53,7 +53,7 @@ export const projectFileUploadOpenApiRoutes = [ { applicationOperation: projectFileOperations.uploadRead, operationId: 'getProjectFileUpload', - summary: 'Get Project File Upload', + 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, 'PayloadTooLarge', 'UnsupportedMediaType'], @@ -86,7 +86,7 @@ export const projectFileUploadOpenApiRoutes = [ { applicationOperation: projectFileOperations.uploadCancel, operationId: 'abortProjectFileUpload', - summary: 'Abort Project File Upload', + 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, 'PayloadTooLarge', 'UnsupportedMediaType'], @@ -119,7 +119,7 @@ export const projectFileUploadOpenApiRoutes = [ { applicationOperation: projectFileOperations.uploadComplete, operationId: 'completeProjectFileUpload', - summary: 'Complete Project File Upload', + 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', 'UnsupportedMediaType'], @@ -152,7 +152,7 @@ export const projectFileUploadOpenApiRoutes = [ { applicationOperation: projectFileOperations.uploadParts, operationId: 'getProjectFileUploadPartUrls', - summary: 'Get Project File Upload Part URLs', + 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'], diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 86b0cb1430d..9a5d57f7e7b 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -405,7 +405,7 @@ export const V2_MCP_OPERATIONS = { }, abortProjectFileUpload: { contract: v2AbortProjectFileUploadContract, - summary: 'Abort Project File Upload', + 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, @@ -664,7 +664,7 @@ export const V2_MCP_OPERATIONS = { }, completeProjectFileUpload: { contract: v2CompleteProjectFileUploadContract, - summary: 'Complete Project File Upload', + 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, @@ -840,7 +840,7 @@ export const V2_MCP_OPERATIONS = { }, createProjectFileFolder: { contract: v2CreateProjectFileFolderContract, - summary: 'Create Project File Folder', + 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, @@ -849,7 +849,7 @@ export const V2_MCP_OPERATIONS = { }, createProjectFileUpload: { contract: v2CreateProjectFileUploadContract, - summary: 'Create Project File Upload', + summary: 'Create Project file upload', description: 'Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. The original API credential and upload-token are required on every control request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, @@ -1556,9 +1556,9 @@ export const V2_MCP_OPERATIONS = { }, getProjectFileUpload: { contract: v2GetProjectFileUploadContract, - summary: 'Get Project File Upload', + 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:write`.', + '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( @@ -1567,7 +1567,7 @@ export const V2_MCP_OPERATIONS = { }, getProjectFileUploadPartUrls: { contract: v2GetProjectFileUploadPartUrlsContract, - summary: 'Get Project File Upload Part URLs', + 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, @@ -2146,7 +2146,7 @@ export const V2_MCP_OPERATIONS = { }, listProjectFileFolders: { contract: v2ListProjectFileFoldersContract, - summary: 'List Project File Folders', + 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, @@ -2660,7 +2660,7 @@ export const V2_MCP_OPERATIONS = { }, restoreProjectFileFolder: { contract: v2RestoreProjectFileFolderContract, - summary: 'Restore Project File Folder', + 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, @@ -3045,7 +3045,7 @@ export const V2_MCP_OPERATIONS = { }, updateProjectFileFolder: { contract: v2UpdateProjectFileFolderContract, - summary: 'Update Project File Folder', + 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, 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/projects/files/__integration__/authorization.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts index ef8a869fe54..3bcc10910d1 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts @@ -542,6 +542,73 @@ describe('Project file authority at the database boundary', () => { (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 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( diff --git a/apps/sim/lib/projects/files/__integration__/browser.integration.ts b/apps/sim/lib/projects/files/__integration__/browser.integration.ts index 17256e413eb..8d3878ce944 100644 --- a/apps/sim/lib/projects/files/__integration__/browser.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/browser.integration.ts @@ -229,6 +229,12 @@ describe('Project browser real mixed collection', () => { (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', diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts index 6167cbf95b1..67fbc16097a 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -2,6 +2,7 @@ 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, @@ -34,14 +35,25 @@ 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/workspace-file-storage-cleanup-outbox' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import * as storage from '@/lib/uploads/core/storage-service' @@ -1454,6 +1466,7 @@ describe('Project outer transaction cleanup', () => { }) afterAll(async () => { + vi.unstubAllEnvs() vi.restoreAllMocks() for (const f of fixtures) { await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) @@ -1476,3 +1489,146 @@ afterAll(async () => { 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'] 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]) { + const execute = () => + operation.execute({ + principal, + input: { projectId: f.projectId, fileId: file.id, version: 1 }, + }) + await expect( + gate === 'missing' + ? execute() + : observeContentDelivery( + { admit: async (sources) => sources.map(() => false) }, + 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) + } + expect(buffered).not.toHaveBeenCalled() + expect(streamed).not.toHaveBeenCalled() + } + ) + } +}) diff --git a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts index 1596bb35c38..222c0ff66e4 100644 --- a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts @@ -367,7 +367,6 @@ describe('Project upload sessions with real leases, storage, and accounting', () .set({ permissionType: 'read' }) .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) for (const operation of [ - application.getProjectFileUploadSession, application.getProjectFileUploadPartUrls, application.completeProjectFileUploadSession, application.abortProjectFileUploadSession, @@ -649,3 +648,27 @@ afterAll(async () => { 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 index 28ee6b947a9..31ce36ac8d3 100644 --- a/apps/sim/lib/projects/files/api/content-presenter.ts +++ b/apps/sim/lib/projects/files/api/content-presenter.ts @@ -4,16 +4,18 @@ import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/de /** Presents authorized bytes without caching the mutable file URL. */ export function presentProjectFileContent({ file, - content, + stream, + contentLength, }: { file: OwnedFileRecord - content: Buffer + stream: ReadableStream + contentLength: number }) { return presentFileDelivery({ - body: content, + body: stream, filename: file.name, contentType: file.type, - contentLength: content.length, + contentLength, cacheControl: FILE_CACHE_CONTROL.noStore, }) } diff --git a/apps/sim/lib/projects/files/api/version-presenters.ts b/apps/sim/lib/projects/files/api/version-presenters.ts index 206edfe9bd4..17facd9200d 100644 --- a/apps/sim/lib/projects/files/api/version-presenters.ts +++ b/apps/sim/lib/projects/files/api/version-presenters.ts @@ -5,17 +5,19 @@ import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/de export function presentProjectFileVersionContent({ file, version, - content, + stream, + contentLength, }: { file: OwnedFileRecord version: WorkspaceFileVersionRecord - content: Buffer + stream: ReadableStream + contentLength: number }) { return presentFileDelivery({ - body: content, + body: stream, filename: file.name, contentType: version.contentType, - contentLength: content.length, + contentLength, attachment: true, cacheControl: FILE_CACHE_CONTROL.noStore, }) diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts index e5b6a65fe6c..459736df8dc 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -1,7 +1,10 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' import type { PreparedCollabDocState } from '@/lib/collab-doc/collab-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, @@ -28,7 +31,7 @@ import { getBoundWorkspaceFileSecretProvenanceByMetadata, type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { downloadFile } from '@/lib/uploads/core/storage-service' +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-utils' @@ -265,10 +268,17 @@ interface PreparedFileRead { export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ operation: projectFileOperations.readContent, async prepare({ + principal, input, - context, }: Omit, 'tx'>): Promise { - const file = requireFile(context) + const snapshot = await captureContentDownload.execute({ principal, input }) + const file = snapshot.file + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: file.id, + provenance: snapshot.secretProvenance, + view: 'complete', + }) const content = await downloadFile({ key: file.key, context: 'project', @@ -277,6 +287,7 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ return { key: file.key, revision: file.contentUpdatedAt, content } }, async execute({ + principal, input, context, tx, @@ -290,7 +301,9 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ ) throw new OrchestrationError('conflict', 'File changed while its content was being read') const provenance = - input.includeSecretProvenance || hasWorkspaceFileDeliveryObserver() + input.includeSecretProvenance || + hasWorkspaceFileDeliveryObserver() || + isCopilotPrincipal(principal) ? ((await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get(file.id) ?? { status: 'unknown' as const, }) @@ -301,6 +314,68 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ ...(provenance ? { secretProvenance: provenance } : {}), } }, - afterSuccess: ({ result }) => - reportWorkspaceFileDelivery(result.secretProvenance, result.file.id), + afterSuccess: async ({ principal, result }) => { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance ?? { status: 'unknown' }, + view: 'complete', + }) + await reportWorkspaceFileDelivery(result.secretProvenance, 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: 'complete', + }) + 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: 'complete', + }) + 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/index.ts b/apps/sim/lib/projects/files/application/index.ts index 42a27c90291..7a6bbc6a509 100644 --- a/apps/sim/lib/projects/files/application/index.ts +++ b/apps/sim/lib/projects/files/application/index.ts @@ -1,5 +1,10 @@ export { getProjectFileListAccess } from './collection-access' -export { createProjectFile, readProjectFileContent, updateProjectFileContent } from './content' +export { + createProjectFile, + downloadProjectFileContent, + readProjectFileContent, + updateProjectFileContent, +} from './content' export { createProjectFileFolder, listProjectFileFolders, updateProjectFileFolder } from './folders' export { archiveProjectFileItems, @@ -24,6 +29,7 @@ export { } from './uploads' export { deleteProjectFileVersion, + downloadProjectFileVersionContent, listProjectFileVersions, readProjectFileVersion, readProjectFileVersionContent, diff --git a/apps/sim/lib/projects/files/application/operations.ts b/apps/sim/lib/projects/files/application/operations.ts index 9dfd0cf83fb..f3087085fac 100644 --- a/apps/sim/lib/projects/files/application/operations.ts +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -184,9 +184,9 @@ export const projectFileOperations = { ...POLICY, id: 'project_file_uploads.read', capability: 'files.use', - access: 'write', + access: 'read', target: 'project', - oauthScope: 'api:write', + oauthScope: 'api:read', }), uploadParts: defineProjectFileOperation({ ...POLICY, diff --git a/apps/sim/lib/projects/files/application/read.ts b/apps/sim/lib/projects/files/application/read.ts index b87955454f5..c510a7993f3 100644 --- a/apps/sim/lib/projects/files/application/read.ts +++ b/apps/sim/lib/projects/files/application/read.ts @@ -16,6 +16,7 @@ import { resolveFileReference, workspaceFileVfsPath, } from '@/lib/uploads/contexts/workspace' +import { resolveArchivedFileFolderScope } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { type FileBrowserQuery, queryFileBrowserItems } from '@/lib/workspace-files/browser-query' import { fileOwnerVfsPath } from '@/lib/workspace-files/owner-paths' @@ -51,7 +52,15 @@ export const listProjectFiles = defineAuthorizedProjectFileUseCase< throw new OrchestrationError('validation', 'Specify either folderPath or folderId, not both') } let folderId: string | null | string[] | undefined = input.folderId - if (input.folderPath !== undefined) { + 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, }) diff --git a/apps/sim/lib/projects/files/application/uploads.ts b/apps/sim/lib/projects/files/application/uploads.ts index 7e4a26b9714..86e1c307787 100644 --- a/apps/sim/lib/projects/files/application/uploads.ts +++ b/apps/sim/lib/projects/files/application/uploads.ts @@ -356,7 +356,7 @@ const registerProjectUpload = defineAuthorizedProjectFileUseCase({ export const completeProjectFileUploadSession = controlUseCase( projectFileOperations.uploadComplete, - async ({ principal, input, loaded }) => { + async ({ principal, input, loaded, request }) => { if (loaded.session.status === 'completed' && !loaded.session.completedFileId) throw new OrchestrationError('conflict', 'Completed upload has no registered file') try { @@ -372,6 +372,7 @@ export const completeProjectFileUploadSession = controlUseCase( const value = await registerProjectUpload.execute({ principal, input: { projectId: loaded.context.projectId, session }, + request, }) return { value, completedFileId: value.file.id } }, diff --git a/apps/sim/lib/projects/files/application/versions.ts b/apps/sim/lib/projects/files/application/versions.ts index 076c6efc215..583b5f409df 100644 --- a/apps/sim/lib/projects/files/application/versions.ts +++ b/apps/sim/lib/projects/files/application/versions.ts @@ -3,7 +3,10 @@ import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/princip 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, @@ -45,7 +48,7 @@ import { queryWorkspaceFileVersions, type WorkspaceFileVersionRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-versions' -import { downloadFile } from '@/lib/uploads/core/storage-service' +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-utils' @@ -75,6 +78,7 @@ interface VersionArgs { prepared?: P } interface VersionSnapshot { + organizationId: string | null file: WorkspaceFileRow current: WorkspaceFileVersionRecord version: WorkspaceFileVersionRecord @@ -162,6 +166,7 @@ async function captureVersion( : 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, @@ -253,6 +258,12 @@ const readVersionContent = defineAuthorizedProjectFileUseCase({ 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: 'complete', + }) return { snapshot, content: await readVersionBytes(snapshot.version, input.maxBytes) } }, async execute({ @@ -271,8 +282,15 @@ const readVersionContent = defineAuthorizedProjectFileUseCase({ secretProvenance: workspaceFileSecretProvenanceFromSnapshot(current.provenance), } }, - afterSuccess: ({ result }) => - reportWorkspaceFileDelivery(result.secretProvenance, result.file.id), + afterSuccess: async ({ principal, result }) => { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance, + view: 'complete', + }) + await reportWorkspaceFileDelivery(result.secretProvenance, result.file.id) + }, }) /** HEAD validates retention and the selected version under the same operation without storage I/O. */ @@ -283,6 +301,56 @@ export const readProjectFileVersionContent = { }, } +/** 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: 'complete', + }) + 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: 'complete', + }) + 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, diff --git a/apps/sim/lib/realtime/notify.test.ts b/apps/sim/lib/realtime/notify.test.ts index 1a7bd1c61be..4b23540a708 100644 --- a/apps/sim/lib/realtime/notify.test.ts +++ b/apps/sim/lib/realtime/notify.test.ts @@ -1,7 +1,11 @@ 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' }) @@ -76,7 +80,7 @@ describe('invalidateLiveFileDoc', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'x-api-key': 'secret' }), - body: JSON.stringify({ ...target, version: 42 }), + body: JSON.stringify({ fileId: target.fileId, version: 42 }), }) ) @@ -84,3 +88,40 @@ describe('invalidateLiveFileDoc', () => { 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 6f51feb764d..2e47d103f9e 100644 --- a/apps/sim/lib/realtime/notify.ts +++ b/apps/sim/lib/realtime/notify.ts @@ -223,7 +223,7 @@ export async function applyEditToLiveFileDoc( fileId: target.fileId, markdown, version: order.version, - ...fileDocOwnerWireFields(target.owner), + ...(target.owner.entityType === 'project' ? fileDocOwnerWireFields(target.owner) : {}), }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) @@ -261,7 +261,11 @@ export async function invalidateLiveFileDoc( 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({ ...target, version, ...fileDocOwnerWireFields(target.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(() => {}) @@ -280,7 +284,12 @@ export async function retireLiveFileDoc( 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, ...fileDocOwnerWireFields(target.owner) }), + 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(() => {}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index c62947649b8..010e976e7bf 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -26,7 +26,11 @@ import { requireWorkspaceFolder, requireWorkspaceFolderCreator, } from '@/lib/folders/scope' -import { collectDescendantFolderIds } from '@/lib/folders/subtree' +import { + collectDescendantFolderIds, + collectDescendantFolderIdsFrom, + indexFolderChildren, +} from '@/lib/folders/subtree' import { lockWorkspaceProject } from '@/lib/projects/membership' import { encodeWorkspaceFileFolderDisplaySegment, @@ -1549,6 +1553,37 @@ export 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 + const rows = await client + .select({ id: folderTable.id, parentId: folderTable.parentId, name: folderTable.name }) + .from(folderTable) + .where(fileFolderOwnerCondition(owner)) + .limit(maxRows + 1) + if (rows.length > maxRows) throw new FolderCollectionLimitExceededError('path index', maxRows) + let parents = new Set([null]) + for (const segment of segments) { + parents = new Set( + rows.filter((row) => row.name === segment && parents.has(row.parentId)).map((row) => row.id) + ) + if (parents.size === 0) return [] + } + const matchingIds = [...parents].filter((id): id is string => id !== null) + if (!recursive) return matchingIds + const children = indexFolderChildren(rows) + return [ + ...new Set(matchingIds.flatMap((id) => [id, ...collectDescendantFolderIdsFrom(children, id)])), + ] +} + export interface WorkspaceFileFolderPathMutation { folder: typeof folderTable.$inferSelect & { workspaceId: string; userId: string } path: string diff --git a/apps/sim/lib/workspace-files/application/file-versions.ts b/apps/sim/lib/workspace-files/application/file-versions.ts index 62017bb2b24..f977165e536 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.ts @@ -272,7 +272,10 @@ export const downloadWorkspaceFileVersion = { ...downloadVersion, async authorize(args: Parameters[0]) { await downloadVersion.authorize(args) - const context = await resolveActiveWorkspaceFileContext(args.input) + const context = await resolveActiveWorkspaceFileContext({ + ...args.input, + ownedFilePrincipal: args.principal, + }) await loadVersion(await loadActiveFile(context), args.input.version) }, } diff --git a/apps/sim/lib/workspace-files/browser-query.ts b/apps/sim/lib/workspace-files/browser-query.ts index cc5206dd096..2791dd7cb8f 100644 --- a/apps/sim/lib/workspace-files/browser-query.ts +++ b/apps/sim/lib/workspace-files/browser-query.ts @@ -137,8 +137,8 @@ export async function queryFileBrowserItems( join owned_files files on files.parent_id = tree.id group by tree.root_id ), effective_files as ( - select *, case when btrim(content_type) not in ('', 'application/octet-stream', 'binary/octet-stream') - then btrim(content_type) else coalesce(${JSON.stringify(EFFECTIVE_EXTENSION_MIMES)}::jsonb ->> extension, 'application/octet-stream') end as mime + 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 ( diff --git a/apps/sim/scripts/test-project-file-history-e2e.ts b/apps/sim/scripts/test-project-file-history-e2e.ts index 7d2a7914351..15fbae6dfec 100644 --- a/apps/sim/scripts/test-project-file-history-e2e.ts +++ b/apps/sim/scripts/test-project-file-history-e2e.ts @@ -1,4 +1,5 @@ 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' @@ -53,6 +54,45 @@ async function request( }) 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)) @@ -344,9 +384,10 @@ try { ) }) await check( - 'oversized historical bytes return 413 without changing the current head', + 'large current and historical downloads stream while oversized reverts leave the head unchanged', async () => { - const bytes = Buffer.alloc(100 * 1024 * 1024 + 1) + const bytes = Buffer.alloc(100 * 1024 * 1024 + 1, 37) + const expectedHash = createHash('sha256').update(bytes).digest('hex') const upload = json( await request( 'POST', @@ -411,6 +452,13 @@ try { 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( @@ -426,7 +474,11 @@ try { [largeDetail, 'personal'], [largeInternal, 'session'], ] as const) { - statuses.push((await request('GET', `${path}/versions/1/content`, undefined, auth)).status) + 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( @@ -438,7 +490,7 @@ try { ).status ) } - assert.deepEqual(statuses, [413, 413, 413, 413]) + assert.deepEqual(statuses, [413, 413]) assert.equal( toRecord(json(await request('GET', `${largeDetail}/metadata`)).data).revision, small.revision diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index bfd85472a1c..102205b497e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -15539,7 +15539,7 @@ export const V2_OPERATIONS = { uploadId: 'Upload session identifier within the Project.', }, responseMode: 'json', - summary: 'Abort Project File Upload', + summary: 'Abort Project file upload', workspaceKeyUnsupported: true, headers: { 'upload-token': { @@ -16116,7 +16116,7 @@ export const V2_OPERATIONS = { uploadId: 'Upload session identifier within the Project.', }, responseMode: 'json', - summary: 'Complete Project File Upload', + summary: 'Complete Project file upload', workspaceKeyUnsupported: true, headers: { 'upload-token': { @@ -16690,7 +16690,7 @@ export const V2_OPERATIONS = { pathParams: ['projectId'] as const, pathParamDocs: { projectId: 'Project identifier.' }, responseMode: 'json', - summary: 'Create Project File Folder', + summary: 'Create Project file folder', workspaceKeyUnsupported: true, body: { name: { kind: 'string', required: true, describe: 'Name for the new folder.' }, @@ -16706,7 +16706,7 @@ export const V2_OPERATIONS = { pathParams: ['projectId'] as const, pathParamDocs: { projectId: 'Project identifier.' }, responseMode: 'json', - summary: 'Create Project File Upload', + summary: 'Create Project file upload', workspaceKeyUnsupported: true, body: { name: { kind: 'string', required: true, describe: 'File name, including its extension.' }, @@ -18509,7 +18509,7 @@ export const V2_OPERATIONS = { uploadId: 'Upload session identifier within the Project.', }, responseMode: 'json', - summary: 'Get Project File Upload', + summary: 'Get Project file upload', workspaceKeyUnsupported: true, headers: { 'upload-token': { @@ -18528,7 +18528,7 @@ export const V2_OPERATIONS = { uploadId: 'Upload session identifier within the Project.', }, responseMode: 'json', - summary: 'Get Project File Upload Part URLs', + summary: 'Get Project file upload part URLs', workspaceKeyUnsupported: true, body: { partNumbers: { @@ -20705,7 +20705,7 @@ export const V2_OPERATIONS = { pathParams: ['projectId'] as const, pathParamDocs: { projectId: 'Project identifier.' }, responseMode: 'json', - summary: 'List Project File Folders', + summary: 'List Project file folders', workspaceKeyUnsupported: true, query: { scope: { @@ -22605,7 +22605,7 @@ export const V2_OPERATIONS = { folderId: 'Folder identifier within the Project.', }, responseMode: 'json', - summary: 'Restore Project File Folder', + summary: 'Restore Project file folder', workspaceKeyUnsupported: true, body: {}, }, @@ -23826,7 +23826,7 @@ export const V2_OPERATIONS = { folderId: 'Folder identifier within the Project.', }, responseMode: 'json', - summary: 'Update Project File Folder', + summary: 'Update Project file folder', workspaceKeyUnsupported: true, body: { name: { kind: 'string', describe: 'New folder name; omit to leave unchanged.' }, 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/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/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index b8bc571750d..1fd88cb4d3f 100644 --- a/scripts/generate-v2-cli-api.test.ts +++ b/scripts/generate-v2-cli-api.test.ts @@ -106,8 +106,8 @@ describe('operations that refuse a workspace API key', () => { * confined to one contract family silently unmark every other one while the * pinned pair stayed green. */ - it('emits the marker for every operation the specs say refuses one', () => { - const marked = [...loadSummaries(workspaceKeyDenialMarkers()).values()].filter( + it('emits the marker for every operation the specs say refuses one', async () => { + const marked = [...(await loadSummaries(workspaceKeyDenialMarkers())).values()].filter( (doc) => doc.workspaceKeyUnsupported ) expect(marked.length).toBeGreaterThan(0) @@ -116,3 +116,23 @@ describe('operations that refuse a workspace API key', () => { ) }) }) + +describe('unpublished Project documentation', () => { + it('retains runtime help and key restrictions without publishing Project paths', async () => { + const docs = await loadSummaries(workspaceKeyDenialMarkers()) + const projectList = docs.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 824b434519d..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 = {} @@ -769,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 From 9ad23207248450d0bc1067febc2aaf51c8930a53 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 15:25:36 -0700 Subject: [PATCH 07/10] test(cli): load canonical documentation in suite setup --- scripts/generate-v2-cli-api.test.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index 1fd88cb4d3f..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. */ @@ -106,10 +111,8 @@ describe('operations that refuse a workspace API key', () => { * confined to one contract family silently unmark every other one while the * pinned pair stayed green. */ - it('emits the marker for every operation the specs say refuses one', async () => { - const marked = [...(await loadSummaries(workspaceKeyDenialMarkers())).values()].filter( - (doc) => doc.workspaceKeyUnsupported - ) + it('emits the marker for every operation the specs say refuses one', () => { + const marked = [...operationDocs.values()].filter((doc) => doc.workspaceKeyUnsupported) expect(marked.length).toBeGreaterThan(0) expect(generatedSource().match(/workspaceKeyUnsupported: true/g)?.length ?? 0).toBe( marked.length @@ -118,9 +121,8 @@ describe('operations that refuse a workspace API key', () => { }) describe('unpublished Project documentation', () => { - it('retains runtime help and key restrictions without publishing Project paths', async () => { - const docs = await loadSummaries(workspaceKeyDenialMarkers()) - const projectList = docs.get('GET /api/v2/projects/{projectId}/files') + 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) From 1d885a92eca56c9c42235f49d74b2f3979c77909 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 15:41:09 -0700 Subject: [PATCH 08/10] fix(files): bound seed reads and preserve browser snapshots --- .../src/handlers/file-doc-store.test.ts | 7 ++- .../[projectId]/[fileId]/seed/route.ts | 2 +- .../editor-lifecycle.test.tsx | 12 ++-- .../rich-markdown-editor.tsx | 23 ++++---- .../v2/openapi/project-file-uploads.ts | 6 +- .../sim/lib/api/contracts/v2/project-files.ts | 8 ++- .../authorization.integration.ts | 21 +++++++ .../__integration__/browser.integration.ts | 30 +++++++++- .../__integration__/content.integration.ts | 2 +- .../__integration__/documents.integration.ts | 31 ++++++++++ .../files/application/authorized-use-case.ts | 20 ++++--- .../lib/projects/files/application/content.ts | 2 + .../projects/files/application/documents.ts | 13 ++++- .../lib/projects/files/application/index.ts | 2 - .../lib/projects/files/application/read.ts | 13 ++++- .../contexts/workspace/file-folder-manager.ts | 57 ++++++++++++------- .../contexts/workspace/file-manager.ts | 4 +- packages/sim-cli/src/generated/v2-api.ts | 4 +- .../check-source-conventions.baseline.json | 1 - scripts/check-test-types.baseline.json | 3 - 20 files changed, 194 insertions(+), 67 deletions(-) diff --git a/apps/realtime/src/handlers/file-doc-store.test.ts b/apps/realtime/src/handlers/file-doc-store.test.ts index d29807a2b48..96a2e32a94b 100644 --- a/apps/realtime/src/handlers/file-doc-store.test.ts +++ b/apps/realtime/src/handlers/file-doc-store.test.ts @@ -423,10 +423,13 @@ describe('FileDocStore', () => { await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, vector), 'generation-1') const snapshot = await store.getStreamSnapshot(NAME, 'generation-1') expect(snapshot?.editor).toEqual(actor) - Y.applyUpdate(replica, snapshot!.docState) + 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) - storeInternals(store).rooms.get(NAME)!.uncompactedDeltaBytes = 12 * 1024 * 1024 + 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) 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 index 69c3f353d37..5c0ee2895e2 100644 --- 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 @@ -15,7 +15,7 @@ export const POST = defineInternalJsonRoute({ reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', }), errorPolicy: internalOrchestrationErrorPolicy, - mapInput: ({ params }) => params, + mapInput: ({ params }, { request }) => ({ ...params, signal: request.signal }), useCase: buildProjectFileDocSeed, present: (result) => ({ update: Buffer.from(result.update).toString('base64'), 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 9cf499b484a..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 @@ -111,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 @@ -378,7 +379,9 @@ describe('loaded rich editor lifecycle', () => { ) } await draw() - const firstBinding = vi.mocked(useFileDocCollaboration).mock.calls.at(-1)![0] + 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({ @@ -457,8 +460,9 @@ describe('loaded rich editor lifecycle', () => { await act(async () => provider.setSynced(true)) const editor = getEditor() await act(async () => editor.commands.setContent('

Original

')) - const input = container.querySelector('input[type="file"]')! - await act(async () => editor.storage.slashCommand.insertImage(1)) + 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: [ @@ -471,7 +475,7 @@ describe('loaded rich editor lifecycle', () => { 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)) + 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) 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 54abf5780eb..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 @@ -663,14 +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( - new Set<{ - editor: Editor - anchors: string[] - controller: AbortController - toastId?: ReturnType - }>() - ) + const imageUploadBatchesRef = useRef + }> | null>(null) + imageUploadBatchesRef.current ??= new Set() /** * Uploads are sequential; every position is anchored before awaiting so queued images also follow @@ -697,7 +696,7 @@ export function LoadedRichMarkdownEditor({ controller: new AbortController(), toastId: undefined as ReturnType | undefined, } - imageUploadBatchesRef.current.add(batch) + imageUploadBatchesRef.current?.add(batch) try { for (const [index, image] of images.entries()) { if (editor.isDestroyed || !editor.isEditable || batch.controller.signal.aborted) break @@ -733,7 +732,7 @@ export function LoadedRichMarkdownEditor({ } finally { if (batch.toastId !== undefined) toast.dismiss(batch.toastId) for (const anchor of anchors) removeImageUpload(editor, anchor) - imageUploadBatchesRef.current.delete(batch) + imageUploadBatchesRef.current?.delete(batch) } } @@ -1037,12 +1036,12 @@ export function LoadedRichMarkdownEditor({ useEffect(() => { if (!editor || !effectiveCanEdit) return return () => { - for (const batch of imageUploadBatchesRef.current) { + 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() + imageUploadBatchesRef.current?.clear() const anchor = pendingImageAnchorRef.current if (anchor) removeImageUpload(editor, anchor) pendingImageAnchorRef.current = null 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 index 10f266c6020..98ad93c4b0e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts @@ -56,7 +56,7 @@ export const projectFileUploadOpenApiRoutes = [ 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, 'PayloadTooLarge', 'UnsupportedMediaType'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Get Project File Upload result.', headers: RATE_LIMIT_HEADERS }, }, { @@ -89,7 +89,7 @@ export const projectFileUploadOpenApiRoutes = [ 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, 'PayloadTooLarge', 'UnsupportedMediaType'], + errors: RESOURCE_CONFLICT_ERRORS, success: { description: 'Abort Project File Upload result.', headers: RATE_LIMIT_HEADERS }, }, { @@ -122,7 +122,7 @@ export const projectFileUploadOpenApiRoutes = [ 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', 'UnsupportedMediaType'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'Complete Project File Upload result.', headers: RATE_LIMIT_HEADERS }, }, { diff --git a/apps/sim/lib/api/contracts/v2/project-files.ts b/apps/sim/lib/api/contracts/v2/project-files.ts index 3f7d7efc596..901c6db07aa 100644 --- a/apps/sim/lib/api/contracts/v2/project-files.ts +++ b/apps/sim/lib/api/contracts/v2/project-files.ts @@ -77,7 +77,9 @@ export const v2CreateProjectFileBodySchema = z .object({ name: v2CreateFileBodySchema.shape.name, contentType: v2CreateFileBodySchema.shape.contentType, - content: v2CreateFileBodySchema.shape.content, + 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.' @@ -89,7 +91,9 @@ export type V2CreateProjectFileBody = z.input featureFlagsMock) @@ -589,6 +590,15 @@ describe('Project file authority at the database boundary', () => { 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], @@ -673,6 +683,17 @@ describe('Project file authority at the database boundary', () => { 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, diff --git a/apps/sim/lib/projects/files/__integration__/browser.integration.ts b/apps/sim/lib/projects/files/__integration__/browser.integration.ts index 8d3878ce944..e04088b9e84 100644 --- a/apps/sim/lib/projects/files/__integration__/browser.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/browser.integration.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { db } from '@sim/db' +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' @@ -12,6 +12,7 @@ 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 @@ -195,6 +196,33 @@ afterAll(async () => { }) 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 () => { diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts index 2ce7fecff88..1d75f06afe1 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -1602,7 +1602,7 @@ describe('Project Copilot content admission', () => { const execute = () => operation.execute({ principal, - input: { projectId: f.projectId, fileId: file.id, version: 1 }, + input: { projectId: f.projectId, fileId: file.id }, }) await expect( gate === 'missing' diff --git a/apps/sim/lib/projects/files/__integration__/documents.integration.ts b/apps/sim/lib/projects/files/__integration__/documents.integration.ts index 092d0dd0544..5bcfc09569d 100644 --- a/apps/sim/lib/projects/files/__integration__/documents.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/documents.integration.ts @@ -24,6 +24,7 @@ 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) @@ -179,6 +180,36 @@ function documentId(update: Uint8Array) { } 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( diff --git a/apps/sim/lib/projects/files/application/authorized-use-case.ts b/apps/sim/lib/projects/files/application/authorized-use-case.ts index 13431e568de..d23c1f25c9f 100644 --- a/apps/sim/lib/projects/files/application/authorized-use-case.ts +++ b/apps/sim/lib/projects/files/application/authorized-use-case.ts @@ -35,6 +35,7 @@ export function defineAuthorizedProjectFileUseCase< P = undefined, >(definition: { operation: O + isolationLevel?: 'repeatable read' prepare?(args: ProjectFileUseCaseContext): Promise

execute(args: ProjectFileUseCaseContext & { tx: DbTransaction; prepared?: P }): Promise onCommitFailure?( @@ -83,14 +84,17 @@ export function defineAuthorizedProjectFileUseCase< 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 } - }) + 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', { diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts index 07eda939e12..e86687a2bd7 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -80,6 +80,7 @@ export interface UpdateProjectFileContentInput extends ProjectFileTarget { export interface ReadProjectFileContentInput extends ProjectFileTarget { fileId: string maxBytes?: number + signal?: AbortSignal includeSecretProvenance?: boolean } @@ -283,6 +284,7 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ key: file.key, context: 'project', maxBytes: input.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + signal: input.signal, }) return { key: file.key, revision: file.contentUpdatedAt, content } }, diff --git a/apps/sim/lib/projects/files/application/documents.ts b/apps/sim/lib/projects/files/application/documents.ts index 4f6fcf467cc..9c434a31f64 100644 --- a/apps/sim/lib/projects/files/application/documents.ts +++ b/apps/sim/lib/projects/files/application/documents.ts @@ -1,5 +1,6 @@ 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' @@ -117,7 +118,7 @@ function isDocumentConflict(error: unknown) { /** The seed cache is a read projection, fenced against both durable bytes and cached CRDT history. */ export const buildProjectFileDocSeed: AuthorizingUseCase< typeof projectFileOperations.readContent, - ProjectDocumentTarget, + ProjectDocumentTarget & { signal?: AbortSignal }, FileDocSeed > = { operation: projectFileOperations.readContent, @@ -126,13 +127,18 @@ export const buildProjectFileDocSeed: AuthorizingUseCase< 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 }, + input: { ...args.input, maxBytes: MAX_SEED_BYTES, signal }, }) + signal.throwIfAborted() if (!isMarkdownFile(read.file)) { throw new OrchestrationError( 'validation', @@ -140,7 +146,9 @@ export const buildProjectFileDocSeed: AuthorizingUseCase< ) } 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: { @@ -155,6 +163,7 @@ export const buildProjectFileDocSeed: AuthorizingUseCase< }, }, }) + signal.throwIfAborted() return { update, version: accepted.version } } catch (error) { if (!isDocumentConflict(error)) throw error diff --git a/apps/sim/lib/projects/files/application/index.ts b/apps/sim/lib/projects/files/application/index.ts index 7a6bbc6a509..65435ae4f2f 100644 --- a/apps/sim/lib/projects/files/application/index.ts +++ b/apps/sim/lib/projects/files/application/index.ts @@ -2,7 +2,6 @@ export { getProjectFileListAccess } from './collection-access' export { createProjectFile, downloadProjectFileContent, - readProjectFileContent, updateProjectFileContent, } from './content' export { createProjectFileFolder, listProjectFileFolders, updateProjectFileFolder } from './folders' @@ -32,6 +31,5 @@ export { downloadProjectFileVersionContent, listProjectFileVersions, readProjectFileVersion, - readProjectFileVersionContent, revertProjectFileVersion, } from './versions' diff --git a/apps/sim/lib/projects/files/application/read.ts b/apps/sim/lib/projects/files/application/read.ts index 9454ca1f6dc..7702d991975 100644 --- a/apps/sim/lib/projects/files/application/read.ts +++ b/apps/sim/lib/projects/files/application/read.ts @@ -124,6 +124,7 @@ export const listProjectFileItems = defineAuthorizedProjectFileUseCase< 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') @@ -144,6 +145,16 @@ export const listProjectFileItems = defineAuthorizedProjectFileUseCase< ) ).files : [] - return { ...page, files, capabilities: { canRead: true as const, canWrite: context.canWrite } } + 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/uploads/contexts/workspace/file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts index 010e976e7bf..3c7cb2b96f6 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts @@ -26,11 +26,7 @@ import { requireWorkspaceFolder, requireWorkspaceFolderCreator, } from '@/lib/folders/scope' -import { - collectDescendantFolderIds, - collectDescendantFolderIdsFrom, - indexFolderChildren, -} from '@/lib/folders/subtree' +import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { lockWorkspaceProject } from '@/lib/projects/membership' import { encodeWorkspaceFileFolderDisplaySegment, @@ -1563,25 +1559,44 @@ export async function resolveArchivedFileFolderScope( ): Promise { const segments = parseFolderPath(path) if (segments.length === 0) return recursive ? undefined : null - const rows = await client - .select({ id: folderTable.id, parentId: folderTable.parentId, name: folderTable.name }) - .from(folderTable) - .where(fileFolderOwnerCondition(owner)) - .limit(maxRows + 1) - if (rows.length > maxRows) throw new FolderCollectionLimitExceededError('path index', maxRows) - let parents = new Set([null]) + let parents: (string | null)[] = [null] for (const segment of segments) { - parents = new Set( - rows.filter((row) => row.name === segment && parents.has(row.parentId)).map((row) => row.id) - ) - if (parents.size === 0) return [] + 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) + const matchingIds = parents.filter((id): id is string => id !== null) if (!recursive) return matchingIds - const children = indexFolderChildren(rows) - return [ - ...new Set(matchingIds.flatMap((id) => [id, ...collectDescendantFolderIdsFrom(children, id)])), - ] + 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 { diff --git a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts index c4f188c0886..0ef7e5e5422 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts @@ -2067,7 +2067,9 @@ export async function resolveFileReference( } let segments: string[] try { - segments = normalizeWorkspaceFileReferenceSegments(localReference) + segments = decodeVfsPathSegments( + isFileId ? localReference : localReference.slice('files/'.length) + ) } catch { throw new OrchestrationError('validation', 'Invalid file reference path') } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 102205b497e..e59ef1d56a5 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -16670,7 +16670,7 @@ export const V2_OPERATIONS = { 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 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Use an upload session for larger payloads.', + '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', @@ -23802,7 +23802,7 @@ export const V2_OPERATIONS = { kind: 'string', required: true, describe: - 'Complete replacement content for the file. The complete JSON request is capped at 10 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The separate decoded-file limit is 50 MiB. Small patches can edit a larger stored text file without sending its full content.', + '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', 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 df66e4d2999..e656a8d0318 100644 --- a/scripts/check-test-types.baseline.json +++ b/scripts/check-test-types.baseline.json @@ -490,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 }, From 4fa71a7d173050d63aa81dc4d248d4e135ba60ea Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 15:51:54 -0700 Subject: [PATCH 09/10] fix(files): keep read provenance private unless requested --- .../contracts/v2/openapi/project-file-uploads.ts | 2 +- apps/sim/lib/api/mcp/generated/v2-operations.ts | 2 +- .../files/__integration__/content.integration.ts | 12 ++++++++++++ apps/sim/lib/projects/files/application/content.ts | 14 ++++++++++---- 4 files changed, 24 insertions(+), 6 deletions(-) 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 index 98ad93c4b0e..9a53e2820ba 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts @@ -21,7 +21,7 @@ export const projectFileUploadOpenApiRoutes = [ 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. The original API credential and upload-token are required on every control request. ${WORKSPACE_API_KEY_DENIED}`, + 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 }, diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 9a5d57f7e7b..a52f2a6b33d 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -851,7 +851,7 @@ export const V2_MCP_OPERATIONS = { 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. The original API credential and upload-token are required on every control request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + '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), diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts index 1d75f06afe1..06f17703a92 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -953,6 +953,18 @@ describe('Project file content against PostgreSQL and the local object store', ( }) ) ).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' }) + } } ) diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts index e86687a2bd7..9f2bf80c250 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -266,6 +266,8 @@ interface PreparedFileRead { content: Buffer } +const readDeliveryProvenance = new WeakMap() + export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ operation: projectFileOperations.readContent, async prepare({ @@ -310,20 +312,24 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ status: 'unknown' as const, }) : undefined - return { + const result = { ...(await mapProjectFileResult(tx, context, file)), content: prepared.content, - ...(provenance ? { secretProvenance: provenance } : {}), + ...(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: result.secretProvenance ?? { status: 'unknown' }, + provenance: provenance ?? { status: 'unknown' }, view: 'complete', }) - await reportWorkspaceFileDelivery(result.secretProvenance, result.file.id) + await reportWorkspaceFileDelivery(provenance, result.file.id) }, }) From 6c035116d9b9ac08f58a723d77f80967685a54a2 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 15:58:51 -0700 Subject: [PATCH 10/10] fix(files): classify raw Project downloads as opaque --- .../__integration__/content.integration.ts | 23 +++++++++++++++---- .../lib/projects/files/application/content.ts | 8 +++---- .../projects/files/application/versions.ts | 8 +++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts index 06f17703a92..46de0b30753 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -1579,7 +1579,7 @@ describe('Project streaming source delivery', () => { }) describe('Project Copilot content admission', () => { - for (const gate of ['missing', 'deny'] as const) { + 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 () => { @@ -1611,6 +1611,7 @@ describe('Project Copilot content admission', () => { 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, @@ -1620,7 +1621,16 @@ describe('Project Copilot content admission', () => { gate === 'missing' ? execute() : observeContentDelivery( - { admit: async (sources) => sources.map(() => false) }, + { + 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( @@ -1638,8 +1648,13 @@ describe('Project Copilot content admission', () => { }) ).rejects.toBeInstanceOf(PrincipalKindAuthorizationError) } - expect(buffered).not.toHaveBeenCalled() - expect(streamed).not.toHaveBeenCalled() + 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/application/content.ts b/apps/sim/lib/projects/files/application/content.ts index 9f2bf80c250..eb7e8dd722e 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -280,7 +280,7 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ kind: 'workspace-file', id: file.id, provenance: snapshot.secretProvenance, - view: 'complete', + view: 'opaque', }) const content = await downloadFile({ key: file.key, @@ -327,7 +327,7 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ kind: 'workspace-file', id: result.file.id, provenance: provenance ?? { status: 'unknown' }, - view: 'complete', + view: 'opaque', }) await reportWorkspaceFileDelivery(provenance, result.file.id) }, @@ -359,7 +359,7 @@ export const downloadProjectFileContent = { kind: 'workspace-file', id: snapshot.file.id, provenance: snapshot.secretProvenance, - view: 'complete', + view: 'opaque', }) const stream = nodeReadableToWebStream( await runWithOutboundOrganization(snapshot.organizationId, () => @@ -377,7 +377,7 @@ export const downloadProjectFileContent = { kind: 'workspace-file', id: current.file.id, provenance: current.secretProvenance, - view: 'complete', + view: 'opaque', }) await reportWorkspaceFileDelivery(current.secretProvenance, current.file.id) return { file: current.file, stream, contentLength: current.file.size } diff --git a/apps/sim/lib/projects/files/application/versions.ts b/apps/sim/lib/projects/files/application/versions.ts index 2f59e2e47bc..3d6516f27fb 100644 --- a/apps/sim/lib/projects/files/application/versions.ts +++ b/apps/sim/lib/projects/files/application/versions.ts @@ -262,7 +262,7 @@ const readVersionContent = defineAuthorizedProjectFileUseCase({ kind: 'workspace-file', id: snapshot.file.id, provenance: workspaceFileSecretProvenanceFromSnapshot(snapshot.provenance), - view: 'complete', + view: 'opaque', }) return { snapshot, content: await readVersionBytes(snapshot.version, input.maxBytes) } }, @@ -287,7 +287,7 @@ const readVersionContent = defineAuthorizedProjectFileUseCase({ kind: 'workspace-file', id: result.file.id, provenance: result.secretProvenance, - view: 'complete', + view: 'opaque', }) await reportWorkspaceFileDelivery(result.secretProvenance, result.file.id) }, @@ -312,7 +312,7 @@ export const downloadProjectFileVersionContent = { kind: 'workspace-file', id: snapshot.file.id, provenance: workspaceFileSecretProvenanceFromSnapshot(snapshot.provenance), - view: 'complete', + view: 'opaque', }) const stream = nodeReadableToWebStream( await runWithOutboundOrganization(snapshot.organizationId, () => @@ -328,7 +328,7 @@ export const downloadProjectFileVersionContent = { kind: 'workspace-file', id: current.file.id, provenance: workspaceFileSecretProvenanceFromSnapshot(current.provenance), - view: 'complete', + view: 'opaque', }) await reportWorkspaceFileDelivery( workspaceFileSecretProvenanceFromSnapshot(current.provenance),