Skip to content

Commit a179093

Browse files
committed
consolidate migrations, rollout compat
1 parent 2e478d3 commit a179093

89 files changed

Lines changed: 3716 additions & 18968 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,21 @@ AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME=og-images
237237
AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos
238238
```
239239

240+
Browser uploads also require an account-level Blob service CORS rule. This rule explicitly allows Sim's create-only `If-None-Match` precondition, Azure upload headers, and multipart `ETag` reads:
241+
242+
```bash
243+
az storage cors add \
244+
--services b \
245+
--methods GET PUT OPTIONS \
246+
--origins "https://your-sim-domain.com" \
247+
--allowed-headers "Content-Type" "If-None-Match" "x-ms-*" \
248+
--exposed-headers "ETag" \
249+
--max-age 3600 \
250+
--account-name mystorageaccount
251+
```
252+
253+
The CORS rule applies to every blob container in the storage account, so it only needs to be added once per account.
254+
240255
A full Helm example lives at `helm/sim/examples/values-azure.yaml`.
241256

242257
## Set up Google Cloud Storage
@@ -276,14 +291,16 @@ cat > /tmp/cors.json <<'EOF'
276291
"responseHeader": [
277292
"Content-Type",
278293
"ETag",
294+
"x-goog-if-generation-match",
279295
"x-goog-meta-originalname",
280296
"x-goog-meta-uploadedat",
281297
"x-goog-meta-purpose",
282298
"x-goog-meta-userid",
283299
"x-goog-meta-workspaceid",
284300
"x-goog-meta-folderid",
285301
"x-goog-meta-workflowid",
286-
"x-goog-meta-executionid"
302+
"x-goog-meta-executionid",
303+
"x-goog-meta-simuploadid"
287304
],
288305
"maxAgeSeconds": 3600
289306
}
@@ -297,7 +314,7 @@ done
297314
```
298315

299316
<Callout type="info">
300-
Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise.
317+
Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise. `x-goog-if-generation-match` is required by Sim's create-only signed uploads, which prevent a reused upload URL from replacing existing bytes. `x-goog-meta-simuploadid` carries the opaque receipt used to verify an upload after an ambiguous network response.
301318
</Callout>
302319

303320
</Step>

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

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,23 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({
5151
abortMultipartUpload: vi.fn(),
5252
}))
5353

54+
vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
55+
generateExecutionAttachmentKey: mockGenerateExecutionAttachmentKey,
56+
}))
57+
5458
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
5559

56-
const { mockCheckStorageQuota, mockInitiateS3MultipartUpload, mockResolveStorageBillingContext } =
57-
vi.hoisted(() => ({
58-
mockCheckStorageQuota: vi.fn(),
59-
mockInitiateS3MultipartUpload: vi.fn(),
60-
mockResolveStorageBillingContext: vi.fn(),
61-
}))
60+
const {
61+
mockCheckStorageQuota,
62+
mockGenerateExecutionAttachmentKey,
63+
mockInitiateS3MultipartUpload,
64+
mockResolveStorageBillingContext,
65+
} = vi.hoisted(() => ({
66+
mockCheckStorageQuota: vi.fn(),
67+
mockGenerateExecutionAttachmentKey: vi.fn(),
68+
mockInitiateS3MultipartUpload: vi.fn(),
69+
mockResolveStorageBillingContext: vi.fn(),
70+
}))
6271

6372
vi.mock('@/lib/billing/storage', () => ({
6473
checkStorageQuotaForBillingContext: mockCheckStorageQuota,
@@ -180,6 +189,7 @@ describe('POST /api/files/multipart action=complete', () => {
180189
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2)
181190
expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith(
182191
tokenPayload.key,
192+
tokenPayload.uploadId,
183193
[
184194
{ partNumber: 1, blockId: 'block-000001' },
185195
{ partNumber: 2, blockId: 'block-000002' },
@@ -240,6 +250,13 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
240250
mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
241251
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
242252
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
253+
mockGenerateExecutionAttachmentKey.mockImplementation(
254+
(
255+
context: { workspaceId: string; workflowId: string; executionId: string },
256+
fileName: string
257+
) =>
258+
`execution/${context.workspaceId}/${context.workflowId}/${context.executionId}/unique-${fileName}`
259+
)
243260
})
244261

245262
it('blocks upload when fileSize: 0 exceeds quota', async () => {
@@ -293,6 +310,38 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
293310
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
294311
})
295312

313+
it('allocates distinct multipart keys for duplicate execution attachment names', async () => {
314+
mockGenerateExecutionAttachmentKey
315+
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/one-output.bin')
316+
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/two-output.bin')
317+
mockInitiateS3MultipartUpload.mockImplementation(async ({ customKey }) => ({
318+
uploadId: `upload-${customKey}`,
319+
key: customKey,
320+
}))
321+
322+
const makeExecutionRequest = (fileSize: number) =>
323+
makeInitiateRequest({
324+
fileName: 'output.bin',
325+
contentType: 'application/octet-stream',
326+
fileSize,
327+
workspaceId: 'ws-1',
328+
workflowId: 'wf-1',
329+
executionId: 'exec-1',
330+
context: 'execution',
331+
})
332+
333+
const first = await POST(makeExecutionRequest(60 * 1024 * 1024))
334+
const second = await POST(makeExecutionRequest(70 * 1024 * 1024))
335+
const firstBody = await first.json()
336+
const secondBody = await second.json()
337+
338+
expect(first.status).toBe(200)
339+
expect(second.status).toBe(200)
340+
expect(firstBody.key).toBe('execution/ws-1/wf-1/exec-1/one-output.bin')
341+
expect(secondBody.key).toBe('execution/ws-1/wf-1/exec-1/two-output.bin')
342+
expect(firstBody.key).not.toBe(secondBody.key)
343+
})
344+
296345
it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])(
297346
'rejects quota-exempt context %s — not allowed via the multipart endpoint',
298347
async (context) => {

apps/sim/app/api/files/multipart/route.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
215215
{ status: 400 }
216216
)
217217
}
218-
const { generateExecutionFileKey } = await import(
218+
const { generateExecutionAttachmentKey } = await import(
219219
'@/lib/uploads/contexts/execution/utils'
220220
)
221-
customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
221+
customKey = generateExecutionAttachmentKey(
222+
{ workspaceId, workflowId, executionId },
223+
fileName
224+
)
222225
}
223226

224227
let uploadId: string
@@ -389,7 +392,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
389392
partNumber: p.partNumber,
390393
blockId: deriveBlobBlockId(p.partNumber),
391394
}))
392-
completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config))
395+
completed = await completeMultipartUpload(
396+
key,
397+
uploadId,
398+
blobParts,
399+
buildBlobCustomConfig(config)
400+
)
393401
} else if (storageProvider === 'gcs' && gcsModule) {
394402
const { completeGcsMultipartUpload } = gcsModule
395403
const gcsParts = parts.map((p) => {
@@ -501,7 +509,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
501509
logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`)
502510
} else if (storageProvider === 'blob') {
503511
const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
504-
await abortMultipartUpload(key, buildBlobCustomConfig(config))
512+
await abortMultipartUpload(key, uploadId, buildBlobCustomConfig(config))
505513
logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`)
506514
} else if (storageProvider === 'gcs') {
507515
const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client')

apps/sim/app/api/files/presigned/batch/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ const {
1212
mockValidateFileType,
1313
mockGetUserEntityPermissions,
1414
mockRecordKnowledgeBaseFileOwnershipMany,
15+
mockSignUploadToken,
1516
} = vi.hoisted(() => ({
1617
mockValidateFileType: vi.fn().mockReturnValue(null),
1718
mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'),
1819
mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined),
20+
mockSignUploadToken: vi.fn(),
1921
}))
2022

2123
vi.mock('@/lib/uploads/config', () => ({
@@ -24,6 +26,10 @@ vi.mock('@/lib/uploads/config', () => ({
2426

2527
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
2628

29+
vi.mock('@/lib/uploads/core/upload-token', () => ({
30+
signUploadToken: mockSignUploadToken,
31+
}))
32+
2733
vi.mock('@/lib/uploads/utils/validation', () => ({
2834
validateFileType: mockValidateFileType,
2935
SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const,
@@ -56,12 +62,14 @@ describe('/api/files/presigned/batch', () => {
5662
mockValidateFileType.mockReturnValue(null)
5763
mockGetUserEntityPermissions.mockResolvedValue('write')
5864
mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined)
65+
mockSignUploadToken.mockReturnValue('signed-receipt-token')
5966
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
6067
storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation(
6168
async (files: Array<{ fileName: string }>, context: string) =>
6269
files.map((file) => ({
6370
url: `https://example.com/${context}/${file.fileName}`,
6471
key: `${context}/${file.fileName}`,
72+
uploadId: `receipt-${file.fileName}`,
6573
}))
6674
)
6775
})
@@ -162,6 +170,17 @@ describe('/api/files/presigned/batch', () => {
162170
expect(data.files).toHaveLength(1)
163171
expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf')
164172
expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base')
173+
expect(data.files[0].uploadToken).toBe('signed-receipt-token')
174+
expect(mockSignUploadToken).toHaveBeenCalledWith(
175+
expect.objectContaining({
176+
uploadId: 'receipt-doc.pdf',
177+
key: 'knowledge-base/doc.pdf',
178+
userId: 'user-1',
179+
workspaceId: 'ws-1',
180+
context: 'knowledge-base',
181+
uploadKind: 'direct',
182+
})
183+
)
165184
expect(data.directUploadSupported).toBe(true)
166185
expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([
167186
{

apps/sim/app/api/files/presigned/batch/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
generateBatchPresignedUploadUrls,
1414
hasCloudStorage,
1515
} from '@/lib/uploads/core/storage-service'
16+
import { signUploadToken } from '@/lib/uploads/core/upload-token'
1617
import { recordKnowledgeBaseFileOwnershipMany } from '@/lib/uploads/server/metadata'
1718
import { validateFileType } from '@/lib/uploads/utils/validation'
1819
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -170,6 +171,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
170171
type: file.contentType,
171172
},
172173
uploadHeaders: urlResponse.uploadHeaders,
174+
...(urlResponse.uploadId
175+
? {
176+
uploadToken: signUploadToken({
177+
uploadId: urlResponse.uploadId,
178+
key: urlResponse.key,
179+
userId: sessionUserId,
180+
workspaceId,
181+
context: 'knowledge-base',
182+
fileName: file.fileName,
183+
contentType: file.contentType,
184+
fileSize: file.fileSize,
185+
uploadKind: 'direct',
186+
}),
187+
}
188+
: {}),
173189
directUploadSupported: true,
174190
}
175191
}),

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ const {
2424
mockIsUsingCloudStorageUploads,
2525
mockGetUserEntityPermissions,
2626
mockGenerateWorkspaceFileKey,
27-
mockGenerateExecutionFileKey,
27+
mockGenerateExecutionAttachmentKey,
2828
mockInsertFileMetadata,
2929
mockCheckStorageQuotaForBillingContext,
3030
mockDecrementStorageUsageForBillingContext,
3131
mockIncrementStorageUsageForBillingContext,
3232
mockResolveStorageBillingContext,
33+
mockSignUploadToken,
3334
} = vi.hoisted(() => ({
3435
mockVerifyFileAccess: vi.fn().mockResolvedValue(true),
3536
mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true),
@@ -51,15 +52,16 @@ const {
5152
mockGenerateWorkspaceFileKey: vi.fn(
5253
(workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}`
5354
),
54-
mockGenerateExecutionFileKey: vi.fn(
55+
mockGenerateExecutionAttachmentKey: vi.fn(
5556
(ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) =>
56-
`execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}`
57+
`execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/attachment-${fileName}`
5758
),
5859
mockInsertFileMetadata: vi.fn().mockResolvedValue({ id: 'wf_test' }),
5960
mockCheckStorageQuotaForBillingContext: vi.fn(),
6061
mockDecrementStorageUsageForBillingContext: vi.fn(),
6162
mockIncrementStorageUsageForBillingContext: vi.fn(),
6263
mockResolveStorageBillingContext: vi.fn(),
64+
mockSignUploadToken: vi.fn(),
6365
}))
6466

6567
vi.mock('@/app/api/files/authorization', () => ({
@@ -83,6 +85,10 @@ vi.mock('@/lib/uploads/config', () => ({
8385

8486
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
8587

88+
vi.mock('@/lib/uploads/core/upload-token', () => ({
89+
signUploadToken: mockSignUploadToken,
90+
}))
91+
8692
vi.mock('@/lib/billing/storage', () => ({
8793
checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext,
8894
decrementStorageUsageForBillingContext: mockDecrementStorageUsageForBillingContext,
@@ -104,11 +110,11 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
104110
}))
105111

106112
vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
107-
generateExecutionFileKey: mockGenerateExecutionFileKey,
113+
generateExecutionAttachmentKey: mockGenerateExecutionAttachmentKey,
108114
}))
109115

110116
vi.mock('@/lib/uploads/server/metadata', () => ({
111-
insertFileMetadata: mockInsertFileMetadata,
117+
insertImmutableFileMetadata: mockInsertFileMetadata,
112118
recordKnowledgeBaseFileOwnership: (ownership: Record<string, unknown>) =>
113119
mockInsertFileMetadata({ ...ownership, context: 'knowledge-base' }),
114120
}))
@@ -181,6 +187,7 @@ function setupFileApiMocks(
181187
return {
182188
url: 'https://example.com/presigned-url',
183189
key,
190+
uploadId: `receipt-${key}`,
184191
}
185192
}
186193
)
@@ -207,6 +214,7 @@ describe('/api/files/presigned', () => {
207214
vi.stubGlobal('crypto', {
208215
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
209216
})
217+
mockSignUploadToken.mockReturnValue('signed-receipt-token')
210218
})
211219

212220
afterEach(() => {
@@ -742,6 +750,39 @@ describe('/api/files/presigned', () => {
742750
})
743751

744752
describe('execution uploads', () => {
753+
it('allocates distinct create-only keys for duplicate attachment names', async () => {
754+
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
755+
mockGenerateExecutionAttachmentKey
756+
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/one-output.txt')
757+
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/two-output.txt')
758+
759+
const makeExecutionRequest = (fileSize: number) =>
760+
new NextRequest(
761+
'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1',
762+
{
763+
method: 'POST',
764+
body: JSON.stringify({
765+
fileName: 'output.txt',
766+
contentType: 'text/plain',
767+
fileSize,
768+
}),
769+
}
770+
)
771+
772+
const first = await POST(makeExecutionRequest(3))
773+
const second = await POST(makeExecutionRequest(12))
774+
const firstBody = await first.json()
775+
const secondBody = await second.json()
776+
777+
expect(first.status).toBe(200)
778+
expect(second.status).toBe(200)
779+
expect(firstBody.fileInfo.key).toBe('execution/ws-1/wf-1/exec-1/one-output.txt')
780+
expect(secondBody.fileInfo.key).toBe('execution/ws-1/wf-1/exec-1/two-output.txt')
781+
expect(firstBody.fileInfo.key).not.toBe(secondBody.fileInfo.key)
782+
expect(firstBody.uploadToken).toBe('signed-receipt-token')
783+
expect(secondBody.uploadToken).toBe('signed-receipt-token')
784+
})
785+
745786
it('uses validateAttachmentFileType — accepts video', async () => {
746787
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
747788

0 commit comments

Comments
 (0)