mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(files): preserve principals when serving generated documents (#6654)
* fix(files): preserve principals when serving documents * fix(files): address principal serve review findings
This commit is contained in:
@@ -20,6 +20,10 @@ const {
|
||||
mockIsUsingCloudStorage,
|
||||
mockDownloadCopilotFile,
|
||||
mockInferContextFromKey,
|
||||
mockParseWorkspaceFileKey,
|
||||
mockAuthenticateWorkspaceFile,
|
||||
mockReadWorkspaceFileContentByKey,
|
||||
mockResolveServableDocBytes,
|
||||
mockGetContentType,
|
||||
mockFindLocalFile,
|
||||
mockCreateFileResponse,
|
||||
@@ -40,6 +44,10 @@ const {
|
||||
mockIsUsingCloudStorage: vi.fn(),
|
||||
mockDownloadCopilotFile: vi.fn(),
|
||||
mockInferContextFromKey: vi.fn(),
|
||||
mockParseWorkspaceFileKey: vi.fn(),
|
||||
mockAuthenticateWorkspaceFile: vi.fn(),
|
||||
mockReadWorkspaceFileContentByKey: vi.fn(),
|
||||
mockResolveServableDocBytes: vi.fn(),
|
||||
mockGetContentType: vi.fn(),
|
||||
mockFindLocalFile: vi.fn(),
|
||||
mockCreateFileResponse: vi.fn(),
|
||||
@@ -82,7 +90,19 @@ vi.mock('@/lib/execution/sandbox/run-task', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
parseWorkspaceFileKey: vi.fn().mockReturnValue(undefined),
|
||||
parseWorkspaceFileKey: mockParseWorkspaceFileKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-files/api', () => ({
|
||||
internalWorkspaceFileServeAuth: { authenticate: mockAuthenticateWorkspaceFile },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({
|
||||
readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
|
||||
resolveServableDocBytes: mockResolveServableDocBytes,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/utils', () => ({
|
||||
@@ -109,7 +129,27 @@ describe('File Serve API Route', () => {
|
||||
mockReadFile.mockResolvedValue(Buffer.from('test content'))
|
||||
mockIsUsingCloudStorage.mockReturnValue(false)
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
mockInferContextFromKey.mockReturnValue('workspace')
|
||||
mockInferContextFromKey.mockReturnValue('mothership')
|
||||
mockParseWorkspaceFileKey.mockReturnValue(undefined)
|
||||
mockAuthenticateWorkspaceFile.mockResolvedValue({
|
||||
kind: 'session',
|
||||
userId: 'test-user-id',
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
mockReadWorkspaceFileContentByKey.mockResolvedValue({
|
||||
file: {
|
||||
id: 'file-1',
|
||||
workspaceId: 'test-workspace-id',
|
||||
name: 'report.pdf',
|
||||
},
|
||||
content: Buffer.from('generated source'),
|
||||
})
|
||||
mockResolveServableDocBytes.mockImplementation(
|
||||
async ({ rawBuffer, fileName }: { rawBuffer: Buffer; fileName: string }) => ({
|
||||
buffer: rawBuffer,
|
||||
contentType: mockGetContentType(fileName),
|
||||
})
|
||||
)
|
||||
mockGetContentType.mockReturnValue('text/plain')
|
||||
mockFindLocalFile.mockReturnValue('/test/uploads/test-file.txt')
|
||||
mockCreateFileResponse.mockImplementation(
|
||||
@@ -181,10 +221,61 @@ describe('File Serve API Route', () => {
|
||||
|
||||
expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith({
|
||||
key: 'workspace/test-workspace-id/1234567890-image.png',
|
||||
context: 'workspace',
|
||||
context: 'mothership',
|
||||
})
|
||||
})
|
||||
|
||||
it('serves a workspace document through the authorized use case and preserves the Principal', async () => {
|
||||
const principal = {
|
||||
kind: 'delegated' as const,
|
||||
serviceId: 'executor' as const,
|
||||
subjectUserId: 'test-user-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
delegationId: 'delegation-1',
|
||||
audience: 'sim:workspace-files',
|
||||
issuedAt: new Date('2026-08-01T00:00:00Z'),
|
||||
expiresAt: new Date('2026-08-01T01:00:00Z'),
|
||||
delegationContext: {
|
||||
kind: 'workflow_execution' as const,
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
}
|
||||
mockInferContextFromKey.mockReturnValue('workspace')
|
||||
mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id')
|
||||
mockAuthenticateWorkspaceFile.mockResolvedValue(principal)
|
||||
mockResolveServableDocBytes.mockResolvedValue({
|
||||
buffer: Buffer.from('%PDF-compiled'),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf'
|
||||
)
|
||||
const response = await GET(req, {
|
||||
params: Promise.resolve({
|
||||
path: ['workspace', 'test-workspace-id', 'report.pdf'],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockReadWorkspaceFileContentByKey).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {
|
||||
key: 'workspace/test-workspace-id/report.pdf',
|
||||
assertedWorkspaceId: 'test-workspace-id',
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
expect(mockResolveServableDocBytes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'test-workspace-id',
|
||||
filePrincipal: principal,
|
||||
})
|
||||
)
|
||||
expect(hybridAuthMockFns.mockCheckSessionOrInternalAuth).not.toHaveBeenCalled()
|
||||
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 404 when file not found', async () => {
|
||||
mockVerifyFileAccess.mockResolvedValue(false)
|
||||
mockFindLocalFile.mockReturnValue(null)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
|
||||
import {
|
||||
concealCrossTenantResourceError,
|
||||
InternalUnauthenticatedError,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { resolveServableDocBytes } from '@/lib/copilot/tools/server/files/doc-compile'
|
||||
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
@@ -13,6 +19,8 @@ import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspac
|
||||
import { downloadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
|
||||
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
|
||||
import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import {
|
||||
createErrorResponse,
|
||||
@@ -66,9 +74,11 @@ async function resolveServableBytes(params: {
|
||||
workspaceId: string | undefined
|
||||
options: ServeOptions
|
||||
ownerKey: string | undefined
|
||||
filePrincipal?: Principal
|
||||
signal: AbortSignal | undefined
|
||||
}): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
const { buffer, filename, storageKey, workspaceId, options, ownerKey, signal } = params
|
||||
const { buffer, filename, storageKey, workspaceId, options, ownerKey, filePrincipal, signal } =
|
||||
params
|
||||
if (options.raw) return { buffer, contentType: getContentType(filename) }
|
||||
|
||||
if (options.preview) {
|
||||
@@ -82,6 +92,7 @@ async function resolveServableBytes(params: {
|
||||
rawBuffer: buffer,
|
||||
fileName: filename,
|
||||
workspaceId,
|
||||
filePrincipal,
|
||||
ownerKey,
|
||||
signal,
|
||||
})
|
||||
@@ -154,6 +165,23 @@ export const GET = withRouteHandler(
|
||||
return await handleLocalFilePublic(fullPath)
|
||||
}
|
||||
|
||||
const storageContext = inferContextFromKey(cloudKey)
|
||||
const workspacePrincipal =
|
||||
storageContext === 'workspace'
|
||||
? await internalWorkspaceFileServeAuth.authenticate(request, { path })
|
||||
: undefined
|
||||
const legacyAuthResult = workspacePrincipal
|
||||
? undefined
|
||||
: await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (legacyAuthResult && (!legacyAuthResult.success || !legacyAuthResult.userId)) {
|
||||
logger.warn('Unauthorized file access attempt', {
|
||||
path,
|
||||
error: legacyAuthResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const query = fileServeQuerySchema.parse({
|
||||
raw: request.nextUrl.searchParams.get('raw'),
|
||||
preview: request.nextUrl.searchParams.get('preview'),
|
||||
@@ -165,17 +193,12 @@ export const GET = withRouteHandler(
|
||||
versioned: query.v != null,
|
||||
}
|
||||
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn('Unauthorized file access attempt', {
|
||||
path,
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (workspacePrincipal) {
|
||||
return await handleWorkspaceFile(cloudKey, workspacePrincipal, options, request)
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const userId = legacyAuthResult?.userId
|
||||
if (!userId) throw new Error('Authenticated file serve request is missing a user ID')
|
||||
|
||||
if (isUsingCloudStorage()) {
|
||||
return await handleCloudProxy(cloudKey, userId, options, request.signal)
|
||||
@@ -183,6 +206,11 @@ export const GET = withRouteHandler(
|
||||
|
||||
return await handleLocalFile(cloudKey, userId, options, request.signal)
|
||||
} catch (error) {
|
||||
if (error instanceof InternalUnauthenticatedError) {
|
||||
logger.warn('Unauthorized file access attempt', { error: error.message })
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// An in-progress/incomplete doc source fails to compile — this is expected
|
||||
// mid-generation, not a server fault. Return 409 (not 500) so it isn't an
|
||||
// alarming error; the client re-fetches once the doc finishes (the serve
|
||||
@@ -194,6 +222,15 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
|
||||
}
|
||||
|
||||
const orchestrationError = asOrchestrationError(
|
||||
concealCrossTenantResourceError(error, 'File not found')
|
||||
)
|
||||
if (orchestrationError?.code === 'not_found') {
|
||||
const notFound = new FileNotFoundError('File not found')
|
||||
logServeFailure('Error serving file:', notFound)
|
||||
return createErrorResponse(notFound)
|
||||
}
|
||||
|
||||
logServeFailure('Error serving file:', error)
|
||||
|
||||
if (error instanceof FileNotFoundError) {
|
||||
@@ -205,6 +242,45 @@ export const GET = withRouteHandler(
|
||||
}
|
||||
)
|
||||
|
||||
async function handleWorkspaceFile(
|
||||
key: string,
|
||||
principal: Principal,
|
||||
options: ServeOptions,
|
||||
request: NextRequest
|
||||
): Promise<NextResponse> {
|
||||
const workspaceId = getWorkspaceIdForCompile(key)
|
||||
if (!workspaceId) throw new FileNotFoundError(`File not found: ${key}`)
|
||||
|
||||
const { file, content } = await readWorkspaceFileContentByKey.execute({
|
||||
principal,
|
||||
input: { key, assertedWorkspaceId: workspaceId },
|
||||
request,
|
||||
})
|
||||
const ownerKey = `user:${requirePrincipalSubjectUserId(principal)}`
|
||||
const resolved = await resolveServableBytes({
|
||||
buffer: content,
|
||||
filename: file.name,
|
||||
storageKey: key,
|
||||
workspaceId,
|
||||
options,
|
||||
ownerKey,
|
||||
filePrincipal: principal,
|
||||
signal: request.signal,
|
||||
})
|
||||
|
||||
logger.info('Workspace file served', {
|
||||
fileId: file.id,
|
||||
workspaceId,
|
||||
size: resolved.buffer.length,
|
||||
})
|
||||
return createFileResponse({
|
||||
buffer: resolved.buffer,
|
||||
contentType: resolved.contentType,
|
||||
filename: file.name,
|
||||
cacheControl: resolveServeCacheControl(options.versioned, 'workspace'),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleLocalFile(
|
||||
filename: string,
|
||||
userId: string,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticateApiKey: vi.fn(),
|
||||
updateLastUsed: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false }))
|
||||
vi.mock('@/lib/api-key/service', () => ({
|
||||
authenticateApiKeyFromHeader: mocks.authenticateApiKey,
|
||||
updateApiKeyLastUsed: mocks.updateLastUsed,
|
||||
}))
|
||||
|
||||
import { authenticateV1Request } from '@/app/api/v1/auth'
|
||||
|
||||
describe('v1 API key authentication', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('constructs a personal API-key Principal from canonical key identity', async () => {
|
||||
mocks.authenticateApiKey.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
keyId: 'key-1',
|
||||
keyType: 'personal',
|
||||
})
|
||||
|
||||
await expect(
|
||||
authenticateV1Request(
|
||||
new NextRequest('http://localhost/api/v1/files', {
|
||||
headers: { 'x-api-key': 'secret' },
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
authenticated: true,
|
||||
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('constructs a workspace API-key Principal without borrowing the creator identity', async () => {
|
||||
mocks.authenticateApiKey.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'creator-1',
|
||||
keyId: 'key-1',
|
||||
keyType: 'workspace',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
|
||||
const result = await authenticateV1Request(
|
||||
new NextRequest('http://localhost/api/v1/files', {
|
||||
headers: { 'x-api-key': 'secret' },
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.principal).toEqual({
|
||||
kind: 'workspace_api_key',
|
||||
workspaceId: 'workspace-1',
|
||||
keyId: 'key-1',
|
||||
})
|
||||
expect(result.principal).not.toHaveProperty('userId')
|
||||
})
|
||||
|
||||
it('fails closed when authenticated key identity is incomplete', async () => {
|
||||
mocks.authenticateApiKey.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'creator-1',
|
||||
keyId: 'key-1',
|
||||
keyType: 'workspace',
|
||||
})
|
||||
|
||||
await expect(
|
||||
authenticateV1Request(
|
||||
new NextRequest('http://localhost/api/v1/files', {
|
||||
headers: { 'x-api-key': 'secret' },
|
||||
})
|
||||
)
|
||||
).resolves.toEqual({ authenticated: false, error: 'Authentication failed' })
|
||||
expect(mocks.updateLastUsed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
@@ -11,6 +12,7 @@ export interface AuthResult {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -20,6 +22,11 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
|
||||
authenticated: true,
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
keyType: 'personal',
|
||||
principal: {
|
||||
kind: 'personal_api_key',
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
keyId: 'auth-disabled',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +50,35 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
|
||||
}
|
||||
}
|
||||
|
||||
await updateApiKeyLastUsed(result.keyId!)
|
||||
if (!result.keyId || !result.userId || !result.keyType) {
|
||||
throw new Error('Authenticated v1 API key is missing its canonical identity')
|
||||
}
|
||||
let principal: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal
|
||||
if (result.keyType === 'workspace') {
|
||||
if (!result.workspaceId) {
|
||||
throw new Error('Authenticated workspace API key is missing its workspace scope')
|
||||
}
|
||||
principal = {
|
||||
kind: 'workspace_api_key',
|
||||
workspaceId: result.workspaceId,
|
||||
keyId: result.keyId,
|
||||
}
|
||||
} else {
|
||||
principal = {
|
||||
kind: 'personal_api_key',
|
||||
userId: result.userId,
|
||||
keyId: result.keyId,
|
||||
}
|
||||
}
|
||||
|
||||
await updateApiKeyLastUsed(result.keyId)
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: result.userId!,
|
||||
userId: result.userId,
|
||||
workspaceId: result.workspaceId,
|
||||
keyType: result.keyType,
|
||||
principal,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error', { error })
|
||||
|
||||
@@ -8,24 +8,27 @@ const {
|
||||
mockCheckRateLimit,
|
||||
mockValidateWorkspaceAccess,
|
||||
mockGetWorkspaceFile,
|
||||
mockFetchServableWorkspaceFileBuffer,
|
||||
mockDownloadWorkspaceFileStream,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockValidateWorkspaceAccess: vi.fn(),
|
||||
mockGetWorkspaceFile: vi.fn(),
|
||||
mockFetchServableWorkspaceFileBuffer: vi.fn(),
|
||||
mockDownloadWorkspaceFileStream: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
checkRateLimit: mockCheckRateLimit,
|
||||
createRateLimitResponse: () => new Response('rate limited', { status: 429 }),
|
||||
requireRateLimitPrincipal: (rateLimit: { principal: unknown }) => rateLimit.principal,
|
||||
validateWorkspaceAccess: mockValidateWorkspaceAccess,
|
||||
v1ValidationErrorResponse: (e: { issues: unknown[] }) =>
|
||||
NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }),
|
||||
}))
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
getWorkspaceFile: mockGetWorkspaceFile,
|
||||
fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
|
||||
}))
|
||||
vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({
|
||||
downloadWorkspaceFileStream: { execute: mockDownloadWorkspaceFileStream },
|
||||
}))
|
||||
vi.mock('@/lib/workspace-files/orchestration', () => ({
|
||||
performDeleteWorkspaceFileItems: vi.fn(),
|
||||
@@ -37,7 +40,7 @@ vi.mock('@sim/audit', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
|
||||
|
||||
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { GET } from '@/app/api/v1/files/[fileId]/route'
|
||||
|
||||
const WORKSPACE_ID = 'ws-1'
|
||||
@@ -45,6 +48,11 @@ const FILE_ID = 'file-1'
|
||||
const context = { params: Promise.resolve({ fileId: FILE_ID }) }
|
||||
|
||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
const PRINCIPAL = {
|
||||
kind: 'personal_api_key' as const,
|
||||
userId: 'user-1',
|
||||
keyId: 'key-1',
|
||||
}
|
||||
|
||||
function request() {
|
||||
return createMockRequest(
|
||||
@@ -71,16 +79,31 @@ function generatedDocument(name = 'report.docx') {
|
||||
}
|
||||
}
|
||||
|
||||
function renderedDownload(buffer: Buffer) {
|
||||
return {
|
||||
file: generatedDocument(),
|
||||
stream: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(buffer)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
contentLength: buffer.length,
|
||||
contentType: DOCX_MIME,
|
||||
}
|
||||
}
|
||||
|
||||
describe('v1 file download', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
|
||||
mockCheckRateLimit.mockResolvedValue({
|
||||
allowed: true,
|
||||
userId: 'user-1',
|
||||
principal: PRINCIPAL,
|
||||
})
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(null)
|
||||
mockGetWorkspaceFile.mockResolvedValue(generatedDocument())
|
||||
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
|
||||
buffer: Buffer.from('PKrendered'),
|
||||
contentType: DOCX_MIME,
|
||||
})
|
||||
mockDownloadWorkspaceFileStream.mockResolvedValue(renderedDownload(Buffer.from('PKrendered')))
|
||||
})
|
||||
|
||||
it('serves the rendered bytes and the rendered content type', async () => {
|
||||
@@ -104,10 +127,7 @@ describe('v1 file download', () => {
|
||||
|
||||
it('reports Content-Length from the rendered bytes, not the declared source size', async () => {
|
||||
const rendered = Buffer.alloc(50_000)
|
||||
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
|
||||
buffer: rendered,
|
||||
contentType: DOCX_MIME,
|
||||
})
|
||||
mockDownloadWorkspaceFileStream.mockResolvedValue(renderedDownload(rendered))
|
||||
|
||||
const response = await GET(request(), context)
|
||||
|
||||
@@ -115,8 +135,8 @@ describe('v1 file download', () => {
|
||||
})
|
||||
|
||||
it('returns a retryable 409 while the artifact is still compiling', async () => {
|
||||
mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
|
||||
new DocCompileUserError('Document is still being generated')
|
||||
mockDownloadWorkspaceFileStream.mockRejectedValue(
|
||||
new OrchestrationError('conflict', 'Document is still being generated')
|
||||
)
|
||||
|
||||
const response = await GET(request(), context)
|
||||
@@ -127,11 +147,17 @@ describe('v1 file download', () => {
|
||||
})
|
||||
|
||||
it('404s a file that does not exist', async () => {
|
||||
mockGetWorkspaceFile.mockResolvedValue(null)
|
||||
mockDownloadWorkspaceFileStream.mockRejectedValue(
|
||||
new OrchestrationError('not_found', 'File not found')
|
||||
)
|
||||
|
||||
const response = await GET(request(), context)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled()
|
||||
expect(mockDownloadWorkspaceFileStream).toHaveBeenCalledWith({
|
||||
principal: PRINCIPAL,
|
||||
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID },
|
||||
request: expect.anything(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
fetchServableWorkspaceFileBuffer,
|
||||
getWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file'
|
||||
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
requireRateLimitPrincipal,
|
||||
v1ValidationErrorResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
@@ -38,7 +36,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DownloadFileContract, request, context, {
|
||||
validationErrorResponse: v1ValidationErrorResponse,
|
||||
})
|
||||
@@ -47,64 +44,43 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
|
||||
const { fileId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Generated docs store their generation source; serve the rendered artifact.
|
||||
// Its content type is the rendered one, not the source MIME on the record.
|
||||
const { buffer, contentType } = await fetchServableWorkspaceFileBuffer(fileRecord)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileRecord.id,
|
||||
resourceName: fileRecord.name,
|
||||
description: `Downloaded file "${fileRecord.name}" via API`,
|
||||
metadata: {
|
||||
fileId: fileRecord.id,
|
||||
fileName: fileRecord.name,
|
||||
bytes: buffer.length,
|
||||
source: 'api_v1',
|
||||
},
|
||||
const principal = requireRateLimitPrincipal(rateLimit)
|
||||
const { file, stream, contentLength, contentType } = await downloadWorkspaceFileStream.execute({
|
||||
principal,
|
||||
input: { fileId, assertedWorkspaceId: workspaceId },
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'file_downloaded',
|
||||
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
if (principal.kind === 'personal_api_key') {
|
||||
captureServerEvent(
|
||||
principal.userId,
|
||||
'file_downloaded',
|
||||
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
// View, not copy — a second full copy would double peak memory for a large file.
|
||||
return new Response(
|
||||
new Uint8Array(buffer.buffer as ArrayBuffer, buffer.byteOffset, buffer.byteLength),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType || fileRecord.type || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
|
||||
'Content-Length': String(buffer.length),
|
||||
'X-File-Id': fileRecord.id,
|
||||
'X-File-Name': encodeURIComponent(fileRecord.name),
|
||||
'X-Uploaded-At':
|
||||
fileRecord.uploadedAt instanceof Date
|
||||
? fileRecord.uploadedAt.toISOString()
|
||||
: String(fileRecord.uploadedAt),
|
||||
},
|
||||
}
|
||||
)
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType || file.type || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||
'Content-Length': String(contentLength),
|
||||
'X-File-Id': file.id,
|
||||
'X-File-Name': encodeURIComponent(file.name),
|
||||
'X-Uploaded-At':
|
||||
file.uploadedAt instanceof Date ? file.uploadedAt.toISOString() : String(file.uploadedAt),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// A generated doc whose artifact is still compiling is retryable, not a fault:
|
||||
// without this the caller sees a 500 and has no reason to try again.
|
||||
if (isDocNotReadyError(error)) {
|
||||
return NextResponse.json({ error: docNotReadyMessage() }, { status: 409 })
|
||||
const orchestrationError = asOrchestrationError(error)
|
||||
if (orchestrationError && orchestrationError.code !== 'internal') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
orchestrationError.code === 'not_found' ? 'File not found' : orchestrationError.message,
|
||||
},
|
||||
{ status: statusForOrchestrationError(orchestrationError.code) }
|
||||
)
|
||||
}
|
||||
logger.error(`[${requestId}] Error downloading file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('checkRateLimit', () => {
|
||||
authenticated: true,
|
||||
userId: 'user-1',
|
||||
keyType: 'personal',
|
||||
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
|
||||
})
|
||||
mockGetSubscription.mockResolvedValue({ plan: 'team' })
|
||||
mockGetRateLimit.mockReturnValue(TEAM_BUCKET)
|
||||
@@ -78,6 +79,16 @@ describe('checkRateLimit', () => {
|
||||
expect(result.limit).not.toBe(TEAM_BUCKET.refillRate)
|
||||
})
|
||||
|
||||
it('preserves the authenticated API-key Principal for application operations', async () => {
|
||||
const result = await checkRateLimit(request(), 'workflows')
|
||||
|
||||
expect(result.principal).toEqual({
|
||||
kind: 'personal_api_key',
|
||||
userId: 'user-1',
|
||||
keyId: 'key-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('never reports more remaining than the limit', async () => {
|
||||
const result = await checkRateLimit(request(), 'workflows')
|
||||
|
||||
@@ -196,6 +207,7 @@ describe('rate-limit snapshot context', () => {
|
||||
authenticated: true,
|
||||
userId: 'user-1',
|
||||
keyType: 'personal',
|
||||
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
|
||||
})
|
||||
mockGetSubscription.mockResolvedValue({ plan: 'team' })
|
||||
mockGetRateLimit.mockReturnValue(TEAM_BUCKET)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -63,6 +64,7 @@ export interface RateLimitResult {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -82,6 +84,18 @@ export function requireRateLimitUserId(rateLimit: RateLimitResult): string {
|
||||
return rateLimit.userId
|
||||
}
|
||||
|
||||
export function requireRateLimitPrincipal(
|
||||
rateLimit: RateLimitResult
|
||||
): PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal {
|
||||
if (!rateLimit.allowed) {
|
||||
throw new Error('Cannot authorize a denied public API request')
|
||||
}
|
||||
if (!rateLimit.principal) {
|
||||
throw new Error('Allowed public API request is missing its Principal')
|
||||
}
|
||||
return rateLimit.principal
|
||||
}
|
||||
|
||||
export async function checkRateLimit(
|
||||
request: NextRequest,
|
||||
endpoint: ApiEndpoint = 'logs'
|
||||
@@ -144,6 +158,7 @@ export async function checkRateLimit(
|
||||
userId,
|
||||
workspaceId: auth.workspaceId,
|
||||
keyType: auth.keyType,
|
||||
principal: auth.principal,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Rate limit check error', { error })
|
||||
|
||||
@@ -84,12 +84,14 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
}))
|
||||
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
|
||||
fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer,
|
||||
findWorkspaceFileRecord: mockFindWorkspaceFileRecord,
|
||||
getSandboxWorkspaceFilePath: mockGetSandboxWorkspaceFilePath,
|
||||
listWorkspaceFiles: mockListWorkspaceFiles,
|
||||
}))
|
||||
vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({
|
||||
fetchAuthorizedServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
|
||||
listWorkspaceFileFolders: mockListWorkspaceFileFolders,
|
||||
}))
|
||||
|
||||
@@ -30,7 +30,6 @@ import { queryRows } from '@/lib/table/rows/service'
|
||||
import { getTableById, listTables } from '@/lib/table/service'
|
||||
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
|
||||
import {
|
||||
fetchServableWorkspaceFileBuffer,
|
||||
findWorkspaceFileRecord,
|
||||
getSandboxWorkspaceFilePath,
|
||||
type WorkspaceFileRecord,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
hasCloudStorage,
|
||||
} from '@/lib/uploads/core/storage-service'
|
||||
import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils'
|
||||
import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer'
|
||||
import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files'
|
||||
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
|
||||
import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record'
|
||||
@@ -202,7 +202,7 @@ async function pushWorkspaceFileMount(
|
||||
}
|
||||
|
||||
const { buffer, contentType } = rendersFromSource
|
||||
? await fetchServableWorkspaceFileBuffer(record, {
|
||||
? await fetchAuthorizedServableWorkspaceFileBuffer(record, principal, {
|
||||
maxBytes: Math.min(MAX_FILE_SIZE, remainingBudget),
|
||||
}).catch((error) => {
|
||||
if (!isPayloadSizeLimitError(error)) throw error
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
executeInSandboxMock,
|
||||
executeShellInSandboxMock,
|
||||
loadCompiledDocMock,
|
||||
publishCompiledDocArtifactMock,
|
||||
readWorkspaceFileContentMock,
|
||||
readWorkspaceFileMetadataMock,
|
||||
storeCompiledDocMock,
|
||||
@@ -15,6 +16,7 @@ const {
|
||||
executeInSandboxMock: vi.fn(),
|
||||
executeShellInSandboxMock: vi.fn(),
|
||||
loadCompiledDocMock: vi.fn(),
|
||||
publishCompiledDocArtifactMock: vi.fn(),
|
||||
readWorkspaceFileContentMock: vi.fn(),
|
||||
readWorkspaceFileMetadataMock: vi.fn(),
|
||||
storeCompiledDocMock: vi.fn(),
|
||||
@@ -35,6 +37,8 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () =>
|
||||
}))
|
||||
vi.mock('./doc-compiled-store', () => ({
|
||||
loadCompiledDoc: loadCompiledDocMock,
|
||||
loadPublishedCompiledDoc: vi.fn(),
|
||||
publishCompiledDocArtifact: publishCompiledDocArtifactMock,
|
||||
storeCompiledDoc: storeCompiledDocMock,
|
||||
}))
|
||||
|
||||
@@ -245,5 +249,11 @@ describe('collectReferencedFileIds', () => {
|
||||
)
|
||||
expect(readWorkspaceFileContentMock).not.toHaveBeenCalled()
|
||||
expect(executeInSandboxMock).not.toHaveBeenCalled()
|
||||
expect(publishCompiledDocArtifactMock).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
`image = await getFileBase64('${ID}')`,
|
||||
'pdf',
|
||||
expect.stringContaining(ID)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { Principal } from '@sim/auth/principal'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
|
||||
import {
|
||||
loadCompiledDoc,
|
||||
loadPublishedCompiledDoc,
|
||||
publishCompiledDocArtifact,
|
||||
storeCompiledDoc,
|
||||
} from '@/lib/copilot/tools/server/files/doc-compiled-store'
|
||||
import { isDocSandboxEnabled } from '@/lib/core/config/env-flags'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
import {
|
||||
@@ -15,7 +21,6 @@ import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read
|
||||
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
|
||||
import { getContentType } from '@/app/api/files/utils'
|
||||
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
|
||||
import { loadCompiledDoc, storeCompiledDoc } from './doc-compiled-store'
|
||||
|
||||
const logger = createLogger('CopilotDocCompile')
|
||||
|
||||
@@ -567,6 +572,14 @@ export async function compileDoc(args: CompileArgs): Promise<CompiledDocResult>
|
||||
referencedImages.artifactIdentity
|
||||
)
|
||||
if (existing) {
|
||||
if (referencedImages.artifactIdentity) {
|
||||
await publishCompiledDocArtifact(
|
||||
workspaceId,
|
||||
source,
|
||||
fmt.ext,
|
||||
referencedImages.artifactIdentity
|
||||
)
|
||||
}
|
||||
const contributingFiles = referencedImageIdentities(referencedImages)
|
||||
return {
|
||||
buffer: existing,
|
||||
@@ -588,6 +601,7 @@ export async function loadCompiledDocByExt(
|
||||
ext: string,
|
||||
options: {
|
||||
allowLegacyReferencedArtifact?: boolean
|
||||
allowPublishedReferencedArtifact?: boolean
|
||||
filePrincipal?: Principal
|
||||
} = {}
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
@@ -599,6 +613,10 @@ export async function loadCompiledDocByExt(
|
||||
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
|
||||
return buffer ? { buffer, contentType: fmt.contentType } : null
|
||||
}
|
||||
if (options.allowPublishedReferencedArtifact) {
|
||||
const publishedBuffer = await loadPublishedCompiledDoc(workspaceId, source, fmt.ext)
|
||||
if (publishedBuffer) return { buffer: publishedBuffer, contentType: fmt.contentType }
|
||||
}
|
||||
if (!options.allowLegacyReferencedArtifact) return null
|
||||
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
|
||||
return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null
|
||||
@@ -655,7 +673,7 @@ export async function resolveServableDoc(
|
||||
workspaceId,
|
||||
storedBytes.toString('utf-8'),
|
||||
fmt.ext,
|
||||
{ allowLegacyReferencedArtifact: true }
|
||||
{ allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
|
||||
)
|
||||
return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' }
|
||||
} catch (error) {
|
||||
@@ -743,7 +761,16 @@ export async function resolveServableDocBytes(args: {
|
||||
return compileDocInLegacySandbox({ source, fileName, workspaceId, ownerKey, signal }, fmt)
|
||||
}
|
||||
const referencedFileIds = collectReferencedFileIds(source)
|
||||
if (referencedFileIds.size > 0 && filePrincipal) {
|
||||
if (referencedFileIds.size > 0) {
|
||||
if (!filePrincipal) {
|
||||
const published = await loadCompiledDocByExt(workspaceId, source, extNoDot, {
|
||||
allowPublishedReferencedArtifact: true,
|
||||
})
|
||||
if (published) return published
|
||||
throw new Error(
|
||||
'Referenced document resolution requires an authorized workspace file principal'
|
||||
)
|
||||
}
|
||||
return compileDoc({ source, fileName, workspaceId, filePrincipal, ownerKey, signal })
|
||||
}
|
||||
const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot, {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockDownloadFile, mockHeadObject, mockUploadFile } = vi.hoisted(() => ({
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockHeadObject: vi.fn(),
|
||||
mockUploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
downloadFile: mockDownloadFile,
|
||||
headObject: mockHeadObject,
|
||||
uploadFile: mockUploadFile,
|
||||
}))
|
||||
|
||||
import {
|
||||
loadPublishedCompiledDoc,
|
||||
storeCompiledDoc,
|
||||
} from '@/lib/copilot/tools/server/files/doc-compiled-store'
|
||||
|
||||
describe('compiled document publication', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHeadObject.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('publishes a source-keyed pointer after storing a dependency-bound artifact', async () => {
|
||||
await storeCompiledDoc(
|
||||
'workspace-1',
|
||||
'source',
|
||||
'pdf',
|
||||
'application/pdf',
|
||||
Buffer.from('%PDF-artifact'),
|
||||
'dependency-identity'
|
||||
)
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockUploadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
file: Buffer.from(
|
||||
JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' })
|
||||
),
|
||||
contentType: 'application/json',
|
||||
context: 'copilot',
|
||||
preserveKey: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('loads only the exact dependency-bound artifact named by the published pointer', async () => {
|
||||
mockHeadObject.mockResolvedValue({ size: 1 })
|
||||
mockDownloadFile
|
||||
.mockResolvedValueOnce(
|
||||
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
|
||||
)
|
||||
.mockResolvedValueOnce(Buffer.from('%PDF-artifact'))
|
||||
|
||||
await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).resolves.toEqual(
|
||||
Buffer.from('%PDF-artifact')
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockDownloadFile.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ context: 'copilot' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails fast on a malformed published pointer', async () => {
|
||||
mockHeadObject.mockResolvedValue({ size: 1 })
|
||||
mockDownloadFile.mockResolvedValueOnce(Buffer.from('{not-json'))
|
||||
|
||||
await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow(
|
||||
'Published compiled document pointer is malformed'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
|
||||
const logger = createLogger('CopilotDocCompiledStore')
|
||||
|
||||
@@ -30,6 +30,41 @@ function compiledArtifactKey(
|
||||
return `copilot-doc-compiled/${workspaceId}/${hash}.${ext}`
|
||||
}
|
||||
|
||||
function publishedArtifactPointerKey(workspaceId: string, source: string, ext: string): string {
|
||||
const sourceHash = createHash('sha256').update(source, 'utf-8').digest('hex')
|
||||
return `copilot-doc-compiled/${workspaceId}/${sourceHash}.${ext}.published.json`
|
||||
}
|
||||
|
||||
interface PublishedArtifactPointer {
|
||||
version: 1
|
||||
referencedInputIdentity: string
|
||||
}
|
||||
|
||||
async function loadPublishedArtifactPointer(key: string): Promise<PublishedArtifactPointer | null> {
|
||||
const stored = await headObject(key, 'copilot')
|
||||
if (!stored) return null
|
||||
const encoded = await downloadFile({ key, context: 'copilot' })
|
||||
|
||||
let decoded: unknown
|
||||
try {
|
||||
decoded = JSON.parse(encoded.toString('utf-8'))
|
||||
} catch {
|
||||
throw new Error(`Published compiled document pointer is malformed: ${key}`)
|
||||
}
|
||||
if (
|
||||
typeof decoded !== 'object' ||
|
||||
decoded === null ||
|
||||
!('version' in decoded) ||
|
||||
decoded.version !== 1 ||
|
||||
!('referencedInputIdentity' in decoded) ||
|
||||
typeof decoded.referencedInputIdentity !== 'string' ||
|
||||
!decoded.referencedInputIdentity
|
||||
) {
|
||||
throw new Error(`Published compiled document pointer is invalid: ${key}`)
|
||||
}
|
||||
return { version: 1, referencedInputIdentity: decoded.referencedInputIdentity }
|
||||
}
|
||||
|
||||
/** Loads the compiled binary for the current source, or null if not yet built. */
|
||||
export async function loadCompiledDoc(
|
||||
workspaceId: string,
|
||||
@@ -45,6 +80,56 @@ export async function loadCompiledDoc(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the exact dependency-bound artifact that was produced under an authorized Principal.
|
||||
* Public shares consume this pointer without gaining authority to read the referenced workspace
|
||||
* files or compile arbitrary source themselves.
|
||||
*/
|
||||
export async function publishCompiledDocArtifact(
|
||||
workspaceId: string,
|
||||
source: string,
|
||||
ext: string,
|
||||
referencedInputIdentity: string
|
||||
): Promise<void> {
|
||||
if (!referencedInputIdentity) {
|
||||
throw new Error('Published compiled document identity must not be empty')
|
||||
}
|
||||
const key = publishedArtifactPointerKey(workspaceId, source, ext)
|
||||
const existing = await loadPublishedArtifactPointer(key)
|
||||
if (existing?.referencedInputIdentity === referencedInputIdentity) return
|
||||
const pointer: PublishedArtifactPointer = { version: 1, referencedInputIdentity }
|
||||
try {
|
||||
await uploadFile({
|
||||
file: Buffer.from(JSON.stringify(pointer), 'utf-8'),
|
||||
fileName: `doc.${ext}.published.json`,
|
||||
contentType: 'application/json',
|
||||
context: 'copilot',
|
||||
customKey: key,
|
||||
preserveKey: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish compiled doc artifact', {
|
||||
key,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
throw toError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads an artifact previously published by an authorized compile, or null before cutover. */
|
||||
export async function loadPublishedCompiledDoc(
|
||||
workspaceId: string,
|
||||
source: string,
|
||||
ext: string
|
||||
): Promise<Buffer | null> {
|
||||
const key = publishedArtifactPointerKey(workspaceId, source, ext)
|
||||
const pointer = await loadPublishedArtifactPointer(key)
|
||||
if (!pointer) return null
|
||||
const artifact = await loadCompiledDoc(workspaceId, source, ext, pointer.referencedInputIdentity)
|
||||
if (!artifact) throw new Error(`Published compiled document artifact is missing: ${key}`)
|
||||
return artifact
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the compiled binary as the source's associated S3 artifact.
|
||||
*
|
||||
@@ -71,6 +156,9 @@ export async function storeCompiledDoc(
|
||||
customKey: key,
|
||||
preserveKey: true,
|
||||
})
|
||||
if (referencedInputIdentity) {
|
||||
await publishCompiledDocArtifact(workspaceId, source, ext, referencedInputIdentity)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to store compiled doc artifact', {
|
||||
key,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
mockExecuteInSandbox,
|
||||
mockLoadCompiledDoc,
|
||||
mockLoadPublishedCompiledDoc,
|
||||
mockPublishCompiledDocArtifact,
|
||||
mockReadWorkspaceFileContent,
|
||||
mockReadWorkspaceFileMetadata,
|
||||
mockRunSandboxTask,
|
||||
@@ -14,6 +16,8 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecuteInSandbox: vi.fn(),
|
||||
mockLoadCompiledDoc: vi.fn(),
|
||||
mockLoadPublishedCompiledDoc: vi.fn(),
|
||||
mockPublishCompiledDocArtifact: vi.fn(),
|
||||
mockReadWorkspaceFileContent: vi.fn(),
|
||||
mockReadWorkspaceFileMetadata: vi.fn(),
|
||||
mockRunSandboxTask: vi.fn(),
|
||||
@@ -38,6 +42,8 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () =>
|
||||
}))
|
||||
vi.mock('./doc-compiled-store', () => ({
|
||||
loadCompiledDoc: mockLoadCompiledDoc,
|
||||
loadPublishedCompiledDoc: mockLoadPublishedCompiledDoc,
|
||||
publishCompiledDocArtifact: mockPublishCompiledDocArtifact,
|
||||
storeCompiledDoc: mockStoreCompiledDoc,
|
||||
}))
|
||||
vi.mock('@/app/api/files/utils', () => ({
|
||||
@@ -72,6 +78,7 @@ describe('resolveServableDocBytes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setEnvFlags({ isDocSandboxEnabled: true })
|
||||
mockLoadPublishedCompiledDoc.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('swaps generated-doc source for the compiled artifact + binary content type', async () => {
|
||||
@@ -201,6 +208,65 @@ describe('resolveServableDocBytes', () => {
|
||||
expect(mockStoreCompiledDoc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves the dependency-bound artifact published by an authorized compile', async () => {
|
||||
const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8')
|
||||
const publishedArtifact = Buffer.from('%PDF-published')
|
||||
mockLoadPublishedCompiledDoc.mockResolvedValue(publishedArtifact)
|
||||
|
||||
await expect(resolveServableDoc(WORKSPACE_ID, source, 'report.pdf')).resolves.toEqual({
|
||||
kind: 'artifact',
|
||||
buffer: publishedArtifact,
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
source.toString('utf-8'),
|
||||
'pdf'
|
||||
)
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when a private referenced document loses its Principal', async () => {
|
||||
const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8')
|
||||
|
||||
await expect(
|
||||
resolveServableDocBytes({
|
||||
rawBuffer: source,
|
||||
fileName: 'report.pdf',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Referenced document resolution requires an authorized workspace file principal'
|
||||
)
|
||||
expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
source.toString('utf-8'),
|
||||
'pdf'
|
||||
)
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a principal-less private adapter use output published by an authorized compile', async () => {
|
||||
const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8')
|
||||
const publishedArtifact = Buffer.from('%PDF-published')
|
||||
mockLoadPublishedCompiledDoc.mockResolvedValue(publishedArtifact)
|
||||
|
||||
await expect(
|
||||
resolveServableDocBytes({
|
||||
rawBuffer: source,
|
||||
fileName: 'report.pdf',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
buffer: publishedArtifact,
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
|
||||
expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled()
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not apply model-only provenance policy while serving a public legacy artifact', async () => {
|
||||
const source = Buffer.from(`image = await getFileBase64('reference-1')`, 'utf-8')
|
||||
const legacyArtifact = Buffer.from('%PDF-legacy')
|
||||
|
||||
@@ -81,10 +81,13 @@ describe('downloadFileFromStorage context derivation', () => {
|
||||
context: 'execution',
|
||||
}
|
||||
|
||||
await downloadServableFileFromStorage(userFile, 'req-1', createLogger('test'))
|
||||
const filePrincipal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }
|
||||
await downloadServableFileFromStorage(userFile, 'req-1', createLogger('test'), {
|
||||
filePrincipal,
|
||||
})
|
||||
|
||||
expect(mockResolveServableDocBytes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceId })
|
||||
expect.objectContaining({ workspaceId, filePrincipal })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Principal } from '@sim/auth/principal'
|
||||
import { createLogger, type Logger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
@@ -403,7 +404,12 @@ export async function downloadServableFileFromStorage(
|
||||
userFile: UserFile,
|
||||
requestId: string,
|
||||
logger: Logger,
|
||||
options: { maxBytes?: number; signal?: AbortSignal; ownerKey?: string } = {}
|
||||
options: {
|
||||
maxBytes?: number
|
||||
signal?: AbortSignal
|
||||
ownerKey?: string
|
||||
filePrincipal?: Principal
|
||||
} = {}
|
||||
): Promise<ServableFile> {
|
||||
const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
|
||||
maxBytes: options.maxBytes,
|
||||
@@ -430,6 +436,7 @@ export async function downloadServableFileFromStorage(
|
||||
rawBuffer: buffer,
|
||||
fileName: userFile.name,
|
||||
workspaceId,
|
||||
filePrincipal: options.filePrincipal,
|
||||
ownerKey: options.ownerKey,
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
@@ -3,5 +3,6 @@ export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-er
|
||||
export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters'
|
||||
export {
|
||||
internalSessionOrExecutorAuth,
|
||||
internalWorkspaceFileServeAuth,
|
||||
v2FileErrorPolicies,
|
||||
} from '@/lib/workspace-files/api/route-policies'
|
||||
|
||||
@@ -14,6 +14,15 @@ export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Generated-document serving authorizes the root file and every referenced input. Its executor
|
||||
* Principal is therefore intentionally workspace-scoped; a file-scoped Principal could authorize
|
||||
* the root or one dependency, but never the full dependency graph.
|
||||
*/
|
||||
export const internalWorkspaceFileServeAuth = createInternalSessionOrExecutorAuth({
|
||||
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
|
||||
})
|
||||
|
||||
export const v2FileErrorPolicies = {
|
||||
default: v2OrchestrationErrorPolicy,
|
||||
concealResourceAuthorization: createV2ResourceConcealmentPolicy({
|
||||
|
||||
@@ -30,11 +30,13 @@ const {
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; path?: string; name: string }>) =>
|
||||
new Map(folders.map((folder) => [folder.id, folder.path ?? folder.name])),
|
||||
fetchServableWorkspaceFileBuffer: mockFetchServable,
|
||||
listWorkspaceFileFolders: mockListFolders,
|
||||
listWorkspaceFiles: mockListFiles,
|
||||
loadWorkspaceFileOperationContext: mockLoadContext,
|
||||
}))
|
||||
vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({
|
||||
fetchAuthorizedServableWorkspaceFileBuffer: mockFetchServable,
|
||||
}))
|
||||
vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
permissionSatisfies: (actual: string | null, required: string) =>
|
||||
actual === 'admin' || actual === required || (actual === 'write' && required === 'read'),
|
||||
@@ -161,9 +163,11 @@ describe('downloadWorkspaceFileItems', () => {
|
||||
|
||||
expect(result.filesToZip[0].id).toBe('f1')
|
||||
expect(result.renderedDocuments.get('f1')).toEqual(Buffer.from('rendered'))
|
||||
expect(mockFetchServable).toHaveBeenCalledWith(expect.objectContaining({ id: 'f1' }), {
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
})
|
||||
expect(mockFetchServable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'f1' }),
|
||||
principal,
|
||||
{ maxBytes: 50 * 1024 * 1024 }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns typed validation and conflict failures without recording audit', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AuditAction, AuditResourceType } from '@sim/audit'
|
||||
import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import {
|
||||
buildWorkspaceFileFolderPathMap,
|
||||
fetchServableWorkspaceFileBuffer,
|
||||
listWorkspaceFileFolders,
|
||||
listWorkspaceFiles,
|
||||
loadWorkspaceFileOperationContext,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
needsRenderedArtifact,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case'
|
||||
import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer'
|
||||
import { fileOperations } from '@/lib/workspace-files/application/operations'
|
||||
|
||||
export const MAX_ZIP_DOWNLOAD_FILES = 100
|
||||
@@ -61,10 +62,12 @@ function validationError(message: string): never {
|
||||
async function executeDownloadWorkspaceFileItems({
|
||||
input,
|
||||
context,
|
||||
}: {
|
||||
input: DownloadWorkspaceFileItemsInput
|
||||
context: Awaited<ReturnType<typeof resolveDownloadContext>>
|
||||
}): Promise<DownloadWorkspaceFileItemsResult> {
|
||||
principal,
|
||||
}: AuthorizedWorkspaceUseCaseContext<
|
||||
typeof fileOperations.download,
|
||||
DownloadWorkspaceFileItemsInput,
|
||||
Awaited<ReturnType<typeof resolveDownloadContext>>
|
||||
>): Promise<DownloadWorkspaceFileItemsResult> {
|
||||
const fileIds = [...new Set(input.fileIds)]
|
||||
const folderIds = [...new Set(input.folderIds)]
|
||||
if (fileIds.length > MAX_REQUESTED_FILE_IDS) {
|
||||
@@ -118,7 +121,9 @@ async function executeDownloadWorkspaceFileItems({
|
||||
const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes)
|
||||
const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES)
|
||||
try {
|
||||
const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance })
|
||||
const { buffer } = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, {
|
||||
maxBytes: allowance,
|
||||
})
|
||||
renderedBytes += buffer.length
|
||||
renderedDocuments.set(file.id, buffer)
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,11 +27,14 @@ vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
fetchServableWorkspaceFileBuffer: mocks.fetchServable,
|
||||
getWorkspaceFile: mocks.getFile,
|
||||
loadActiveWorkspaceFileContext: mocks.loadContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({
|
||||
fetchAuthorizedServableWorkspaceFileBuffer: mocks.fetchServable,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
downloadFileStream: mocks.downloadStream,
|
||||
}))
|
||||
@@ -194,6 +197,7 @@ describe('workspace file downloads', () => {
|
||||
|
||||
expect(mocks.fetchServable).toHaveBeenCalledWith(
|
||||
generatedDoc,
|
||||
SESSION,
|
||||
expect.objectContaining({ maxBytes: MAX_RENDERED_DOCUMENT_BYTES })
|
||||
)
|
||||
})
|
||||
@@ -202,7 +206,7 @@ describe('workspace file downloads', () => {
|
||||
it('routes a record with no content type by its extension', async () => {
|
||||
await downloadStream('file-1')
|
||||
|
||||
expect(mocks.fetchServable).toHaveBeenCalledWith(file, expect.anything())
|
||||
expect(mocks.fetchServable).toHaveBeenCalledWith(file, SESSION, expect.anything())
|
||||
expect(mocks.downloadStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import {
|
||||
type ActiveWorkspaceFileContext,
|
||||
fetchServableWorkspaceFileBuffer,
|
||||
getWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { downloadFileStream } from '@/lib/uploads/core/storage-service'
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
needsRenderedArtifact,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case'
|
||||
import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer'
|
||||
import { fileOperations } from '@/lib/workspace-files/application/operations'
|
||||
import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context'
|
||||
|
||||
@@ -93,9 +93,16 @@ export const downloadWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({
|
||||
* artifact that never appears. It stays a `conflict` only because the v2
|
||||
* envelope has no 422; the message is what distinguishes the two.
|
||||
*/
|
||||
async function resolveRenderedArtifact(file: DownloadWorkspaceFileResult['file']) {
|
||||
async function resolveRenderedArtifact(
|
||||
file: DownloadWorkspaceFileResult['file'],
|
||||
filePrincipal: AuthorizedWorkspaceUseCaseContext<
|
||||
typeof fileOperations.download,
|
||||
DownloadWorkspaceFileInput,
|
||||
ActiveWorkspaceFileContext
|
||||
>['principal']
|
||||
) {
|
||||
try {
|
||||
return await fetchServableWorkspaceFileBuffer(file, {
|
||||
return await fetchAuthorizedServableWorkspaceFileBuffer(file, filePrincipal, {
|
||||
maxBytes: MAX_RENDERED_DOCUMENT_BYTES,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -118,6 +125,7 @@ async function resolveRenderedArtifact(file: DownloadWorkspaceFileResult['file']
|
||||
|
||||
async function executeDownloadWorkspaceFileStream({
|
||||
context,
|
||||
principal,
|
||||
}: AuthorizedWorkspaceUseCaseContext<
|
||||
typeof fileOperations.download,
|
||||
DownloadWorkspaceFileInput,
|
||||
@@ -138,7 +146,7 @@ async function executeDownloadWorkspaceFileStream({
|
||||
* double peak memory.
|
||||
*/
|
||||
if (needsRenderedArtifact(file.type, file.name)) {
|
||||
const { buffer, contentType } = await resolveRenderedArtifact(file)
|
||||
const { buffer, contentType } = await resolveRenderedArtifact(file, principal)
|
||||
return {
|
||||
file,
|
||||
stream: new ReadableStream<Uint8Array>({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Principal } from '@sim/auth/principal'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
|
||||
const logger = createLogger('FetchServableWorkspaceFileBuffer')
|
||||
|
||||
/**
|
||||
* Resolves the rendered bytes of an already-authorized workspace file while preserving the
|
||||
* Principal for any referenced-file reads performed by the document compiler.
|
||||
*/
|
||||
export async function fetchAuthorizedServableWorkspaceFileBuffer(
|
||||
fileRecord: WorkspaceFileRecord,
|
||||
filePrincipal: Principal,
|
||||
options: { maxBytes?: number; signal?: AbortSignal; requestId?: string } = {}
|
||||
): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
return downloadServableFileFromStorage(
|
||||
{
|
||||
id: fileRecord.id,
|
||||
name: fileRecord.name,
|
||||
url: fileRecord.url ?? fileRecord.path,
|
||||
size: fileRecord.size,
|
||||
type: fileRecord.type,
|
||||
key: fileRecord.key,
|
||||
context: fileRecord.storageContext ?? 'workspace',
|
||||
},
|
||||
options.requestId ?? generateRequestId(),
|
||||
logger,
|
||||
{
|
||||
...options,
|
||||
filePrincipal,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchContent: vi.fn(),
|
||||
getFile: vi.fn(),
|
||||
getMetadata: vi.fn(),
|
||||
loadContext: vi.fn(),
|
||||
resolvePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
fetchWorkspaceFileBuffer: mocks.fetchContent,
|
||||
getWorkspaceFile: mocks.getFile,
|
||||
loadActiveWorkspaceFileContext: mocks.loadContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/server/metadata', () => ({
|
||||
getFileMetadataByKey: mocks.getMetadata,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
permissionSatisfies: () => true,
|
||||
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
|
||||
}))
|
||||
|
||||
import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
|
||||
|
||||
const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }
|
||||
const context = {
|
||||
fileId: 'file-1',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'billing-owner',
|
||||
}
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
workspaceId: 'workspace-1',
|
||||
name: 'report.pdf',
|
||||
key: 'workspace/workspace-1/report.pdf',
|
||||
path: '/api/files/serve/workspace/workspace-1/report.pdf',
|
||||
size: 6,
|
||||
type: 'text/x-python-pdf',
|
||||
uploadedBy: 'user-1',
|
||||
uploadedAt: new Date('2026-08-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-08-01T00:00:00Z'),
|
||||
}
|
||||
|
||||
describe('readWorkspaceFileContentByKey', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getMetadata.mockResolvedValue({
|
||||
id: file.id,
|
||||
workspaceId: file.workspaceId,
|
||||
key: file.key,
|
||||
context: 'workspace',
|
||||
})
|
||||
mocks.loadContext.mockResolvedValue(context)
|
||||
mocks.resolvePermission.mockResolvedValue('read')
|
||||
mocks.getFile.mockResolvedValue(file)
|
||||
mocks.fetchContent.mockResolvedValue(Buffer.from('source'))
|
||||
})
|
||||
|
||||
it('authorizes the canonical file before returning its stored bytes', async () => {
|
||||
await expect(
|
||||
readWorkspaceFileContentByKey.execute({
|
||||
principal,
|
||||
input: { key: file.key, assertedWorkspaceId: file.workspaceId },
|
||||
})
|
||||
).resolves.toEqual({ file, content: Buffer.from('source') })
|
||||
|
||||
expect(mocks.getFile).toHaveBeenCalledWith(file.workspaceId, file.id, {
|
||||
throwOnError: true,
|
||||
})
|
||||
expect(mocks.fetchContent).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('rejects a stale key instead of serving the file current at the same ID', async () => {
|
||||
mocks.getFile.mockResolvedValue({ ...file, key: `${file.key}.new` })
|
||||
|
||||
await expect(
|
||||
readWorkspaceFileContentByKey.execute({
|
||||
principal,
|
||||
input: { key: file.key, assertedWorkspaceId: file.workspaceId },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
expect(mocks.fetchContent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import {
|
||||
type ActiveWorkspaceFileContext,
|
||||
fetchWorkspaceFileBuffer,
|
||||
getWorkspaceFile,
|
||||
loadActiveWorkspaceFileContext,
|
||||
type WorkspaceFileRecord,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
|
||||
import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case'
|
||||
import { fileOperations } from '@/lib/workspace-files/application/operations'
|
||||
|
||||
export interface ReadWorkspaceFileContentByKeyInput {
|
||||
key: string
|
||||
assertedWorkspaceId?: string
|
||||
}
|
||||
|
||||
export interface ReadWorkspaceFileContentByKeyResult {
|
||||
file: WorkspaceFileRecord
|
||||
content: Buffer
|
||||
}
|
||||
|
||||
async function executeReadWorkspaceFileContentByKey({
|
||||
input,
|
||||
context,
|
||||
}: AuthorizedWorkspaceUseCaseContext<
|
||||
typeof fileOperations.readContent,
|
||||
ReadWorkspaceFileContentByKeyInput,
|
||||
ActiveWorkspaceFileContext
|
||||
>): Promise<ReadWorkspaceFileContentByKeyResult> {
|
||||
const file = await getWorkspaceFile(context.workspaceId, context.fileId, {
|
||||
throwOnError: true,
|
||||
})
|
||||
if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found')
|
||||
return { file, content: await fetchWorkspaceFileBuffer(file) }
|
||||
}
|
||||
|
||||
export const readWorkspaceFileContentByKey = defineAuthorizedWorkspaceFileUseCase({
|
||||
operation: fileOperations.readContent,
|
||||
async resolveContext({ input }) {
|
||||
const metadata = await getFileMetadataByKey(input.key, 'workspace')
|
||||
if (
|
||||
!metadata?.workspaceId ||
|
||||
(input.assertedWorkspaceId !== undefined &&
|
||||
input.assertedWorkspaceId !== metadata.workspaceId)
|
||||
) {
|
||||
throw new OrchestrationError('not_found', 'File not found')
|
||||
}
|
||||
const canonical = await loadActiveWorkspaceFileContext(metadata.id)
|
||||
if (!canonical || canonical.workspaceId !== metadata.workspaceId) {
|
||||
throw new OrchestrationError('not_found', 'File not found')
|
||||
}
|
||||
return canonical
|
||||
},
|
||||
execute: executeReadWorkspaceFileContentByKey,
|
||||
})
|
||||
Reference in New Issue
Block a user