fix(files): authorize presigned upload contexts per type (#6175)

The batch presign endpoint validated its type param against the shared
seven-value upload enum while only authorizing knowledge-base, so any
authenticated user could mint an S3 presigned PUT into workspace-logos,
profile-pictures, execution, mothership, chat and copilot prefixes —
objects that are then served unauthenticated from the app origin with a
one-year public cache. The single presign endpoint had the same gap for
chat, which has no authorization predicate and no client.

- Batch presign now accepts only knowledge-base, and always requires a
  workspaceId the caller has write/admin on before anything is minted
- Single presign drops chat from its accepted contexts; every remaining
  context has a per-context predicate
- Both allowlists live in the contract module so the enforced enum and
  the documented one cannot drift apart again
This commit is contained in:
Waleed
2026-08-01 17:53:01 -07:00
committed by GitHub
parent e5d2f7d80a
commit 51dd0df4ca
5 changed files with 396 additions and 161 deletions
@@ -0,0 +1,189 @@
/**
* Tests for the batch presigned upload API route
*
* @vitest-environment node
*/
import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockValidateFileType,
mockGetUserEntityPermissions,
mockRecordKnowledgeBaseFileOwnershipMany,
} = vi.hoisted(() => ({
mockValidateFileType: vi.fn().mockReturnValue(null),
mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'),
mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/uploads/config', () => ({
getServeStoragePrefix: () => 's3',
}))
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
vi.mock('@/lib/uploads/server/metadata', () => ({
recordKnowledgeBaseFileOwnershipMany: mockRecordKnowledgeBaseFileOwnershipMany,
}))
import { POST } from '@/app/api/files/presigned/batch/route'
const KB_QUERY = 'type=knowledge-base&workspaceId=ws-1'
const buildRequest = (query: string, files?: unknown) =>
new NextRequest(`http://localhost:3000/api/files/presigned/batch?${query}`, {
method: 'POST',
body: JSON.stringify({
files: files ?? [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }],
}),
})
describe('/api/files/presigned/batch', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockValidateFileType.mockReturnValue(null)
mockGetUserEntityPermissions.mockResolvedValue('write')
mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined)
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation(
async (files: Array<{ fileName: string }>, context: string) =>
files.map((file) => ({
url: `https://example.com/${context}/${file.fileName}`,
key: `${context}/${file.fileName}`,
}))
)
})
it('returns 401 when the caller has no session', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const response = await POST(buildRequest(KB_QUERY))
expect(response.status).toBe(401)
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
it.each([
'workspace-logos',
'profile-pictures',
'execution',
'mothership',
'chat',
'copilot',
'workspace',
])('refuses to presign the %s context', async (type) => {
const response = await POST(buildRequest(`type=${type}&workspaceId=ws-1`))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('Invalid type parameter')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
it('returns 400 when type is missing', async () => {
const response = await POST(buildRequest('workspaceId=ws-1'))
expect(response.status).toBe(400)
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
it('returns 400 when workspaceId is missing', async () => {
const response = await POST(buildRequest('type=knowledge-base'))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('workspaceId')
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
it.each([['read'], [null]])(
'returns 403 when the caller has %s access to the workspace',
async (permission) => {
mockGetUserEntityPermissions.mockResolvedValue(permission)
const response = await POST(buildRequest(KB_QUERY))
expect(response.status).toBe(403)
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
}
)
it('authorizes the workspace before returning the local-storage fallback', async () => {
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
mockGetUserEntityPermissions.mockResolvedValue('read')
const response = await POST(buildRequest(KB_QUERY))
expect(response.status).toBe(403)
})
it('rejects unsupported file types before minting any URL', async () => {
mockValidateFileType.mockReturnValue({
code: 'UNSUPPORTED_FILE_TYPE',
message: 'Unsupported file type: html.',
supportedTypes: ['pdf'],
})
const response = await POST(
buildRequest(KB_QUERY, [{ fileName: 'poc.html', contentType: 'text/html', fileSize: 41 }])
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.code).toBe('UNSUPPORTED_FILE_TYPE')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
it('mints knowledge-base URLs and records workspace ownership for a permitted caller', async () => {
const response = await POST(buildRequest(KB_QUERY))
const data = await response.json()
expect(response.status).toBe(200)
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).toHaveBeenCalledWith(
[{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }],
'knowledge-base',
'user-1',
3600
)
expect(data.files).toHaveLength(1)
expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf')
expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base')
expect(data.directUploadSupported).toBe(true)
expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([
{
key: 'knowledge-base/doc.pdf',
userId: 'user-1',
workspaceId: 'ws-1',
originalName: 'doc.pdf',
contentType: 'application/pdf',
size: 1024,
},
])
})
it('returns the fallback response when cloud storage is not configured', async () => {
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false)
const response = await POST(buildRequest(KB_QUERY))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.directUploadSupported).toBe(false)
expect(data.files[0].presignedUrl).toBe('')
expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled()
})
})
+43 -54
View File
@@ -2,12 +2,12 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
batchPresignedUploadBodyContract,
uploadTypeSchema,
batchPresignedUploadTypeSchema,
batchPresignedUploadTypes,
} from '@/lib/api/contracts/storage-transfer'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { StorageContext } from '@/lib/uploads/config'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import {
generateBatchPresignedUploadUrls,
@@ -20,8 +20,13 @@ import { createErrorResponse } from '@/app/api/files/utils'
const logger = createLogger('BatchPresignedUploadAPI')
const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const
/**
* Mints presigned upload URLs for knowledge-base ingest, the only context this
* endpoint can authorize. Every request must name a workspace the caller has
* write access to; other storage contexts are rejected rather than presigned,
* because a presigned PUT is a write grant into a bucket served from a trusted
* origin.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
@@ -52,62 +57,49 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 })
}
const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam)
const uploadTypeResult = batchPresignedUploadTypeSchema.safeParse(uploadTypeParam)
if (!uploadTypeResult.success) {
return NextResponse.json(
{ error: `Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}` },
{
error: `Invalid type parameter. Must be one of: ${batchPresignedUploadTypes.join(', ')}`,
},
{ status: 400 }
)
}
const uploadType = uploadTypeResult.data as StorageContext
const uploadType = uploadTypeResult.data
const sessionUserId = session.user.id
let knowledgeBaseWorkspaceId: string | null = null
if (uploadType === 'knowledge-base') {
for (const file of files) {
const fileValidationError = validateFileType(file.fileName, file.contentType)
if (fileValidationError) {
return NextResponse.json(
{
error: fileValidationError.message,
code: fileValidationError.code,
supportedTypes: fileValidationError.supportedTypes,
},
{ status: 400 }
)
}
}
knowledgeBaseWorkspaceId = request.nextUrl.searchParams.get('workspaceId')
if (!knowledgeBaseWorkspaceId?.trim()) {
for (const file of files) {
const fileValidationError = validateFileType(file.fileName, file.contentType)
if (fileValidationError) {
return NextResponse.json(
{ error: 'workspaceId query parameter is required for knowledge-base uploads' },
{
error: fileValidationError.message,
code: fileValidationError.code,
supportedTypes: fileValidationError.supportedTypes,
},
{ status: 400 }
)
}
const permission = await getUserEntityPermissions(
sessionUserId,
'workspace',
knowledgeBaseWorkspaceId
)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Write or Admin access required for knowledge-base uploads' },
{ status: 403 }
)
}
}
if (uploadType === 'copilot' && !sessionUserId?.trim()) {
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
if (!workspaceId?.trim()) {
return NextResponse.json(
{ error: 'Authenticated user session is required for copilot uploads' },
{ error: 'workspaceId query parameter is required for knowledge-base uploads' },
{ status: 400 }
)
}
const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json(
{ error: 'Write or Admin access required for knowledge-base uploads' },
{ status: 403 }
)
}
if (!hasCloudStorage()) {
logger.info(
`Local storage detected - batch presigned URLs not available, client will use API fallback`
@@ -149,19 +141,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
`Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)`
)
if (uploadType === 'knowledge-base' && knowledgeBaseWorkspaceId) {
const ownerWorkspaceId = knowledgeBaseWorkspaceId
await recordKnowledgeBaseFileOwnershipMany(
presignedUrls.map((urlResponse, index) => ({
key: urlResponse.key,
userId: sessionUserId,
workspaceId: ownerWorkspaceId,
originalName: files[index].fileName,
contentType: files[index].contentType,
size: files[index].fileSize,
}))
)
}
await recordKnowledgeBaseFileOwnershipMany(
presignedUrls.map((urlResponse, index) => ({
key: urlResponse.key,
userId: sessionUserId,
workspaceId,
originalName: files[index].fileName,
contentType: files[index].contentType,
size: files[index].fileSize,
}))
)
const storagePrefix = getServeStoragePrefix()
+124 -80
View File
@@ -220,14 +220,17 @@ describe('/api/files/presigned', () => {
storageProvider: 's3',
})
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'avatar.png',
contentType: 'image/png',
fileSize: 1024,
}),
}
)
const response = await POST(request)
const data = await response.json()
@@ -235,11 +238,11 @@ describe('/api/files/presigned', () => {
expect(response.status).toBe(200)
expect(data.directUploadSupported).toBe(false)
expect(data.presignedUrl).toBe('')
expect(data.fileName).toBe('test.txt')
expect(data.fileName).toBe('avatar.png')
expect(data.fileInfo).toBeDefined()
expect(data.fileInfo.name).toBe('test.txt')
expect(data.fileInfo.name).toBe('avatar.png')
expect(data.fileInfo.size).toBe(1024)
expect(data.fileInfo.type).toBe('text/plain')
expect(data.fileInfo.type).toBe('image/png')
})
it('should return error when fileName is missing', async () => {
@@ -339,14 +342,17 @@ describe('/api/files/presigned', () => {
storageProvider: 's3',
})
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'test document.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'test avatar.png',
contentType: 'image/png',
fileSize: 1024,
}),
}
)
const response = await POST(request)
const data = await response.json()
@@ -354,11 +360,11 @@ describe('/api/files/presigned', () => {
expect(response.status).toBe(200)
expect(data.presignedUrl).toBe('https://example.com/presigned-url')
expect(data.fileInfo).toMatchObject({
path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=chat$/),
key: expect.stringMatching(/.*test.document\.txt$/),
name: 'test document.txt',
path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/),
key: expect.stringMatching(/.*test.avatar\.png$/),
name: 'test avatar.png',
size: 1024,
type: 'text/plain',
type: 'image/png',
})
expect(data.directUploadSupported).toBe(true)
})
@@ -389,27 +395,30 @@ describe('/api/files/presigned', () => {
expect(data.directUploadSupported).toBe(true)
})
it('should generate chat S3 presigned URL with chat prefix and direct path', async () => {
it('should generate profile-pictures S3 presigned URL with its prefix and direct path', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'chat-logo.png',
contentType: 'image/png',
fileSize: 4096,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'avatar.png',
contentType: 'image/png',
fileSize: 4096,
}),
}
)
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/)
expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=chat$/)
expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/)
expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/)
expect(data.presignedUrl).toBeTruthy()
expect(data.directUploadSupported).toBe(true)
})
@@ -420,14 +429,17 @@ describe('/api/files/presigned', () => {
storageProvider: 'blob',
})
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'test document.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'test avatar.png',
contentType: 'image/png',
fileSize: 1024,
}),
}
)
const response = await POST(request)
const data = await response.json()
@@ -436,35 +448,38 @@ describe('/api/files/presigned', () => {
expect(data.presignedUrl).toBeTruthy()
expect(typeof data.presignedUrl).toBe('string')
expect(data.fileInfo).toMatchObject({
key: expect.stringMatching(/.*test.document\.txt$/),
name: 'test document.txt',
key: expect.stringMatching(/.*test.avatar\.png$/),
name: 'test avatar.png',
size: 1024,
type: 'text/plain',
type: 'image/png',
})
expect(data.directUploadSupported).toBe(true)
})
it('should generate chat Azure Blob presigned URL with chat prefix and direct path', async () => {
it('should generate profile-pictures Azure Blob presigned URL with its prefix and direct path', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 'blob',
})
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'chat-logo.png',
contentType: 'image/png',
fileSize: 4096,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'avatar.png',
contentType: 'image/png',
fileSize: 4096,
}),
}
)
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/)
expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=chat$/)
expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/)
expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=profile-pictures$/)
expect(data.presignedUrl).toBeTruthy()
expect(data.directUploadSupported).toBe(true)
})
@@ -479,14 +494,17 @@ describe('/api/files/presigned', () => {
new Error('Unknown storage provider: unknown')
)
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'avatar.png',
contentType: 'image/png',
fileSize: 1024,
}),
}
)
const response = await POST(request)
const data = await response.json()
@@ -506,14 +524,17 @@ describe('/api/files/presigned', () => {
new Error('S3 service unavailable')
)
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'avatar.png',
contentType: 'image/png',
fileSize: 1024,
}),
}
)
const response = await POST(request)
const data = await response.json()
@@ -533,14 +554,17 @@ describe('/api/files/presigned', () => {
new Error('Azure service unavailable')
)
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 1024,
}),
})
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=profile-pictures',
{
method: 'POST',
body: JSON.stringify({
fileName: 'avatar.png',
contentType: 'image/png',
fileSize: 1024,
}),
}
)
const response = await POST(request)
const data = await response.json()
@@ -568,6 +592,26 @@ describe('/api/files/presigned', () => {
expect(data.error).toBe('Invalid JSON in request body') // Updated error message
expect(data.code).toBe('VALIDATION_ERROR')
})
it('rejects the unauthorizable chat context without minting a URL', async () => {
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'poc.html',
contentType: 'text/html',
fileSize: 41,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('Invalid type parameter')
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled()
})
})
describe('mothership uploads', () => {
+12 -26
View File
@@ -1,12 +1,15 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { presignedUploadBodyContract, uploadTypeSchema } from '@/lib/api/contracts/storage-transfer'
import {
presignedUploadBodyContract,
presignedUploadTypeSchema,
presignedUploadTypes,
} from '@/lib/api/contracts/storage-transfer'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CopilotFiles } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
@@ -20,16 +23,6 @@ import { createErrorResponse } from '@/app/api/files/utils'
const logger = createLogger('PresignedUploadAPI')
const VALID_UPLOAD_TYPES = [
'knowledge-base',
'chat',
'copilot',
'profile-pictures',
'mothership',
'workspace-logos',
'execution',
] as const
class PresignedUrlError extends Error {
constructor(
message: string,
@@ -76,14 +69,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
throw new ValidationError('type query parameter is required')
}
const uploadTypeResult = uploadTypeSchema.safeParse(uploadTypeParam)
const uploadTypeResult = presignedUploadTypeSchema.safeParse(uploadTypeParam)
if (!uploadTypeResult.success) {
throw new ValidationError(
`Invalid type parameter. Must be one of: ${VALID_UPLOAD_TYPES.join(', ')}`
`Invalid type parameter. Must be one of: ${presignedUploadTypes.join(', ')}`
)
}
const uploadType = uploadTypeResult.data as StorageContext
const uploadType = uploadTypeResult.data
if (uploadType === 'knowledge-base') {
const fileValidationError = validateFileType(fileName, contentType)
@@ -290,17 +283,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
size: fileSize,
})
} else {
if (uploadType === 'profile-pictures') {
if (!sessionUserId?.trim()) {
throw new ValidationError(
'Authenticated user session is required for profile picture uploads'
)
}
if (!isImageFileType(contentType)) {
throw new ValidationError(
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads'
)
}
if (!isImageFileType(contentType)) {
throw new ValidationError(
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads'
)
}
presignedUrlResponse = await generatePresignedUploadUrl({
+28 -1
View File
@@ -338,8 +338,35 @@ export const validUploadTypes = [
export const uploadTypeSchema = z.enum(validUploadTypes)
/**
* Storage contexts a client may mint a single presigned upload URL for. Each one
* has a per-context authorization predicate in `/api/files/presigned`; a context
* that cannot be authorized must not be listed here. `chat` is deliberately
* absent — it has no owning entity to authorize against and no client that mints
* one (chat assets go through the server-proxied `/api/files/upload`).
*/
export const presignedUploadTypes = [
'knowledge-base',
'copilot',
'profile-pictures',
'mothership',
'workspace-logos',
'execution',
] as const
export const presignedUploadTypeSchema = z.enum(presignedUploadTypes)
/**
* Storage contexts `/api/files/presigned/batch` serves. Batching exists only for
* knowledge-base ingest; no other context has a batch client, and the batch
* endpoint carries no authorization predicate for one.
*/
export const batchPresignedUploadTypes = ['knowledge-base'] as const
export const batchPresignedUploadTypeSchema = z.enum(batchPresignedUploadTypes)
export const presignedUploadQuerySchema = z.object({
type: uploadTypeSchema,
type: presignedUploadTypeSchema,
})
export const presignedUrlBodySchema = z