chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,124 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile'
import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc'
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
import { validateMermaidSource } from '@/lib/mermaid/validate'
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const logger = createLogger('WorkspaceFileCompiledCheckAPI')
/**
* GET /api/workspaces/[id]/files/[fileId]/compiled-check
*
* Compiles or validates the saved source for generated document-like files and
* returns whether it succeeds. Used by the file agent to self-verify generated
* code or diagram syntax before finalising an edit.
*
* Returns:
* 200 { ok: true }
* 200 { ok: false, error: string, errorName: string } — user code error
* 4xx on auth / missing file / unsupported extension
* 500 on system (sandbox infra) failure
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!membership) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? ''
// In the E2B regime ALL four formats compile in the doc sandbox (Node for
// pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so
// a stale file can't trigger an E2B compile when the sandbox is disabled.
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null
const taskId = BINARY_DOC_TASKS[ext]
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
if (!e2bFmt && !taskId && !isMermaidFile) {
return NextResponse.json(
{ error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` },
{ status: 422 }
)
}
let buffer: Buffer
try {
buffer = await fetchWorkspaceFileBuffer(fileRecord)
} catch (err) {
logger.error('Failed to download file for compiled check', {
fileId,
error: toError(err).message,
})
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
}
const code = buffer.toString('utf-8')
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 })
}
if (isMermaidFile) {
return NextResponse.json(await validateMermaidSource(code))
}
if (e2bFmt) {
// Loads the compile-once artifact if present, else compiles via E2B once
// (and recalc-scans xlsx formulas). Only a script error is { ok: false };
// infra failures rethrow → 500, so the agent isn't told to "fix its script"
// during an E2B/S3 outage.
const result = await runE2BCompiledCheck({
source: code,
fileName: fileRecord.name,
workspaceId,
ext,
})
return NextResponse.json(result)
}
try {
if (!taskId) {
return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 })
}
await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` })
return NextResponse.json({ ok: true })
} catch (err) {
if (err instanceof SandboxUserCodeError) {
logger.info('Compiled check failed with user code error', {
fileId,
taskId,
error: toError(err).message,
errorName: err.name,
})
return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name })
}
throw err
}
}
)
@@ -0,0 +1,96 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceFileContentAPI')
/**
* PUT /api/workspaces/[id]/files/[fileId]/content
* Update a workspace file's text content (requires write permission)
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const { content, encoding } = parsed.data.body
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const buffer =
encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
const maxFileSizeBytes = 50 * 1024 * 1024
if (buffer.length > maxFileSizeBytes) {
return NextResponse.json(
{ error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` },
{ status: 413 }
)
}
const updatedFile = await updateWorkspaceFileContent(
workspaceId,
fileId,
session.user.id,
buffer
)
logger.info(`Updated content for workspace file: ${updatedFile.name}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPDATED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: updatedFile.name,
description: `Updated content of file "${updatedFile.name}"`,
metadata: { contentSize: buffer.length },
request,
})
return NextResponse.json({
success: true,
file: updatedFile,
})
} catch (error) {
const errorMessage = toError(error).message || 'Failed to update file content'
const isNotFound = errorMessage.includes('File not found')
const isQuotaExceeded = errorMessage.includes('Storage limit exceeded')
const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
if (status === 500) {
logger.error('Error updating file content:', error)
} else {
logger.warn(errorMessage)
}
return NextResponse.json({ success: false, error: errorMessage }, { status })
}
}
)
@@ -0,0 +1,56 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceCsvPreviewAPI')
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const userId = authResult.userId
const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const { key } = parsed.data.query
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Resolve the file record (active, in this workspace) and read from its authoritative key —
// never the client-supplied one. This rejects archived/deleted files and keys with no live
// row, matching the access guarantees of /api/files/serve.
const record = await getWorkspaceFile(workspaceId, fileId)
if (!record || record.key !== key) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const slice = await getCsvPreviewSlice({
key: record.key,
context: 'workspace',
signal: request.signal,
})
logger.info('CSV preview served', {
workspaceId,
rows: slice.rows.length,
truncated: slice.truncated,
})
return NextResponse.json({ success: true, ...slice })
}
)
@@ -0,0 +1,96 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceFileDownloadAPI')
/**
* POST /api/workspaces/[id]/files/[fileId]/download
* Return authenticated file serve URL (requires read permission)
* Uses /api/files/serve endpoint which enforces authentication and context
*/
export const POST = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
if (!paramsResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
{ status: 400 }
)
}
const { id: workspaceId, fileId } = paramsResult.data
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!userPermission) {
logger.warn(
`[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const { getBaseUrl } = await import('@/lib/core/utils/urls')
const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace`
const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}`
logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`)
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.FILE_DOWNLOADED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: fileRecord.name,
description: `Downloaded file "${fileRecord.name}"`,
metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size },
request,
})
captureServerEvent(
session.user.id,
'file_downloaded',
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({
success: true,
downloadUrl: serveUrl,
viewerUrl: viewerUrl,
fileName: fileRecord.name,
expiresIn: null,
})
} catch (error) {
logger.error(`[${requestId}] Error generating download URL:`, error)
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Failed to generate download URL'),
},
{ status: 500 }
)
}
}
)
@@ -0,0 +1,64 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreWorkspaceFileAPI')
export const POST = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
if (!paramsResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
{ status: 400 }
)
}
const { id: workspaceId, fileId } = paramsResult.data
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (userPermission !== 'admin' && userPermission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const result = await performRestoreWorkspaceFile({
workspaceId,
fileId,
userId: session.user.id,
})
if (!result.success) {
return NextResponse.json(
{ error: result.error },
{ status: result.errorCode === 'conflict' ? 409 : 500 }
)
}
logger.info(`[${requestId}] Restored workspace file ${fileId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Internal server error') },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,168 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
renameWorkspaceFileContract,
workspaceFileParamsSchema,
} from '@/lib/api/contracts/workspace-files'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
performDeleteWorkspaceFileItems,
performRenameWorkspaceFile,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceFileAPI')
/**
* PATCH /api/workspaces/[id]/files/[fileId]
* Rename a workspace file (requires write permission)
*/
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(renameWorkspaceFileContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const { name } = parsed.data.body
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const result = await performRenameWorkspaceFile({
workspaceId,
fileId,
name,
userId: session.user.id,
})
if (!result.success || !result.file) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: result.errorCode === 'conflict' ? 409 : 500 }
)
}
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`)
captureServerEvent(
session.user.id,
'file_renamed',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({
success: true,
file: result.file,
})
} catch (error) {
logger.error(`[${requestId}] Error renaming workspace file:`, error)
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Failed to rename file'),
},
{ status: 500 }
)
}
}
)
/**
* DELETE /api/workspaces/[id]/files/[fileId]
* Archive a workspace file (requires write permission)
*/
export const DELETE = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
const paramsResult = workspaceFileParamsSchema.safeParse(await params)
if (!paramsResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
{ status: 400 }
)
}
const { id: workspaceId, fileId } = paramsResult.data
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check workspace permissions (requires write)
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId: session.user.id,
fileIds: [fileId],
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'validation'
? 400
: result.errorCode === 'not_found'
? 404
: 500,
}
)
}
logger.info(`[${requestId}] Archived workspace file: ${fileId}`)
captureServerEvent(
session.user.id,
'file_deleted',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({
success: true,
})
} catch (error) {
logger.error(`[${requestId}] Error deleting workspace file:`, error)
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Failed to delete file'),
},
{ status: 500 }
)
}
}
)
@@ -0,0 +1,171 @@
/**
* @vitest-environment node
*/
import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } =
vi.hoisted(() => ({
mockGetWorkspaceFile: vi.fn(),
mockGetShareForResource: vi.fn(),
mockUpsertFileShare: vi.fn(),
mockValidateSharing: vi.fn(),
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({
getWorkspaceFile: mockGetWorkspaceFile,
}))
vi.mock('@/lib/public-shares/share-manager', () => {
class ShareValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'ShareValidationError'
}
}
return {
getShareForResource: mockGetShareForResource,
upsertFileShare: mockUpsertFileShare,
ShareValidationError,
}
})
vi.mock('@/ee/access-control/utils/permission-check', () => {
class PublicFileSharingNotAllowedError extends Error {
constructor() {
super('Public file sharing is not allowed based on your permission group settings')
this.name = 'PublicFileSharingNotAllowedError'
}
}
return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError }
})
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@sim/audit', () => auditMock)
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
const FILE_ID = 'wf_abc'
import { ShareValidationError } from '@/lib/public-shares/share-manager'
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
const putRequest = (body: unknown) =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const getRequest = () =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)
const SHARE = {
id: 'sh_1',
token: 'tok_1',
url: 'https://sim.ai/f/tok_1',
isActive: true,
resourceType: 'file' as const,
resourceId: FILE_ID,
}
describe('share route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
mockGetShareForResource.mockResolvedValue(SHARE)
mockUpsertFileShare.mockResolvedValue(SHARE)
mockValidateSharing.mockResolvedValue(undefined) // policy allows by default
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const res = await GET(getRequest(), params())
expect(res.status).toBe(401)
})
it('returns 403 when the caller has no workspace access', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
const res = await GET(getRequest(), params())
expect(res.status).toBe(403)
})
it('returns the share for a member', async () => {
const res = await GET(getRequest(), params())
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ share: SHARE })
})
})
describe('PUT', () => {
it('returns 403 for a read-only member', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(403)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})
it('maps a ShareValidationError to 400, not 500', async () => {
mockUpsertFileShare.mockRejectedValueOnce(
new ShareValidationError('Password is required for password-protected shares')
)
const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params())
expect(res.status).toBe(400)
expect((await res.json()).error).toBe('Password is required for password-protected shares')
})
it('returns 404 when the file is not in the workspace', async () => {
mockGetWorkspaceFile.mockResolvedValueOnce(null)
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(404)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})
it('enables the share for a writer', async () => {
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(200)
expect(mockUpsertFileShare).toHaveBeenCalledWith({
workspaceId: WS,
fileId: FILE_ID,
userId: 'user-1',
isActive: true,
})
expect(await res.json()).toEqual({ share: SHARE })
})
it('returns 403 when org access-control disables public sharing (enable)', async () => {
const { PublicFileSharingNotAllowedError } = await import(
'@/ee/access-control/utils/permission-check'
)
mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError())
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(403)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})
it('allows disabling a share even when policy disallows enabling', async () => {
mockValidateSharing.mockRejectedValue(new Error('should not be called for disable'))
const res = await PUT(putRequest({ isActive: false }), params())
expect(res.status).toBe(200)
expect(mockValidateSharing).not.toHaveBeenCalled()
expect(mockUpsertFileShare).toHaveBeenCalledWith({
workspaceId: WS,
fileId: FILE_ID,
userId: 'user-1',
isActive: false,
})
})
it('rejects a missing isActive body', async () => {
const res = await PUT(putRequest({}), params())
expect(res.status).toBe(400)
})
})
})
@@ -0,0 +1,158 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
getShareForResource,
ShareValidationError,
upsertFileShare,
} from '@/lib/public-shares/share-manager'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
PublicFileSharingNotAllowedError,
validatePublicFileSharing,
} from '@/ee/access-control/utils/permission-check'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceFileShareAPI')
/**
* GET /api/workspaces/[id]/files/[fileId]/share
* Fetch the public share state for a file (requires workspace membership).
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getFileShareContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission === null) {
logger.warn(
`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const file = await getWorkspaceFile(workspaceId, fileId)
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const share = await getShareForResource('file', fileId)
return NextResponse.json({ share })
} catch (error) {
logger.error(`[${requestId}] Error fetching file share:`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to fetch share') },
{
status: 500,
}
)
}
}
)
/**
* PUT /api/workspaces/[id]/files/[fileId]/share
* Enable or disable the public share for a file (requires write permission).
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(upsertFileShareContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const { isActive, authType, password, allowedEmails, token } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const file = await getWorkspaceFile(workspaceId, fileId)
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
// Enabling a share is gated by the org's access-control policy (both the
// master on/off and the per-auth-type allow-list); disabling is always
// allowed so users can still un-share after the policy is turned on.
if (isActive) {
try {
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
} catch (error) {
if (error instanceof PublicFileSharingNotAllowedError) {
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
return NextResponse.json({ error: error.message }, { status: 403 })
}
throw error
}
}
const share = await upsertFileShare({
workspaceId,
fileId,
userId: session.user.id,
isActive,
authType,
password,
allowedEmails,
token,
})
logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: file.name,
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
request,
})
return NextResponse.json({ share })
} catch (error) {
if (error instanceof ShareValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error updating file share:`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to update share') },
{
status: 500,
}
)
}
}
)
@@ -0,0 +1,90 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const logger = createLogger('WorkspaceFileStyleAPI')
/**
* GET /api/workspaces/[id]/files/[fileId]/style
* Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file.
* OOXML files return theme colors, font pair, and named styles.
* PDF files return page dimensions and embedded font names.
*/
const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(workspaceFileStyleContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!membership) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const rawExt = fileRecord.name.split('.').pop()?.toLowerCase()
if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') {
return NextResponse.json(
{ error: 'Style extraction supports .docx, .pptx, and .pdf files' },
{ status: 422 }
)
}
const ext: 'docx' | 'pptx' | 'pdf' = rawExt
if (fileRecord.size > MAX_STYLE_FILE_BYTES) {
return NextResponse.json(
{ error: 'File is too large for style extraction (limit: 100 MB)' },
{ status: 422 }
)
}
let buffer: Buffer
try {
buffer = await fetchWorkspaceFileBuffer(fileRecord)
} catch (err) {
logger.error('Failed to download file for style extraction', {
fileId,
error: toError(err).message,
})
return NextResponse.json({ error: 'Failed to read file' }, { status: 500 })
}
const summary = await extractDocumentStyle(buffer, ext)
if (!summary) {
return NextResponse.json(
{
error:
'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information',
},
{ status: 422 }
)
}
logger.info('Extracted style summary via API', { fileId, format: ext })
return NextResponse.json(summary, {
headers: { 'Cache-Control': 'private, max-age=300' },
})
}
)
@@ -0,0 +1,70 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileBulkArchiveAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { fileIds, folderIds } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId: session.user.id,
fileIds,
folderIds,
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'validation'
? 400
: result.errorCode === 'not_found'
? 404
: 500,
}
)
}
if (!result.deletedItems) {
return NextResponse.json(
{ success: false, error: 'Failed to delete workspace file items' },
{ status: 500 }
)
}
captureServerEvent(
session.user.id,
'file_bulk_deleted',
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, deletedItems: result.deletedItems })
} catch (error) {
logger.error('Failed to bulk archive workspace file items:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,169 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { type NextRequest, NextResponse } from 'next/server'
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
buildWorkspaceFileFolderPathMap,
fetchWorkspaceFileBuffer,
listWorkspaceFileFolders,
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkspaceFilesDownloadAPI')
const MAX_ZIP_DOWNLOAD_FILES = 100
const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024
function safeZipPath(path: string): string {
return path
.split('/')
.map((segment) => {
const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_')
return cleaned === '.' || cleaned === '..' ? '_' : cleaned
})
.filter(Boolean)
.join('/')
}
function withZipPathSuffix(path: string, suffix: number): string {
const slashIndex = path.lastIndexOf('/')
const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : ''
const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path
const dotIndex = filename.lastIndexOf('.')
return dotIndex > 0
? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}`
: `${directory}${filename} (${suffix})`
}
function collectDescendantFolderIds(
selectedFolderIds: string[],
folders: Array<{ id: string; parentId: string | null }>
): Set<string> {
const folderIds = new Set(selectedFolderIds)
let changed = true
while (changed) {
changed = false
for (const folder of folders) {
if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) {
folderIds.add(folder.id)
changed = true
}
}
}
return folderIds
}
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { fileIds, folderIds } = parsed.data.query
const permission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const [files, folders] = await Promise.all([
listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }),
listWorkspaceFileFolders(workspaceId),
])
const folderPaths = buildWorkspaceFileFolderPathMap(folders)
const selectedFolderIds = collectDescendantFolderIds(folderIds, folders)
const requestedFileIds = new Set(fileIds)
const filesToZip = files.filter(
(file) =>
requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId))
)
if (filesToZip.length === 0) {
return NextResponse.json({ error: 'No files selected for download' }, { status: 400 })
}
if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) {
return NextResponse.json(
{
error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`,
},
{ status: 400 }
)
}
const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) {
return NextResponse.json(
{
error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`,
},
{ status: 400 }
)
}
const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file)))
// Assemble zip synchronously so path deduplication is deterministic.
const zip = new JSZip()
const usedPaths = new Set<string>()
for (let i = 0; i < filesToZip.length; i++) {
const file = filesToZip[i]
const buffer = buffers[i]
const folderPath = file.folderId ? folderPaths.get(file.folderId) : null
const basePath =
safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) ||
safeZipPath(file.name) ||
file.id
let zipPath = basePath
let suffix = 2
while (usedPaths.has(zipPath)) {
zipPath = withZipPathSuffix(basePath, suffix)
suffix++
}
usedPaths.add(zipPath)
zip.file(zipPath, buffer)
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.FILE_DOWNLOADED,
resourceType: AuditResourceType.FILE,
description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`,
metadata: { fileCount: filesToZip.length, totalBytes },
request,
})
captureServerEvent(
session.user.id,
'file_downloaded',
{ workspace_id: workspaceId, is_bulk: true, file_count: filesToZip.length },
{ groups: { workspace: workspaceId } }
)
return new NextResponse(new Uint8Array(zipBuffer), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="workspace-files.zip"',
'Cache-Control': 'no-store',
},
})
} catch (error) {
logger.error('Failed to download workspace file selection:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,66 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
performRestoreWorkspaceFileFolder,
workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFolderRestoreAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, folderId } = parsed.data.params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performRestoreWorkspaceFileFolder({
workspaceId,
folderId,
userId: session.user.id,
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
)
}
const { folder, restoredItems } = result
if (!folder || !restoredItems) {
return NextResponse.json(
{ success: false, error: 'Failed to restore workspace file folder' },
{ status: 500 }
)
}
logger.info(`Restored workspace file folder: ${folderId}`)
captureServerEvent(
session.user.id,
'folder_restored',
{ folder_id: folderId, workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, folder, restoredItems })
} catch (error) {
logger.error('Failed to restore workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,121 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteWorkspaceFileFolderContract,
updateWorkspaceFileFolderContract,
} from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
performDeleteWorkspaceFileItems,
performUpdateWorkspaceFileFolder,
workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFolderAPI')
async function assertWritePermission(userId: string, workspaceId: string) {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
return permission === 'admin' || permission === 'write'
}
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, folderId } = parsed.data.params
if (!(await assertWritePermission(session.user.id, workspaceId))) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performUpdateWorkspaceFileFolder({
workspaceId,
folderId,
userId: session.user.id,
...parsed.data.body,
})
if (!result.success || !result.folder) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
)
}
captureServerEvent(
session.user.id,
'folder_renamed',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, folder: result.folder })
} catch (error) {
logger.error('Failed to update workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, folderId } = parsed.data.params
if (!(await assertWritePermission(session.user.id, workspaceId))) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId: session.user.id,
folderIds: [folderId],
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'validation'
? 400
: result.errorCode === 'not_found'
? 404
: 500,
}
)
}
if (!result.deletedItems) {
return NextResponse.json(
{ success: false, error: 'Failed to delete workspace file folder' },
{ status: 500 }
)
}
captureServerEvent(
session.user.id,
'folder_deleted',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, deletedItems: result.deletedItems })
} catch (error) {
logger.error('Failed to delete workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,88 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
createWorkspaceFileFolderContract,
listWorkspaceFileFoldersContract,
} from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace'
import {
performCreateWorkspaceFileFolder,
workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFoldersAPI')
async function getWorkspacePermission(userId: string, workspaceId: string) {
return getUserEntityPermissions(userId, 'workspace', workspaceId)
}
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { scope } = parsed.data.query
const permission = await getWorkspacePermission(session.user.id, workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const folders = await listWorkspaceFileFolders(workspaceId, { scope })
return NextResponse.json({ success: true, folders })
}
)
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { name, parentId } = parsed.data.body
const permission = await getWorkspacePermission(session.user.id, workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performCreateWorkspaceFileFolder({
workspaceId,
userId: session.user.id,
name,
parentId,
})
if (!result.success || !result.folder) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
)
}
captureServerEvent(
session.user.id,
'folder_created',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, folder: result.folder })
} catch (error) {
logger.error('Failed to create workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,77 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockGetPerms: vi.fn(),
mockResolveImage: vi.fn(),
mockDownloadFile: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms }))
vi.mock('@/lib/uploads/server/inline-image', () => ({
resolveWorkspaceInlineImage: mockResolveImage,
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
import { GET } from '@/app/api/workspaces/[id]/files/inline/route'
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
const params = { params: Promise.resolve({ id: 'ws-1' }) }
const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`)
describe('GET /api/workspaces/[id]/files/inline', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
mockGetPerms.mockResolvedValue('read')
mockResolveImage.mockResolvedValue({
key: 'workspace/ws-1/x-photo.png',
contentType: 'image/png',
filename: 'photo.png',
})
mockDownloadFile.mockResolvedValue(PNG)
})
it('serves a workspace-scoped image by fileId', async () => {
const res = await GET(req('fileId=wf_abc'), params)
expect(res.status).toBe(200)
expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' })
})
it('serves a workspace-scoped image by key', async () => {
const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params)
expect(res.status).toBe(200)
})
it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => {
mockResolveImage.mockResolvedValue(null)
const res = await GET(req('fileId=wf_other'), params)
expect(res.status).toBe(404)
})
it('404s without workspace membership, before resolving the file', async () => {
mockGetPerms.mockResolvedValue(null)
const res = await GET(req('fileId=wf_abc'), params)
expect(res.status).toBe(404)
expect(mockResolveImage).not.toHaveBeenCalled()
})
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null)
const res = await GET(req('fileId=wf_abc'), params)
expect(res.status).toBe(401)
})
it('400s when neither key nor fileId is provided', async () => {
const res = await GET(req(''), params)
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,59 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceInlineFileAPI')
/**
* GET /api/workspaces/[id]/files/inline?key=<cloudKey>|fileId=<id>
*
* Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**.
* The markdown editor rewrites its embedded `/api/files/serve/<key>` and `/api/files/view/<id>` srcs to
* this route so a referenced file resolves only within the document's workspace — a cross-workspace
* reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read
* access to the workspace is required; disposition/content-type handling mirrors the serve route.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
try {
const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const ref = parsed.data.query
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Authorize before disclosing anything; deny with 404 so a non-member can't probe existence.
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission) {
throw new FileNotFoundError('Not found')
}
const image = await resolveWorkspaceInlineImage(workspaceId, ref)
if (!image) {
throw new FileNotFoundError('Not found')
}
return await serveInlineImage(image, { sniff: false })
} catch (error) {
if (error instanceof FileNotFoundError) {
return createErrorResponse(error)
}
logger.error('Error serving workspace inline image:', error)
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
}
}
)
@@ -0,0 +1,78 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileMoveAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { fileIds, folderIds, targetFolderId } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performMoveWorkspaceFileItems({
workspaceId,
userId: session.user.id,
fileIds,
folderIds,
targetFolderId,
})
if (!result.success || !result.movedItems) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'conflict'
? 409
: result.errorCode === 'not_found'
? 404
: result.errorCode === 'validation'
? 400
: 500,
}
)
}
if (fileIds.length > 0) {
captureServerEvent(
session.user.id,
'file_moved',
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
{ groups: { workspace: workspaceId } }
)
}
if (folderIds.length > 0) {
captureServerEvent(
session.user.id,
'folder_moved',
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
{ groups: { workspace: workspaceId } }
)
}
return NextResponse.json({
success: true,
movedItems: result.movedItems,
})
} catch (error) {
logger.error('Failed to move workspace file items:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import {
authMockFns,
permissionsMock,
permissionsMockFns,
storageServiceMock,
storageServiceMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckStorageQuota, mockGenerateWorkspaceFileKey, mockUseBlobStorage } = vi.hoisted(
() => ({
mockCheckStorageQuota: vi.fn(),
mockGenerateWorkspaceFileKey: vi.fn(),
mockUseBlobStorage: { value: false },
})
)
vi.mock('@/lib/billing/storage', () => ({
checkStorageQuota: mockCheckStorageQuota,
}))
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
}))
vi.mock('@/lib/uploads/config', () => ({
get USE_BLOB_STORAGE() {
return mockUseBlobStorage.value
},
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
import { POST } from '@/app/api/workspaces/[id]/files/presigned/route'
const params = (id = WS) => ({ params: Promise.resolve({ id }) })
const makeRequest = (body: unknown) =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const validBody = {
fileName: 'video.mp4',
contentType: 'video/mp4',
fileSize: 10 * 1024 * 1024,
}
describe('POST /api/workspaces/[id]/files/presigned', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`)
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({
url: 'https://s3/presigned',
key: `workspace/${WS}/123-abc-video.mp4`,
uploadHeaders: { 'Content-Type': 'video/mp4' },
})
})
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const res = await POST(makeRequest(validBody), params())
expect(res.status).toBe(401)
})
it('returns 403 when user has read-only permission', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
const res = await POST(makeRequest(validBody), params())
expect(res.status).toBe(403)
})
it('returns 400 for missing fileName', async () => {
const res = await POST(makeRequest({ ...validBody, fileName: '' }), params())
expect(res.status).toBe(400)
})
it('returns 400 for negative fileSize', async () => {
const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params())
expect(res.status).toBe(400)
})
it('accepts fileSize === 0 (empty new files)', async () => {
const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params())
expect(res.status).toBe(200)
})
it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => {
const res = await POST(
makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }),
params()
)
expect(res.status).toBe(413)
})
it('returns 413 when storage quota would be exceeded', async () => {
mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' })
const res = await POST(makeRequest(validBody), params())
const body = await res.json()
expect(res.status).toBe(413)
expect(body.error).toBe('Over quota')
})
it('returns local fallback signal when cloud storage is not configured', async () => {
storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false)
const res = await POST(makeRequest(validBody), params())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.directUploadSupported).toBe(false)
expect(body.presignedUrl).toBe('')
expect(body.fileInfo.name).toBe('video.mp4')
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled()
})
it('issues a presigned URL bound to the workspace', async () => {
const res = await POST(makeRequest(validBody), params())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.directUploadSupported).toBe(true)
expect(body.presignedUrl).toBe('https://s3/presigned')
expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`)
expect(body.fileInfo.path).toContain('?context=workspace')
expect(body.fileInfo.path).toContain('s3')
expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' })
expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4')
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith(
expect.objectContaining({
context: 'workspace',
userId: 'user-1',
customKey: `workspace/${WS}/123-abc-video.mp4`,
metadata: { workspaceId: WS },
})
)
})
it('serves blob path when blob storage is configured', async () => {
mockUseBlobStorage.value = true
try {
const res = await POST(makeRequest(validBody), params())
const body = await res.json()
expect(body.fileInfo.path).toContain('/blob/')
} finally {
mockUseBlobStorage.value = false
}
})
})
@@ -0,0 +1,109 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { checkStorageQuota } from '@/lib/billing/storage'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspacePresignedAPI')
/**
* POST /api/workspaces/[id]/files/presigned
* Returns a presigned PUT URL for a workspace-scoped object key. The client
* uploads the bytes directly to S3/Blob, then calls /files/register to
* insert metadata.
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const parsed = await parseRequest(workspacePresignedUploadContract, request, context)
if (!parsed.success) return parsed.response
const { params, body } = parsed.data
const workspaceId = params.id
const { fileName, contentType, fileSize, folderId } = body
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (fileSize > MAX_WORKSPACE_FILE_SIZE) {
return NextResponse.json(
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
{ status: 413 }
)
}
let targetFolderId: string | null
try {
targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId)
} catch (error) {
return NextResponse.json(
{ error: getErrorMessage(error, 'Invalid target folder') },
{ status: 400 }
)
}
if (!hasCloudStorage()) {
logger.info(`Local storage detected, signaling API fallback for ${fileName}`)
return NextResponse.json({
fileName,
presignedUrl: '',
fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType },
directUploadSupported: false,
})
}
const quotaCheck = await checkStorageQuota(userId, fileSize)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
{ status: 413 }
)
}
const key = generateWorkspaceFileKey(workspaceId, fileName)
const presigned = await generatePresignedUploadUrl({
fileName,
contentType,
fileSize,
context: 'workspace',
userId,
customKey: key,
expirationSeconds: 3600,
metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) },
})
const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace`
logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`)
return NextResponse.json({
fileName,
presignedUrl: presigned.url,
fileInfo: {
path: finalPath,
key: presigned.key,
name: fileName,
size: fileSize,
type: contentType,
},
uploadHeaders: presigned.uploadHeaders,
directUploadSupported: true,
})
}
)
@@ -0,0 +1,171 @@
/**
* @vitest-environment node
*/
import {
auditMock,
auditMockFns,
authMockFns,
permissionsMock,
permissionsMockFns,
posthogServerMock,
posthogServerMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRegisterUploadedWorkspaceFile, mockParseWorkspaceFileKey, FileConflictErrorImpl } =
vi.hoisted(() => {
class FileConflictErrorImpl extends Error {
constructor(message: string) {
super(message)
this.name = 'FileConflictError'
}
}
return {
mockRegisterUploadedWorkspaceFile: vi.fn(),
mockParseWorkspaceFileKey: vi.fn(),
FileConflictErrorImpl,
}
})
vi.mock('@/lib/uploads/contexts/workspace', () => ({
registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile,
parseWorkspaceFileKey: mockParseWorkspaceFileKey,
FileConflictError: FileConflictErrorImpl,
}))
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@sim/audit', () => auditMock)
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
const VALID_KEY = `workspace/${WS}/123-abc-video.mp4`
import { POST } from '@/app/api/workspaces/[id]/files/register/route'
const params = (id = WS) => ({ params: Promise.resolve({ id }) })
const makeRequest = (body: unknown) =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const validBody = {
key: VALID_KEY,
name: 'video.mp4',
contentType: 'video/mp4',
}
describe('POST /api/workspaces/[id]/files/register', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockParseWorkspaceFileKey.mockImplementation((key: string) => {
const match = key.match(/^workspace\/([^/]+)\//)
return match ? match[1] : null
})
mockRegisterUploadedWorkspaceFile.mockResolvedValue({
file: {
id: 'wf_123',
name: 'video.mp4',
size: 10 * 1024 * 1024,
type: 'video/mp4',
url: '/api/files/serve/...',
key: VALID_KEY,
context: 'workspace',
},
created: true,
})
})
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const res = await POST(makeRequest(validBody), params())
expect(res.status).toBe(401)
})
it('returns 403 when user lacks write permission', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
const res = await POST(makeRequest(validBody), params())
expect(res.status).toBe(403)
})
it('rejects keys belonging to a different workspace', async () => {
const otherWsKey = `workspace/00000000-0000-0000-0000-000000000000/123-abc-video.mp4`
const res = await POST(makeRequest({ ...validBody, key: otherWsKey }), params())
const body = await res.json()
expect(res.status).toBe(400)
expect(body.error).toContain('does not belong')
expect(mockRegisterUploadedWorkspaceFile).not.toHaveBeenCalled()
})
it('returns 400 for empty key/name', async () => {
const res = await POST(makeRequest({ ...validBody, key: '' }), params())
expect(res.status).toBe(400)
})
it('returns 404 when storage object is missing', async () => {
mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(
new Error('Uploaded object not found in storage')
)
const res = await POST(makeRequest(validBody), params())
expect(res.status).toBe(404)
})
it('returns 409 on duplicate file conflict', async () => {
mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(new FileConflictErrorImpl('video.mp4'))
const res = await POST(makeRequest(validBody), params())
const body = await res.json()
expect(res.status).toBe(409)
expect(body.isDuplicate).toBe(true)
})
it('skips audit + analytics on idempotent re-register (created=false)', async () => {
mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({
file: {
id: 'wf_123',
name: 'video.mp4',
size: 10 * 1024 * 1024,
type: 'video/mp4',
url: '/api/files/serve/...',
key: VALID_KEY,
context: 'workspace',
},
created: false,
})
const res = await POST(makeRequest(validBody), params())
expect(res.status).toBe(200)
expect(posthogServerMockFns.mockCaptureServerEvent).not.toHaveBeenCalled()
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
})
it('finalizes upload, records audit and analytics', async () => {
const res = await POST(makeRequest(validBody), params())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.success).toBe(true)
expect(body.file).toMatchObject({ id: 'wf_123', key: VALID_KEY })
expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith({
workspaceId: WS,
userId: 'user-1',
key: VALID_KEY,
originalName: 'video.mp4',
contentType: 'video/mp4',
})
expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith(
'user-1',
'file_uploaded',
expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }),
expect.any(Object)
)
})
})
@@ -0,0 +1,103 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
FileConflictError,
parseWorkspaceFileKey,
registerUploadedWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceRegisterAPI')
/**
* POST /api/workspaces/[id]/files/register
* Finalize a direct-to-storage upload by inserting metadata, updating quota,
* and recording an audit log. Validates the storage key belongs to the
* caller's workspace to prevent cross-tenant key smuggling.
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const parsed = await parseRequest(registerWorkspaceFileContract, request, context)
if (!parsed.success) return parsed.response
const { params, body } = parsed.data
const workspaceId = params.id
const { key, name, contentType, folderId } = body
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (parseWorkspaceFileKey(key) !== workspaceId) {
logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`)
return NextResponse.json(
{ error: 'Storage key does not belong to this workspace' },
{ status: 400 }
)
}
try {
const { file: userFile, created } = await registerUploadedWorkspaceFile({
workspaceId,
userId,
key,
originalName: name,
contentType,
folderId,
})
if (created) {
logger.info(`Registered direct upload ${name} -> ${key}`)
captureServerEvent(
userId,
'file_uploaded',
{ workspace_id: workspaceId, file_type: contentType },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: userId,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: name,
description: `Uploaded file "${name}"`,
metadata: { fileSize: userFile.size, fileType: contentType },
request,
})
} else {
logger.info(`Idempotent re-register for existing upload ${name} -> ${key}`)
}
return NextResponse.json({ success: true, file: userFile })
} catch (error) {
logger.error('Failed to register workspace file:', error)
const errorMessage = getErrorMessage(error, 'Failed to register file')
const isDuplicate =
error instanceof FileConflictError || errorMessage.includes('already exists')
const isMissing = errorMessage.includes('not found in storage')
const status = isDuplicate ? 409 : isMissing ? 404 : 500
return NextResponse.json({ success: false, error: errorMessage, isDuplicate }, { status })
}
}
)
@@ -0,0 +1,143 @@
/**
* Tests for the workspace files upload route's bounded multipart read.
*
* @vitest-environment node
*/
import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
mockUploadWorkspaceFile: vi.fn(),
mockGetSharesForResources: vi.fn(),
mockRecordAudit: vi.fn(),
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({
uploadWorkspaceFile: mockUploadWorkspaceFile,
FileConflictError: class FileConflictError extends Error {},
}))
vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/uploads/shared/types')>()
return {
...actual,
MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
}
})
vi.mock('@/lib/public-shares/share-manager', () => ({
getSharesForResources: mockGetSharesForResources,
}))
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/app/api/workflows/utils', () => ({
verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'),
}))
vi.mock('@sim/audit', () => ({
recordAudit: mockRecordAudit,
AuditAction: { FILE_UPLOADED: 'file_uploaded' },
AuditResourceType: { FILE: 'file' },
}))
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
import { POST } from '@/app/api/workspaces/[id]/files/route'
const routeContext = { params: Promise.resolve({ id: WS }) }
function buildFormData(file: File): FormData {
const formData = new FormData()
formData.append('file', file)
return formData
}
/**
* Builds a pull-based stream that emits fixed-size chunks on demand, so the
* size-capped reader's `reader.cancel()` simply stops future `pull` calls
* instead of racing an external (e.g. undici FormData) chunk producer.
*/
function makeChunkedOverLimitBody(
chunkBytes: number,
chunkCount: number
): ReadableStream<Uint8Array> {
let emitted = 0
return new ReadableStream<Uint8Array>({
pull(controller) {
if (emitted >= chunkCount) {
controller.close()
return
}
emitted++
controller.enqueue(new Uint8Array(chunkBytes))
},
})
}
describe('workspace files upload route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockGetSharesForResources.mockResolvedValue(new Map())
mockUploadWorkspaceFile.mockResolvedValue({
id: 'file-1',
name: 'file.txt',
url: 'https://example.com/file.txt',
size: 11,
type: 'text/plain',
})
})
it('rejects a declared content-length above the limit before reading the body', async () => {
const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
method: 'POST',
headers: { 'content-length': String(10 * 1024 * 1024) },
body: formData,
})
const response = await POST(req, routeContext)
const data = await response.json()
expect(response.status).toBe(413)
expect(data.error).toContain('exceeds maximum size')
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
const body = makeChunkedOverLimitBody(64 * 1024, 32)
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
method: 'POST',
body,
// @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
duplex: 'half',
})
expect(req.headers.get('content-length')).toBeNull()
const response = await POST(req, routeContext)
const data = await response.json()
expect(response.status).toBe(413)
expect(data.error).toContain('exceeds maximum size')
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('uploads a normal, well-under-limit file successfully', async () => {
const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
const formData = buildFormData(file)
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
method: 'POST',
headers: { 'content-length': '512' },
body: formData,
})
const response = await POST(req, routeContext)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,230 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
listWorkspaceFilesQuerySchema,
workspaceFilesParamsSchema,
} from '@/lib/api/contracts/workspace-files'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import {
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getSharesForResources } from '@/lib/public-shares/share-manager'
import {
FileConflictError,
listWorkspaceFiles,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceFilesAPI')
/**
* GET /api/workspaces/[id]/files
* List all files for a workspace (requires read permission)
*/
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const paramsResult = workspaceFilesParamsSchema.safeParse(await params)
if (!paramsResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
{ status: 400 }
)
}
const { id: workspaceId } = paramsResult.data
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check workspace permissions (requires read)
const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!userPermission) {
logger.warn(
`[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const queryResult = listWorkspaceFilesQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries())
)
if (!queryResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(queryResult.error, 'Invalid scope') },
{ status: 400 }
)
}
const { scope } = queryResult.data
const files = await listWorkspaceFiles(workspaceId, { scope })
const shares = await getSharesForResources(
'file',
files.map((file) => file.id)
)
const filesWithShares = files.map((file) => ({
...file,
share: shares.get(file.id) ?? null,
}))
logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`)
return NextResponse.json({
success: true,
files: filesWithShares,
})
} catch (error) {
logger.error(`[${requestId}] Error listing workspace files:`, error)
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Failed to list files'),
},
{ status: 500 }
)
}
}
)
/**
* POST /api/workspaces/[id]/files
* Upload a new file to workspace storage (requires write permission)
*/
export const POST = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const paramsResult = workspaceFilesParamsSchema.safeParse(await params)
if (!paramsResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
{ status: 400 }
)
}
const { id: workspaceId } = paramsResult.data
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check workspace permissions (requires write)
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
let formData: FormData
try {
formData = await readFormDataWithLimit(request, {
maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
label: 'workspace file upload body',
})
} catch (error) {
if (isPayloadSizeLimitError(error)) {
return NextResponse.json({ error: error.message }, { status: 413 })
}
return NextResponse.json(
{ error: 'Request body must be valid multipart form data' },
{ status: 400 }
)
}
const rawFile = formData.get('file')
const rawFolderId = formData.get('folderId')
const folderId =
typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null
if (!rawFile || !(rawFile instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const fileName = rawFile.name || 'untitled.md'
if (rawFile.size > MAX_WORKSPACE_FORMDATA_FILE_SIZE) {
return NextResponse.json(
{
error: `File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`,
},
{ status: 413 }
)
}
const buffer = Buffer.from(await rawFile.arrayBuffer())
const userFile = await uploadWorkspaceFile(
workspaceId,
session.user.id,
buffer,
fileName,
rawFile.type || 'application/octet-stream',
{ folderId }
)
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
captureServerEvent(
session.user.id,
'file_uploaded',
{ workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: fileName,
description: `Uploaded file "${fileName}"`,
metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' },
request,
})
return NextResponse.json({
success: true,
file: userFile,
})
} catch (error) {
logger.error(`[${requestId}] Error uploading workspace file:`, error)
const errorMessage = getErrorMessage(error, 'Failed to upload file')
const isDuplicate =
error instanceof FileConflictError || errorMessage.includes('already exists')
return NextResponse.json(
{
success: false,
error: errorMessage,
isDuplicate,
},
{ status: isDuplicate ? 409 : 500 }
)
}
}
)