Skip to content

Commit c03fc3d

Browse files
feat(files): add authored file creation
1 parent b1d4d63 commit c03fc3d

36 files changed

Lines changed: 1824 additions & 274 deletions

apps/docs/openapi-v2-files-audit.json

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,125 @@
168168
"$ref": "#/components/responses/InternalError"
169169
}
170170
}
171+
},
172+
"post": {
173+
"operationId": "createFile",
174+
"summary": "Create File",
175+
"description": "Create an authored workspace file, either empty or with initial inline content. Use this endpoint for files whose bytes are already available as UTF-8 text or base64 and are at most 50 MiB after decoding. Use the upload-session endpoints for streamed or larger files. A live file with the same name in the same folder is rejected with `409`.",
176+
"tags": ["Files"],
177+
"x-codeSamples": [
178+
{
179+
"id": "curl",
180+
"label": "cURL",
181+
"lang": "bash",
182+
"source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"notes.md\"}'"
183+
}
184+
],
185+
"requestBody": {
186+
"required": true,
187+
"content": {
188+
"application/json": {
189+
"schema": {
190+
"type": "object",
191+
"additionalProperties": false,
192+
"required": ["workspaceId", "name"],
193+
"properties": {
194+
"workspaceId": {
195+
"type": "string",
196+
"minLength": 1,
197+
"description": "Workspace in which to create the file."
198+
},
199+
"name": {
200+
"type": "string",
201+
"minLength": 1,
202+
"maxLength": 255,
203+
"description": "File name, including its extension. Path separators and dot segments are rejected."
204+
},
205+
"contentType": {
206+
"type": "string",
207+
"minLength": 1,
208+
"maxLength": 255,
209+
"description": "MIME type. When omitted, it is inferred from the file extension."
210+
},
211+
"folderId": {
212+
"type": "string",
213+
"minLength": 1,
214+
"maxLength": 128,
215+
"description": "Destination folder. Omit to create the file at the workspace root."
216+
},
217+
"content": {
218+
"type": "string",
219+
"maxLength": 70000000,
220+
"default": "",
221+
"description": "Initial file content. Omit or send an empty string to create a zero-byte file."
222+
},
223+
"encoding": {
224+
"type": "string",
225+
"enum": ["utf-8", "base64"],
226+
"default": "utf-8",
227+
"description": "Encoding of `content`."
228+
}
229+
}
230+
},
231+
"example": {
232+
"workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
233+
"name": "notes.md"
234+
}
235+
}
236+
}
237+
},
238+
"responses": {
239+
"201": {
240+
"description": "The created file.",
241+
"headers": {
242+
"X-RateLimit-Limit": {
243+
"$ref": "#/components/headers/X-RateLimit-Limit"
244+
},
245+
"X-RateLimit-Remaining": {
246+
"$ref": "#/components/headers/X-RateLimit-Remaining"
247+
},
248+
"X-RateLimit-Reset": {
249+
"$ref": "#/components/headers/X-RateLimit-Reset"
250+
}
251+
},
252+
"content": {
253+
"application/json": {
254+
"schema": {
255+
"$ref": "#/components/schemas/V2FileResponse"
256+
},
257+
"example": {
258+
"data": {
259+
"id": "wf_V1StGXR8z5jdHi6BmyT91",
260+
"name": "notes.md",
261+
"size": 0,
262+
"type": "text/markdown",
263+
"key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-notes.md",
264+
"folderId": null,
265+
"folderPath": null,
266+
"uploadedBy": "user_abc123",
267+
"uploadedAt": "2026-01-15T10:30:00Z",
268+
"updatedAt": "2026-01-15T10:30:00Z"
269+
}
270+
}
271+
}
272+
}
273+
},
274+
"400": { "$ref": "#/components/responses/BadRequest" },
275+
"401": { "$ref": "#/components/responses/Unauthorized" },
276+
"403": { "$ref": "#/components/responses/Forbidden" },
277+
"404": { "$ref": "#/components/responses/NotFound" },
278+
"409": { "$ref": "#/components/responses/Conflict" },
279+
"413": { "$ref": "#/components/responses/PayloadTooLarge" },
280+
"429": { "$ref": "#/components/responses/RateLimited" },
281+
"500": { "$ref": "#/components/responses/InternalError" }
282+
}
171283
}
172284
},
173285
"/api/v2/files/uploads": {
174286
"post": {
175287
"operationId": "createFileUpload",
176288
"summary": "Create File Upload",
177-
"description": "Create an upload session and signed control token. Files up to and including 50 MiB receive a single signed PUT URL; larger files receive multipart transfer instructions. The maximum file size is 5 GB.",
289+
"description": "Create an upload session and signed control token. Empty files and files up to and including 50 MiB receive a single signed PUT URL; larger files receive multipart transfer instructions. The maximum file size is 5 GB.",
178290
"tags": ["Files"],
179291
"requestBody": {
180292
"required": true,

apps/sim/app/api/files/uploads/route.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,46 @@ describe('/api/files/uploads', () => {
143143
expect(body.data.session).not.toHaveProperty('transfer')
144144
})
145145

146+
it('creates a PUT session for an empty workspace file', async () => {
147+
mockCreateUploadSession.mockResolvedValue({
148+
...session({
149+
workspaceId: 'workspace-1',
150+
purpose: 'workspace_file',
151+
storageContext: 'workspace',
152+
storageKey: 'workspace/workspace-1/empty.md',
153+
fileName: 'empty.md',
154+
contentType: 'text/markdown',
155+
fileSize: 0,
156+
}),
157+
transfer: {
158+
method: 'put',
159+
url: 'https://storage.example.com/upload',
160+
headers: { 'Content-Type': 'text/markdown' },
161+
},
162+
})
163+
const request = new NextRequest('http://localhost/api/files/uploads', {
164+
method: 'POST',
165+
headers: { 'Content-Type': 'application/json' },
166+
body: JSON.stringify({
167+
purpose: 'workspace_file',
168+
workspaceId: 'workspace-1',
169+
name: 'empty.md',
170+
contentType: 'text/markdown',
171+
size: 0,
172+
}),
173+
})
174+
175+
const response = await createUpload(request)
176+
177+
expect(response.status).toBe(201)
178+
expect(mockCreateUploadSession).toHaveBeenCalledWith(
179+
expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 })
180+
)
181+
await expect(response.json()).resolves.toMatchObject({
182+
data: { session: { purpose: 'workspace_file', size: 0 } },
183+
})
184+
})
185+
146186
it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => {
147187
mockCreateUploadSession.mockResolvedValue({
148188
...session({

apps/sim/app/api/v2/files/[fileId]/content/route.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock('@/app/api/v2/lib/gate', () => ({
2222
}))
2323

2424
vi.mock('@/lib/workspace-files/orchestration', () => ({
25+
MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024,
2526
performUpdateWorkspaceFileContent: mockPerformUpdateContent,
2627
}))
2728

@@ -62,12 +63,15 @@ const RECORD = {
6263
updatedAt: new Date('2024-01-03T00:00:00Z'),
6364
}
6465

65-
const callPut = (body: unknown) =>
66+
const callPut = (body: unknown, contentLength?: number) =>
6667
PUT(
6768
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, {
6869
method: 'PUT',
69-
headers: { 'Content-Type': 'application/json' },
70-
body: JSON.stringify(body),
70+
headers: {
71+
'Content-Type': 'application/json',
72+
...(contentLength === undefined ? {} : { 'Content-Length': String(contentLength) }),
73+
},
74+
body: typeof body === 'string' ? body : JSON.stringify(body),
7175
}),
7276
{ params: Promise.resolve({ fileId: FILE_ID }) }
7377
)
@@ -104,6 +108,45 @@ describe('PUT /api/v2/files/[fileId]/content', () => {
104108
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
105109
})
106110

111+
it('400s malformed base64 in the v2 error envelope', async () => {
112+
const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' })
113+
const body = await res.json()
114+
115+
expect(res.status).toBe(400)
116+
expect(body.error.code).toBe('BAD_REQUEST')
117+
expect(body.error.message).toBe('content must be valid base64')
118+
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
119+
})
120+
121+
it('accepts empty base64 as a zero-byte replacement', async () => {
122+
const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' })
123+
124+
expect(res.status).toBe(200)
125+
expect(mockPerformUpdateContent).toHaveBeenCalledWith(
126+
expect.objectContaining({ content: '', encoding: 'base64' })
127+
)
128+
})
129+
130+
it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => {
131+
const res = await callPut(
132+
{ workspaceId: WS, content: 'TQ==', encoding: 'base64' },
133+
60 * 1024 * 1024
134+
)
135+
136+
expect(res.status).toBe(200)
137+
expect(mockPerformUpdateContent).toHaveBeenCalled()
138+
})
139+
140+
it('returns an oversized JSON body in the canonical v2 413 envelope', async () => {
141+
const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1)
142+
143+
expect(res.status).toBe(413)
144+
await expect(res.json()).resolves.toEqual({
145+
error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' },
146+
})
147+
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
148+
})
149+
107150
it('surfaces an access-denied failure in the v2 error envelope', async () => {
108151
mockResolveWorkspaceAccess.mockResolvedValue({
109152
status: 403,

apps/sim/app/api/v2/files/[fileId]/content/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files'
55
import { parseRequest } from '@/lib/api/server'
66
import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration'
8+
import {
9+
MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
10+
performUpdateWorkspaceFileContent,
11+
} from '@/lib/workspace-files/orchestration'
912
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
1013
import { toV2File } from '@/app/api/v2/files/utils'
1114
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
@@ -46,9 +49,15 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo
4649
if (gate) return gate
4750

4851
const parsed = await parseRequest(v2UpdateFileContentContract, request, context, {
52+
invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
53+
maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES,
4954
validationErrorResponse: v2ValidationError,
5055
})
51-
if (!parsed.success) return parsed.response
56+
if (!parsed.success) {
57+
return parsed.response.status === 413
58+
? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
59+
: parsed.response
60+
}
5261

5362
const { fileId } = parsed.data.params
5463
const { workspaceId, content, encoding } = parsed.data.body

0 commit comments

Comments
 (0)