Skip to content

Commit 96ab45e

Browse files
authored
fix(files): restore workspace read-path behavior after rendered delivery (#9064)
* fix(files): restore workspace read-path behavior after rendered delivery * fix(files): keep the delivery recheck for every principal and narrow the csv parser * fix(files): keep the receipt recheck's provenance read typed for existing callers
1 parent 5c7d838 commit 96ab45e

23 files changed

Lines changed: 392 additions & 151 deletions

File tree

‎.claude/rules/sim-content-delivery.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ own unless noted.
7777

7878
| Path | U | E | R / C |
7979
|---|---|---|---|
80-
| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Redacted | Delivered |
80+
| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Opaque: into a chat workbench with entries imported, so output is redacted; refused without one | Delivered |
8181
| File download (run_code proxy) | Refused before bytes enter workbench history | Recorded in workbench history | Delivered |
8282
| Requested mounts | Refused before code executes, listed in `excludedInputs`; later calls remain usable | Recorded in workbench history | Delivered |
8383
| File text, version text, `files read`, grep | 403 | Redacted (grep redacts before clipping) | Delivered |

‎.cursor/rules/sim-content-delivery.mdc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ own unless noted.
6868

6969
| Path | U | E | R / C |
7070
|---|---|---|---|
71-
| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Redacted | Delivered |
71+
| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Opaque: into a chat workbench with entries imported, so output is redacted; refused without one | Delivered |
7272
| File download (run_code proxy) | Refused before bytes enter workbench history | Recorded in workbench history | Delivered |
7373
| Requested mounts | Refused before code executes, listed in `excludedInputs`; later calls remain usable | Recorded in workbench history | Delivered |
7474
| File text, version text, `files read`, grep | 403 | Redacted (grep redacts before clipping) | Delivered |

‎apps/sim/app/api/files/serve/[...path]/route.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,5 +381,36 @@ describe('File Serve API Route', () => {
381381
const response = await serveVersionedDoc(true)
382382
expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate')
383383
})
384+
385+
it('serves stored bytes under the type their extension names, not the recorded type', async () => {
386+
mockResolveStoredFileContext.mockResolvedValue('workspace')
387+
mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id')
388+
mockAuthenticateWorkspaceFile.mockResolvedValue(principal)
389+
mockReadWorkspaceFileRecordByKey.mockResolvedValue({
390+
file: {
391+
id: 'file-1',
392+
workspaceId: 'test-workspace-id',
393+
name: 'export.csv',
394+
key: 'workspace/test-workspace-id/export.csv',
395+
type: 'application/vnd.ms-excel',
396+
size: 3,
397+
contentUpdatedAt: new Date('2026-09-06'),
398+
},
399+
})
400+
fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({
401+
buffer: Buffer.from('a,b'),
402+
contentType: 'application/vnd.ms-excel',
403+
})
404+
405+
const response = await GET(
406+
createMockRequest({
407+
url: 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/export.csv',
408+
}),
409+
createRouteContext({ path: ['workspace', 'test-workspace-id', 'export.csv'] })
410+
)
411+
412+
expect(response.status).toBe(200)
413+
expect(response.headers.get('Content-Type')).toBe('text/csv')
414+
})
384415
})
385416
})

‎apps/sim/app/api/files/serve/[...path]/route.ts‎

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1616
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
1717
import type { StorageContext } from '@/lib/uploads/config'
1818
import { readOrganizationChatAttachment } from '@/lib/uploads/contexts/organization-assistant/application'
19+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1920
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/file-manager'
2021
import { downloadFile } from '@/lib/uploads/core/storage-service'
2122
import { resolveServableDocBytes } from '@/lib/uploads/documents/compile'
@@ -24,7 +25,7 @@ import { FILE_CACHE_CONTROL, workspaceFileCacheControl } from '@/lib/uploads/ser
2425
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
2526
import { resolveStoredFileContext } from '@/lib/uploads/server/metadata'
2627
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
27-
import { inferContextFromKey } from '@/lib/uploads/utils/file'
28+
import { inferContextFromKey, resolveEffectiveMimeType } from '@/lib/uploads/utils/file'
2829
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
2930
import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer'
3031
import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery'
@@ -116,9 +117,6 @@ async function resolveServableBytes(params: {
116117
workspaceId: string | undefined
117118
options: ServeOptions
118119
ownerKey: string | undefined
119-
filePrincipal?: Principal
120-
/** The stored record's content type, where the caller has the record. */
121-
fileType?: string
122120
signal: AbortSignal | undefined
123121
}): Promise<ServableBytes> {
124122
// `raw` is the stored source, already bounded by the read that produced it, but it
@@ -146,29 +144,16 @@ async function resolveTransformedBytes(params: {
146144
workspaceId: string | undefined
147145
options: ServeOptions
148146
ownerKey: string | undefined
149-
filePrincipal?: Principal
150-
fileType?: string
151147
signal: AbortSignal | undefined
152148
}): Promise<ServableBytes> {
153-
const {
154-
buffer,
155-
filename,
156-
storageKey,
157-
workspaceId,
158-
options,
159-
ownerKey,
160-
filePrincipal,
161-
fileType,
162-
signal,
163-
} = params
149+
const { buffer, filename, storageKey, workspaceId, options, ownerKey, signal } = params
164150

165151
// The pdf model for pages: a page file stores its SOURCE (frontmatter +
166152
// markdown + sim: fences) and serving compiles it to the rendered document,
167153
// the same way a .pdf key stores its script and serves the binary. Raw
168154
// requests above still return the source; bespoke/legacy HTML falls through
169-
// untouched. Sim pages store an EXTENSIONLESS name — the record type marks
170-
// them; legacy pages still carry .html.
171-
if (fileType === SIM_PAGE_CONTENT_TYPE || filename.toLowerCase().endsWith('.html')) {
155+
// untouched.
156+
if (filename.toLowerCase().endsWith('.html')) {
172157
const text = buffer.toString('utf8')
173158
if (isSimPageSource(text)) {
174159
const rendered = Buffer.from(
@@ -195,9 +180,7 @@ async function resolveTransformedBytes(params: {
195180
const doc = await resolveServableDocBytes({
196181
rawBuffer: buffer,
197182
fileName: filename,
198-
sourceMime: fileType,
199183
workspaceId,
200-
filePrincipal,
201184
ownerKey,
202185
signal,
203186
})
@@ -246,9 +229,19 @@ function resolveServeCacheControl(
246229
): string | undefined {
247230
if (context === 'workspace') return workspaceFileCacheControl(versioned, dependsOnReferencedFiles)
248231
if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL
249-
return context === 'workspace' || dependsOnReferencedFiles
250-
? WORKSPACE_REVALIDATE_CACHE_CONTROL
251-
: undefined
232+
return dependsOnReferencedFiles ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined
233+
}
234+
235+
/**
236+
* The type a rendered workspace file is served under. A rendered Sim page is HTML; every other
237+
* representation is named by the file's extension, as {@link resolveServableDocBytes} names it for
238+
* local and cloud files, rather than by the stored record's type, so a `.csv` recorded as
239+
* `application/vnd.ms-excel` still opens inline.
240+
*/
241+
function renderedWorkspaceContentType(file: WorkspaceFileRecord, renderedType: string): string {
242+
return file.type === SIM_PAGE_CONTENT_TYPE && renderedType === 'text/html'
243+
? renderedType
244+
: resolveEffectiveMimeType(undefined, file.name)
252245
}
253246

254247
/**
@@ -418,7 +411,16 @@ export const GET = withRouteHandler(
418411
logger.info('Serve: document still compiling, returning 409', {
419412
message: error.message,
420413
})
421-
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
414+
return NextResponse.json(
415+
{ error: 'Document is still being generated' },
416+
{
417+
status: 409,
418+
headers: {
419+
'Cache-Control': FILE_CACHE_CONTROL.noStore,
420+
'X-Content-Type-Options': 'nosniff',
421+
},
422+
}
423+
)
422424
}
423425

424426
const orchestrationError = asOrchestrationError(
@@ -462,29 +464,32 @@ async function handleWorkspaceFile(
462464
if (!workspaceId) throw new FileNotFoundError(`File not found: ${key}`)
463465

464466
const input = { key, assertedWorkspaceId: workspaceId }
465-
const { file } = await readWorkspaceFileRecordByKey.execute({ principal, input, request })
466467
if (request.method === 'HEAD') {
468+
await readWorkspaceFileRecordByKey.execute({ principal, input, request })
467469
return new NextResponse(null, {
468470
headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' },
469471
})
470472
}
473+
let file: WorkspaceFileRecord
471474
let resolved: ServableBytes
472475
if (options.raw) {
473-
const { content } = await readWorkspaceFileContentByKey.execute({ principal, input, request })
476+
const read = await readWorkspaceFileContentByKey.execute({ principal, input, request })
477+
file = read.file
474478
resolved = {
475-
buffer: content,
479+
buffer: read.content,
476480
contentType: getContentType(file.name),
477481
dependsOnReferencedFiles: false,
478482
}
479483
} else {
484+
file = (await readWorkspaceFileRecordByKey.execute({ principal, input, request })).file
480485
const rendered = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, {
481486
maxBytes: MAX_BUFFERED_TRANSFER_BYTES,
482487
signal: request.signal,
483488
})
484489
const preview = options.preview ? await resolveServableImageBytes(rendered.buffer, key) : null
485490
resolved = {
486491
buffer: preview?.buffer ?? rendered.buffer,
487-
contentType: preview?.contentType ?? rendered.contentType,
492+
contentType: preview?.contentType ?? renderedWorkspaceContentType(file, rendered.contentType),
488493
dependsOnReferencedFiles: rendered.dependsOnReferencedFiles,
489494
}
490495
assertKnownSizeWithinLimit(
@@ -493,6 +498,7 @@ async function handleWorkspaceFile(
493498
'served file response'
494499
)
495500
await finishFileDelivery({
501+
principal,
496502
authorize: () => readWorkspaceFileRecordByKey.authorize({ principal, input, request }),
497503
receipt: rendered.receipt,
498504
})

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-
1010

1111
export const dynamic = 'force-dynamic'
1212

13+
/**
14+
* GET /api/workspaces/[id]/files/inline?key=<cloudKey>|fileId=<id>
15+
*
16+
* Serves an embedded workspace image. A URL naming the exact storage object that was streamed is
17+
* cached immutably (see {@link ReadWorkspaceInlineFileResult.contentAddressed}): a content write
18+
* never rewrites an object, and every document render asks for each image at least twice (the
19+
* editor's DOM, then its React node view) across two editor mounts, so revalidating re-downloaded
20+
* every image on every open. A URL naming the file, whose bytes move as it is edited, revalidates.
21+
*/
1322
export const GET = defineInternalBinaryRoute({
1423
contract: getInlineWorkspaceFileContract,
1524
auth: internalSessionAuth,

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,4 @@ describe('content-source resolveImageSrc', () => {
2323
'/api/files/public/tok_1/inline?fileId=wf_abc'
2424
)
2525
})
26-
it('preserves the canonical Project owner on public sources', () => {
27-
const owner = { entityType: 'project', entityId: 'project-1' } as const
28-
const source = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content', owner)
29-
expect(source.owner).toEqual(owner)
30-
expect(source.buildUrl('ignored')).toBe('/api/files/public/tok_1/content')
31-
})
3226
})

‎apps/sim/executor/utils/resolved-secret-trace-registry.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ export type ResolvedSecretIncompletenessReason =
7777
| 'table-run-state-provenance-unavailable'
7878
| 'mounted-file-provenance-unavailable'
7979
| 'workspace-file-provenance-unknown'
80-
| 'project-file-provenance-unavailable'
8180
| 'file-source-unidentified'
8281
| 'table-snapshot-unsafe-for-mount'
8382
| 'restored-provenance-untrusted'

‎apps/sim/hooks/use-file-content-source.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export interface ImageDimensionsSource {
5757
* this source so the same components work in both contexts.
5858
*/
5959
export interface FileContentSource {
60-
owner?: EditableFileOwner
6160
buildUrl: (key: string, opts?: FileContentUrlOptions) => string
6261
/**
6362
* Map an embedded image `src` to a display URL scoped to the current context: the in-app source
@@ -91,7 +90,6 @@ function inlineImageSource(
9190
owner?: EditableFileOwner
9291
): FileContentSource {
9392
return {
94-
owner,
9593
buildUrl,
9694
resolveImageSrc: (src) => {
9795
if (!src) return src

‎apps/sim/lib/file-parsers/csv-preview-slice.test.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,23 @@ describe('getCsvPreviewSlice', () => {
8282
})
8383
})
8484

85-
it('rejects an oversized streamed record before consuming the entire file', async () => {
85+
it('ends at an oversized streamed record with the rows before it, without consuming the file', async () => {
8686
let chunksRead = 0
8787
const source = Readable.from(
8888
(async function* () {
89-
yield Buffer.from('header\n"')
89+
yield Buffer.from('header\nfirst\n"')
9090
for (let i = 0; i < 1000; i++) {
9191
chunksRead++
9292
yield Buffer.alloc(16 * 1024, 'x')
9393
}
9494
})()
9595
)
9696
mockDownloadFileStream.mockResolvedValue(source)
97-
await expect(getCsvPreviewSlice(args)).rejects.toMatchObject({ code: 'CSV_MAX_RECORD_SIZE' })
97+
await expect(getCsvPreviewSlice(args)).resolves.toEqual({
98+
headers: ['header'],
99+
rows: [['first']],
100+
truncated: true,
101+
})
98102
expect(chunksRead).toBeLessThan(30)
99103
expect(source.destroyed).toBe(true)
100104
})
@@ -111,7 +115,40 @@ it('bounds bytes consumed even when blank records produce no preview rows', asyn
111115
})()
112116
)
113117
mockDownloadFileStream.mockResolvedValue(source)
114-
await expect(getCsvPreviewSlice(args)).rejects.toThrow('CSV preview')
118+
await expect(getCsvPreviewSlice(args)).resolves.toEqual({
119+
headers: ['header'],
120+
rows: [],
121+
truncated: true,
122+
})
115123
expect(chunksRead).toBeLessThan(140)
116124
expect(source.destroyed).toBe(true)
117125
})
126+
127+
it('returns the leading rows of a file whose rows exhaust the byte budget before the row cap', async () => {
128+
const wide = 'x'.repeat(10 * 1024)
129+
const source = Readable.from(
130+
(async function* () {
131+
yield Buffer.from('id,payload\n')
132+
for (let i = 0; i < CSV_PREVIEW_MAX_ROWS + 100; i++) yield Buffer.from(`${i},${wide}\n`)
133+
})()
134+
)
135+
mockDownloadFileStream.mockResolvedValue(source)
136+
const slice = await getCsvPreviewSlice(args)
137+
expect(slice.truncated).toBe(true)
138+
expect(slice.headers).toEqual(['id', 'payload'])
139+
expect(slice.rows.length).toBeGreaterThan(0)
140+
expect(slice.rows.length).toBeLessThan(CSV_PREVIEW_MAX_ROWS)
141+
expect(slice.rows.map(([id]) => id)).toEqual(slice.rows.map((_, index) => String(index)))
142+
})
143+
144+
it.each([
145+
['a quote followed by other characters', 'a,b\n1,2\n"x"y,3\n'],
146+
['a quote left open at the end of the file', 'a,b\n1,2\n"open,3'],
147+
])('skips a malformed record with %s instead of failing the preview', async (_, text) => {
148+
mockDownloadFileStream.mockResolvedValue(streamOf(text))
149+
await expect(getCsvPreviewSlice(args)).resolves.toEqual({
150+
headers: ['a', 'b'],
151+
rows: [['1', '2']],
152+
truncated: false,
153+
})
154+
})

0 commit comments

Comments
 (0)