Skip to content

Commit b9f4533

Browse files
committed
fix(uploads): add Zod contracts for workspace presigned/register routes
1 parent 8afc02a commit b9f4533

7 files changed

Lines changed: 99 additions & 65 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/presigned/route.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files'
4+
import { parseRequest } from '@/lib/api/server'
35
import { getSession } from '@/lib/auth'
46
import { checkStorageQuota } from '@/lib/billing/storage'
57
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -11,51 +13,32 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1113

1214
const logger = createLogger('WorkspacePresignedAPI')
1315

14-
interface WorkspacePresignedRequest {
15-
fileName: string
16-
contentType: string
17-
fileSize: number
18-
}
19-
2016
/**
2117
* POST /api/workspaces/[id]/files/presigned
2218
* Returns a presigned PUT URL for a workspace-scoped object key. The client
2319
* uploads the bytes directly to S3/Blob, then calls /files/register to
2420
* insert metadata.
2521
*/
2622
export const POST = withRouteHandler(
27-
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
28-
const { id: workspaceId } = await params
29-
23+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
3024
const session = await getSession()
3125
if (!session?.user?.id) {
3226
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3327
}
3428
const userId = session.user.id
3529

30+
const parsed = await parseRequest(workspacePresignedUploadContract, request, context)
31+
if (!parsed.success) return parsed.response
32+
const { params, body } = parsed.data
33+
const workspaceId = params.id
34+
const { fileName, contentType, fileSize } = body
35+
3636
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
3737
if (permission !== 'admin' && permission !== 'write') {
3838
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
3939
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
4040
}
4141

42-
let body: WorkspacePresignedRequest
43-
try {
44-
body = (await request.json()) as WorkspacePresignedRequest
45-
} catch {
46-
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
47-
}
48-
49-
const { fileName, contentType, fileSize } = body
50-
if (!fileName?.trim()) {
51-
return NextResponse.json({ error: 'fileName is required' }, { status: 400 })
52-
}
53-
if (!contentType?.trim()) {
54-
return NextResponse.json({ error: 'contentType is required' }, { status: 400 })
55-
}
56-
if (typeof fileSize !== 'number' || !Number.isFinite(fileSize) || fileSize < 0) {
57-
return NextResponse.json({ error: 'fileSize must be a non-negative number' }, { status: 400 })
58-
}
5942
if (fileSize > MAX_WORKSPACE_FILE_SIZE) {
6043
return NextResponse.json(
6144
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },

apps/sim/app/api/workspaces/[id]/files/register/route.ts

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
33
import { type NextRequest, NextResponse } from 'next/server'
4+
import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
5+
import { parseRequest } from '@/lib/api/server'
46
import { getSession } from '@/lib/auth'
57
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
68
import { captureServerEvent } from '@/lib/posthog/server'
@@ -13,56 +15,32 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1315

1416
const logger = createLogger('WorkspaceRegisterAPI')
1517

16-
interface RegisterRequestBody {
17-
key: string
18-
name: string
19-
size: number
20-
contentType: string
21-
}
22-
2318
/**
2419
* POST /api/workspaces/[id]/files/register
2520
* Finalize a direct-to-storage upload by inserting metadata, updating quota,
2621
* and recording an audit log. Validates the storage key belongs to the
2722
* caller's workspace to prevent cross-tenant key smuggling.
2823
*/
2924
export const POST = withRouteHandler(
30-
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
31-
const { id: workspaceId } = await params
32-
25+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
3326
const session = await getSession()
3427
if (!session?.user?.id) {
3528
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3629
}
3730
const userId = session.user.id
3831

32+
const parsed = await parseRequest(registerWorkspaceFileContract, request, context)
33+
if (!parsed.success) return parsed.response
34+
const { params, body } = parsed.data
35+
const workspaceId = params.id
36+
const { key, name, size, contentType } = body
37+
3938
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
4039
if (permission !== 'admin' && permission !== 'write') {
4140
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
4241
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
4342
}
4443

45-
let body: RegisterRequestBody
46-
try {
47-
body = (await request.json()) as RegisterRequestBody
48-
} catch {
49-
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
50-
}
51-
52-
const { key, name, size, contentType } = body
53-
if (!key?.trim()) {
54-
return NextResponse.json({ error: 'key is required' }, { status: 400 })
55-
}
56-
if (!name?.trim()) {
57-
return NextResponse.json({ error: 'name is required' }, { status: 400 })
58-
}
59-
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) {
60-
return NextResponse.json({ error: 'size must be a non-negative number' }, { status: 400 })
61-
}
62-
if (!contentType?.trim()) {
63-
return NextResponse.json({ error: 'contentType is required' }, { status: 400 })
64-
}
65-
6644
if (parseWorkspaceFileKey(key) !== workspaceId) {
6745
logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`)
6846
return NextResponse.json(

apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
353353

354354
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))
355355

356+
// boundary-raw-fetch: bulk document-processing kickoff with dynamic recipe payload; response is consumed alongside the upload progress lifecycle and not modeled by a single contract
356357
const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
357358
method: 'POST',
358359
headers: { 'Content-Type': 'application/json' },

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits'
77
import {
88
deleteWorkspaceFileContract,
99
listWorkspaceFilesContract,
10+
registerWorkspaceFileContract,
1011
renameWorkspaceFileContract,
1112
restoreWorkspaceFileContract,
1213
updateWorkspaceFileContentContract,
@@ -217,6 +218,7 @@ async function uploadViaApiFallback(
217218
const formData = new FormData()
218219
formData.append('file', file)
219220

221+
// boundary-raw-fetch: multipart/form-data fallback upload, requestJson only supports JSON bodies
220222
const response = await fetch(`/api/workspaces/${workspaceId}/files`, {
221223
method: 'POST',
222224
body: formData,
@@ -264,19 +266,21 @@ async function uploadWorkspaceFile(
264266
throw error
265267
}
266268

267-
const registerResponse = await fetch(`/api/workspaces/${workspaceId}/files/register`, {
268-
method: 'POST',
269-
headers: { 'Content-Type': 'application/json' },
270-
body: JSON.stringify({
269+
const data = await requestJson(registerWorkspaceFileContract, {
270+
params: { id: workspaceId },
271+
body: {
271272
key: result.key,
272273
name: result.name,
273274
size: result.size,
274275
contentType: result.contentType,
275-
}),
276+
},
276277
signal,
277278
})
278279

279-
return parseUploadResponse(registerResponse, 'Failed to register file')
280+
if (!data.success || !data.file) {
281+
throw new Error(data.error || 'Failed to register file')
282+
}
283+
return { success: true, file: data.file }
280284
}
281285

282286
export function useUploadWorkspaceFile() {

apps/sim/lib/api/contracts/workspace-files.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,67 @@ export const workspaceFileCompiledCheckContract = defineRouteContract({
143143
schema: compiledCheckResponseSchema,
144144
},
145145
})
146+
147+
export const workspacePresignedUploadBodySchema = z.object({
148+
fileName: z.string().min(1, 'fileName is required'),
149+
contentType: z.string().min(1, 'contentType is required'),
150+
fileSize: z.number().nonnegative('fileSize must be a non-negative number'),
151+
})
152+
153+
export type WorkspacePresignedUploadBody = z.input<typeof workspacePresignedUploadBodySchema>
154+
155+
const workspacePresignedFileInfoSchema = z.object({
156+
path: z.string(),
157+
key: z.string(),
158+
name: z.string(),
159+
size: z.number(),
160+
type: z.string(),
161+
})
162+
163+
const workspacePresignedUploadResponseSchema = z.object({
164+
fileName: z.string(),
165+
presignedUrl: z.string(),
166+
fileInfo: workspacePresignedFileInfoSchema,
167+
uploadHeaders: z.record(z.string(), z.string()).optional(),
168+
directUploadSupported: z.boolean(),
169+
})
170+
171+
export const workspacePresignedUploadContract = defineRouteContract({
172+
method: 'POST',
173+
path: '/api/workspaces/[id]/files/presigned',
174+
params: workspaceFilesParamsSchema,
175+
body: workspacePresignedUploadBodySchema,
176+
response: {
177+
mode: 'json',
178+
schema: workspacePresignedUploadResponseSchema,
179+
},
180+
})
181+
182+
export const registerWorkspaceFileBodySchema = z.object({
183+
key: z.string().min(1, 'key is required'),
184+
name: z.string().min(1, 'name is required'),
185+
size: z.number().nonnegative('size must be a non-negative number'),
186+
contentType: z.string().min(1, 'contentType is required'),
187+
})
188+
189+
export type RegisterWorkspaceFileBody = z.input<typeof registerWorkspaceFileBodySchema>
190+
191+
const registerWorkspaceFileResponseSchema = z.object({
192+
success: z.boolean(),
193+
file: workspaceFileRecordSchema.optional(),
194+
error: z.string().optional(),
195+
isDuplicate: z.boolean().optional(),
196+
})
197+
198+
export type RegisterWorkspaceFileResponse = z.output<typeof registerWorkspaceFileResponseSchema>
199+
200+
export const registerWorkspaceFileContract = defineRouteContract({
201+
method: 'POST',
202+
path: '/api/workspaces/[id]/files/register',
203+
params: workspaceFilesParamsSchema,
204+
body: registerWorkspaceFileBodySchema,
205+
response: {
206+
mode: 'json',
207+
schema: registerWorkspaceFileResponseSchema,
208+
},
209+
})

apps/sim/lib/uploads/client/direct-upload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ const uploadViaMultipart = async (
300300
): Promise<{ key: string; path: string }> => {
301301
const { file, workspaceId, context, signal, onProgress } = opts
302302

303+
// boundary-raw-fetch: multipart upload control plane uses action query strings; client lifecycle (initiate/get-part-urls/complete/abort) is sequenced manually and not modeled by a single contract
303304
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
304305
method: 'POST',
305306
headers: { 'Content-Type': 'application/json' },
@@ -346,6 +347,7 @@ const uploadViaMultipart = async (
346347

347348
const abortMultipart = async () => {
348349
try {
350+
// boundary-raw-fetch: fire-and-forget abort during multipart cleanup; intentionally avoids contract response parsing so cleanup cannot mask the original error
349351
await fetch('/api/files/multipart?action=abort', {
350352
method: 'POST',
351353
headers: { 'Content-Type': 'application/json' },
@@ -356,6 +358,7 @@ const uploadViaMultipart = async (
356358
}
357359
}
358360

361+
// boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/complete/abort outside the contract layer
359362
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
360363
method: 'POST',
361364
headers: { 'Content-Type': 'application/json' },
@@ -443,6 +446,7 @@ const uploadViaMultipart = async (
443446
throw error
444447
}
445448

449+
// boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/get-part-urls/abort outside the contract layer
446450
const completeResponse = await fetch('/api/files/multipart?action=complete', {
447451
method: 'POST',
448452
headers: { 'Content-Type': 'application/json' },

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 718,
13-
zodRoutes: 718,
12+
totalRoutes: 720,
13+
zodRoutes: 720,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)