diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index d8fcd3a3d3d..b85fe6df606 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + logPublicFileError, publicFileAuthDenied, publicFileBinaryErrorResponse, publicFileShareCredential, @@ -58,7 +59,7 @@ export const GET = withRouteHandler( request.headers.get('if-none-match') ) } catch (error) { - logger.error('Error serving public shared file:', error) + logPublicFileError(logger, 'Error serving public shared file:', error) return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 638eec5dda0..346fbeb88a5 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + logPublicFileError, publicFileAuthDenied, publicFileBinaryErrorResponse, publicFileShareCredential, @@ -48,7 +49,7 @@ export const GET = withRouteHandler( request.headers.get('if-none-match') ) } catch (error) { - logger.error('Error serving public inline image:', error) + logPublicFileError(logger, 'Error serving public inline image:', error) return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 11b9fcadbcb..cd72724fb04 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -11,7 +11,11 @@ import { OTP_IP_RATE_LIMIT } from '@/lib/core/security/otp' import { afterResponse } from '@/lib/core/utils/after-response' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { + logPublicFileError, + publicFileAuthDenied, + publicFileErrorResponse, +} from '@/lib/public-shares/api' import { requestPublicFileShareOtp, verifyPublicFileShareOtp, @@ -48,7 +52,7 @@ export const POST = withRouteHandler( afterResponse(accepted.deliver) return NextResponse.json({ message: 'Verification code sent' }) } catch (error) { - logger.error('Error processing OTP request:', error) + logPublicFileError(logger, 'Error processing OTP request:', error) return publicFileErrorResponse(error, 'Failed to process request') } } @@ -69,7 +73,7 @@ export const PUT = withRouteHandler( response.cookies.set(auth.cookie) return response } catch (error) { - logger.error('Error verifying OTP:', error) + logPublicFileError(logger, 'Error verifying OTP:', error) return publicFileErrorResponse(error, 'Failed to process request') } } diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 2c2aed33059..66c305c4e21 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -9,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + logPublicFileError, publicFileAuthDenied, publicFileErrorResponse, publicFileShareCredential, @@ -48,7 +49,7 @@ export const GET = withRouteHandler( ownerName, }) } catch (error) { - logger.error('Error fetching public file metadata:', error) + logPublicFileError(logger, 'Error fetching public file metadata:', error) return publicFileErrorResponse(error, 'Failed to fetch file') } } @@ -70,7 +71,7 @@ export const POST = withRouteHandler( response.cookies.set(auth.cookie) return response } catch (error) { - logger.error('Error authenticating public file share:', error) + logPublicFileError(logger, 'Error authenticating public file share:', error) return publicFileErrorResponse(error, 'Failed to authenticate') } } diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index 9718d8a1e86..bd0aebe3b97 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -6,7 +6,11 @@ import { parseRequest } from '@/lib/api/server' import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { + logPublicFileError, + publicFileAuthDenied, + publicFileErrorResponse, +} from '@/lib/public-shares/api' import { getPublicFileShareSsoEligibility } from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' @@ -47,7 +51,7 @@ export const POST = withRouteHandler( ? NextResponse.json({ eligible: result.eligible }) : publicFileAuthDenied(result) } catch (error) { - logger.error('Error checking public file SSO eligibility:', error) + logPublicFileError(logger, 'Error checking public file SSO eligibility:', error) return publicFileErrorResponse(error, 'Failed to process request') } } diff --git a/apps/sim/app/api/internal/file-doc/persist/route.ts b/apps/sim/app/api/internal/file-doc/persist/route.ts index 399791a0172..8719bb1fff5 100644 --- a/apps/sim/app/api/internal/file-doc/persist/route.ts +++ b/apps/sim/app/api/internal/file-doc/persist/route.ts @@ -3,8 +3,10 @@ import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { persistFileDocContract } from '@/lib/api/contracts/file-doc' +import { isOffAppHost } from '@/lib/api/mcp/host-routing' import { parseRequest } from '@/lib/api/server' import { persistFileDoc } from '@/lib/collab-doc/persist' +import { MAX_COLLAB_DOC_PERSIST_BODY_BYTES } from '@/lib/collab-doc/state' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/mothership/request/http' @@ -16,12 +18,19 @@ const logger = createLogger('FileDocPersistAPI') * the shared `x-api-key: INTERNAL_API_SECRET` secret, matching the header the realtime relay sends. * The relay owns the live doc but not the conversion engine or blob/DB access, so it ships the current * doc state here — the server-authoritative durable path that replaces the editor's client autosave. + * Excluded from the proxy matcher, so the route refuses the dedicated MCP host before auth itself. */ export const POST = withRouteHandler(async (request: NextRequest) => { + if (isOffAppHost(request)) return new NextResponse(null, { status: 404 }) const auth = checkInternalApiKey(request) if (!auth.success) return createUnauthorizedResponse() - const parsed = await parseRequest(persistFileDocContract, request, {}) + const parsed = await parseRequest( + persistFileDocContract, + request, + {}, + { maxBodyBytes: MAX_COLLAB_DOC_PERSIST_BODY_BYTES, bypassesProxy: true } + ) if (!parsed.success) return parsed.response const { workspaceId, fileId, userId, docState, expectedVersion } = parsed.data.body diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts index 4699695c7ad..a81097ab5d2 100644 --- a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts @@ -7,12 +7,9 @@ import { internalRateLimits, } from '@/lib/api/server/routes' import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' -import { MAX_COLLAB_DOC_STATE_BYTES } from '@/lib/collab-doc/state' +import { MAX_COLLAB_DOC_PERSIST_BODY_BYTES } from '@/lib/collab-doc/state' import { persistProjectFileDoc } from '@/lib/projects/files/application/documents' -/** A base64-encoded maximum snapshot plus 1 MiB for its JSON envelope (17 MiB). */ -const PERSIST_MAX_BODY_BYTES = Math.ceil(MAX_COLLAB_DOC_STATE_BYTES / 3) * 4 + 1024 * 1024 - const persist = defineInternalJsonRoute({ contract: projectFileDocPersistContract, operation: persistProjectFileDoc.operation, @@ -27,7 +24,7 @@ const persist = defineInternalJsonRoute({ expectedVersion: body.expectedVersion, }), useCase: persistProjectFileDoc, - parseOptions: { maxBodyBytes: PERSIST_MAX_BODY_BYTES, bypassesProxy: true }, + parseOptions: { maxBodyBytes: MAX_COLLAB_DOC_PERSIST_BODY_BYTES, bypassesProxy: true }, }) /** Excluded from the proxy matcher, so the route refuses the dedicated MCP host before auth itself. */ diff --git a/apps/sim/lib/collab-doc/state.ts b/apps/sim/lib/collab-doc/state.ts index 9af5c13e919..a0f7bbba020 100644 --- a/apps/sim/lib/collab-doc/state.ts +++ b/apps/sim/lib/collab-doc/state.ts @@ -7,6 +7,13 @@ import type { DbOrTx, DbTransaction } from '@/lib/db/types' /** Matches the decoded size of the persist endpoint's 16 MiB base64 snapshot limit. */ export const MAX_COLLAB_DOC_STATE_BYTES = 12 * 1024 * 1024 +/** + * Body cap of the relay's persist routes: a base64-encoded maximum snapshot plus 1 MiB for its JSON + * envelope (17 MiB). Both routes bypass the proxy, whose 10 MB buffer would truncate it. + */ +export const MAX_COLLAB_DOC_PERSIST_BODY_BYTES = + Math.ceil(MAX_COLLAB_DOC_STATE_BYTES / 3) * 4 + 1024 * 1024 + /** Reject oversized snapshots before copying, decoding, or writing them. */ export function assertCollabDocStateSize(docState: Uint8Array): void { if (docState.byteLength > MAX_COLLAB_DOC_STATE_BYTES) { diff --git a/apps/sim/lib/mothership/memories/cleanup-outbox.ts b/apps/sim/lib/mothership/memories/cleanup-outbox.ts index eb67bcf1267..cd7fa5e3037 100644 --- a/apps/sim/lib/mothership/memories/cleanup-outbox.ts +++ b/apps/sim/lib/mothership/memories/cleanup-outbox.ts @@ -29,9 +29,15 @@ const logger = createLogger('MemoryCleanup') const UNAVAILABLE_RETRY_MS = 60 * 60 * 1000 /** * Statuses meaning the worker does not serve cleanup yet, not that this request failed: a worker - * that predates the route answers 404 to the hosted key and 401 to a self-hosted customer key. + * that predates the route answers 404 to the hosted key. */ -const ENDPOINT_MISSING_STATUSES = new Set([401, 404, 405, 501]) +const ENDPOINT_MISSING_STATUSES = new Set([404, 405, 501]) +/** + * A worker that predates the route also answers this to a self-hosted customer key, but so does a + * worker that revoked the key for good: it waits like a missing route but spends an attempt, so a + * revoked key dead-letters after the default attempts (about ten hours), past a worker rollout. + */ +const UNAUTHORIZED_STATUS = 401 /** Non-strict so rows enqueued with the retired `credentialFingerprint` field still parse. */ const cleanupPayloadSchema = z.object({ @@ -74,16 +80,21 @@ export async function enqueueMemoryCleanup( } } -function waitForWorker(reason: string, eventId: string, detail?: Record) { +function waitForWorker( + reason: string, + eventId: string, + { consumeAttempt = false, ...detail }: { baseUrl?: string; consumeAttempt?: boolean } = {} +) { logger.warn(`Memory cleanup waiting: ${reason}`, { eventId, ...detail }) - return deferOutboxHandler(reason, UNAVAILABLE_RETRY_MS, false) + return deferOutboxHandler(reason, UNAVAILABLE_RETRY_MS, consumeAttempt) } /** * Sends the processor's current key only to a destination the processor itself configures, and * replays the enqueuing deployment's source env so the worker erases that deployment's notes. * A missing key, destination, or worker endpoint waits without consuming attempts: erasure must - * not dead-letter while configuration or the worker rollout catches up. + * not dead-letter while configuration or the worker rollout catches up. A rejected key waits too, + * but within the retry budget. */ export const memoryCleanupOutboxHandlers = { [MEMORY_CLEANUP_EVENT]: async (rawPayload, context) => { @@ -109,6 +120,10 @@ export const memoryCleanupOutboxHandlers = { await response.body?.cancel().catch(() => {}) if (ENDPOINT_MISSING_STATUSES.has(response.status)) return waitForWorker(`endpoint unavailable (${response.status})`, context.eventId) + if (response.status === UNAUTHORIZED_STATUS) + return waitForWorker('worker rejected the key (401)', context.eventId, { + consumeAttempt: true, + }) throw new Error(`Memory cleanup failed (${response.status})`) } MemoryCleanupResult.parse(await response.json()) diff --git a/apps/sim/lib/mothership/memories/cleanup.integration.ts b/apps/sim/lib/mothership/memories/cleanup.integration.ts index dd361ac1b2a..a1ee6900ab0 100644 --- a/apps/sim/lib/mothership/memories/cleanup.integration.ts +++ b/apps/sim/lib/mothership/memories/cleanup.integration.ts @@ -139,10 +139,10 @@ it('rolls cleanup back with deletion and retries committed account/target erasur expect(received.every((request) => request.key === 'memory-cleanup-fixture-key')).toBe(true) const first = events[0] - const replay = async (payload: unknown = first.payload) => { + const replay = async (payload: unknown = first.payload, attempts = 9) => { await db .update(outboxEvent) - .set({ status: 'pending', availableAt: new Date(0), attempts: 9, payload }) + .set({ status: 'pending', availableAt: new Date(0), attempts, payload }) .where(eq(outboxEvent.id, first.id)) await runtime.outbox.processOutboxEventById( first.id, @@ -161,11 +161,18 @@ it('rolls cleanup back with deletion and retries committed account/target erasur waitsAnHour(await replay()) expect(received).toHaveLength(before + 1) + status = 401 + const rejected = await replay(first.payload, 5) + expect(rejected).toMatchObject({ status: 'pending', attempts: 6 }) + expect(rejected.availableAt.getTime()).toBeGreaterThan(Date.now() + 50 * 60_000) + expect(await replay()).toMatchObject({ status: 'dead_letter', attempts: 10 }) + expect(received).toHaveLength(before + 3) + status = 200 waitsAnHour(await replay({ ...toRecord(first.payload), baseUrl: 'https://unconfigured.test' })) - expect(received).toHaveLength(before + 1) + expect(received).toHaveLength(before + 3) const legacy = await replay({ ...toRecord(first.payload), credentialFingerprint: 'retired' }) expect(legacy.status).toBe('completed') - expect(received).toHaveLength(before + 2) + expect(received).toHaveLength(before + 4) }, 30_000) diff --git a/apps/sim/lib/public-shares/api/index.ts b/apps/sim/lib/public-shares/api/index.ts index d1f19dddef0..d2c4112631f 100644 --- a/apps/sim/lib/public-shares/api/index.ts +++ b/apps/sim/lib/public-shares/api/index.ts @@ -1,5 +1,6 @@ export { publicFileShareCredential } from './credentials' export { + logPublicFileError, publicFileAuthDenied, publicFileBinaryErrorResponse, publicFileErrorResponse, diff --git a/apps/sim/lib/public-shares/api/responses.ts b/apps/sim/lib/public-shares/api/responses.ts index e47a7cd2e4f..e5432b741a7 100644 --- a/apps/sim/lib/public-shares/api/responses.ts +++ b/apps/sim/lib/public-shares/api/responses.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@sim/logger' import { NextResponse } from 'next/server' import { asOrchestrationError, @@ -39,6 +40,12 @@ function classifyPublicFileError(error: unknown, fallback: string) { } } +/** A classified client failure, such as a missing share or the wrong auth mode, is only a warning. */ +export function logPublicFileError(logger: Logger, message: string, error: unknown) { + if (classifyPublicFileError(error, '').status < 500) logger.warn(message, error) + else logger.error(message, error) +} + /** Conceal unavailable targets and driver failures while preserving classified domain failures. */ export function publicFileErrorResponse(error: unknown, fallback: string) { const result = classifyPublicFileError(error, fallback) diff --git a/apps/sim/lib/public-shares/application/read.ts b/apps/sim/lib/public-shares/application/read.ts index 9ff4ef28c7a..641c227981e 100644 --- a/apps/sim/lib/public-shares/application/read.ts +++ b/apps/sim/lib/public-shares/application/read.ts @@ -37,7 +37,11 @@ import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file' import { sniffImageContentType } from '@/lib/uploads/utils/validation' import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' -import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { + isSimPageSource, + ProjectPageForeignEmbedError, + SIM_PAGE_CONTENT_TYPE, +} from '@/lib/workspace-files/page-compile' import { renderSimPageDocument } from '@/lib/workspace-files/page-document' import { collectSimPageFileReferences, @@ -70,6 +74,16 @@ function present(snapshot: PublicFileShareSnapshot) { }, } } +/** A Project page embedding another owner's file reads as a missing input, as for members. */ +function renderSharedPage(source: string, snapshot: PublicFileShareSnapshot) { + try { + return renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + } catch (error) { + if (error instanceof ProjectPageForeignEmbedError) + throw new OrchestrationError('not_found', 'Document input not found') + throw error + } +} function requireRevision(current: WorkspaceFileRow, expected: WorkspaceFileRow) { if ( current.id !== expected.id || @@ -287,7 +301,7 @@ export async function readPublicFileShareContent({ (snapshot.file.contentType === SIM_PAGE_CONTENT_TYPE || snapshot.file.originalName.toLowerCase().endsWith('.html')) && isSimPageSource(source) - ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + ? renderSharedPage(source, snapshot) : undefined const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] if ( @@ -386,9 +400,7 @@ export async function readPublicFileShareInline({ operation, async (tx, snapshot): Promise => { requireRevision(snapshot.file, initial.file) - const pageHtml = isSimPageSource(source) - ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) - : undefined + const pageHtml = isSimPageSource(source) ? renderSharedPage(source, snapshot) : undefined const reference = fileId ? { fileId } : key ? { key } : null const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] const embedded = diff --git a/apps/sim/lib/workspace-files/search/artifact-ready.ts b/apps/sim/lib/workspace-files/search/artifact-ready.ts index 96e1b83fae9..c41698dc70f 100644 --- a/apps/sim/lib/workspace-files/search/artifact-ready.ts +++ b/apps/sim/lib/workspace-files/search/artifact-ready.ts @@ -4,7 +4,7 @@ import { workspaceFileSearchBuild, workspaceFileSearchRevision, } from '@sim/db/schema' -import { and, eq, or, sql } from 'drizzle-orm' +import { and, eq, inArray, or, sql } from 'drizzle-orm' import type { DbTransaction } from '@/lib/db/types' import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { FILE_SEARCH_ARTIFACT_READY_LOCK_TIMEOUT_MS } from '@/lib/workspace-files/search/constants' @@ -51,7 +51,8 @@ function artifactRevisionCondition(input: FileSearchArtifactCompletion, owner: E /** * Lock-free form of {@link markFileSearchArtifactReadyInTx}'s no-op test: the file's current - * revision is already built from this artifact, ready or still under a live build lease. + * revision is already built from this artifact, published (ready or skipped) or still under a live + * build lease. */ async function isFileSearchArtifactCurrent( input: FileSearchArtifactCompletion, @@ -69,7 +70,7 @@ async function isFileSearchArtifactCurrent( artifactRevisionCondition(input, owner), eq(workspaceFileSearchBuild.artifactKey, input.artifactKey), or( - eq(workspaceFileSearchRevision.status, 'ready'), + inArray(workspaceFileSearchRevision.status, ['ready', 'skipped']), and( eq(workspaceFileSearchRevision.status, 'pending'), sql`${workspaceFileSearchBuild.expiresAt} > clock_timestamp()` @@ -128,7 +129,9 @@ export async function markFileSearchArtifactReadyInTx( .for('update') if ( build?.artifactKey === input.artifactKey && - (state.status === 'ready' || (state.status === 'pending' && build.live)) + (state.status === 'ready' || + state.status === 'skipped' || + (state.status === 'pending' && build.live)) ) return false await tx diff --git a/apps/sim/lib/workspace-files/search/index-state.ts b/apps/sim/lib/workspace-files/search/index-state.ts index f555acbd6a9..014c670f475 100644 --- a/apps/sim/lib/workspace-files/search/index-state.ts +++ b/apps/sim/lib/workspace-files/search/index-state.ts @@ -298,18 +298,26 @@ export async function publishFileSearchBuild( publication.dependencies.map((dependency) => ({ ...dependency, buildId: build.id })) ) } + /** + * A skipped build stays published, with its bound artifact key, so a rendered read of the same + * artifact sees it as current instead of requeueing it. It keeps no text. + */ + if (publication.status === 'skipped') + await tx + .delete(workspaceFileSearchChunk) + .where(eq(workspaceFileSearchChunk.buildId, build.id)) await tx .update(workspaceFileSearchBuild) .set({ - expiresAt: publication.status === 'ready' ? null : sql`clock_timestamp()`, - artifactKey: publication.status === 'ready' ? (publication.artifactKey ?? null) : null, + expiresAt: null, + ...(publication.status === 'ready' ? { artifactKey: publication.artifactKey ?? null } : {}), }) .where(eq(workspaceFileSearchBuild.id, build.id)) await tx .update(workspaceFileSearchRevision) .set({ status: publication.status, - buildId: publication.status === 'ready' ? build.id : null, + buildId: build.id, failureReason: publication.status === 'skipped' ? publication.failureReason : null, lineCount: publication.status === 'ready' ? publication.lineCount : 0, indexedBytes: publication.status === 'ready' ? publication.indexedBytes : 0, diff --git a/apps/sim/lib/workspace-files/search/owners.integration.ts b/apps/sim/lib/workspace-files/search/owners.integration.ts index ceb5422dca3..3eede26617d 100644 --- a/apps/sim/lib/workspace-files/search/owners.integration.ts +++ b/apps/sim/lib/workspace-files/search/owners.integration.ts @@ -66,7 +66,10 @@ import { beginFileSearchBuild, publishFileSearchBuild, } from '@/lib/workspace-files/search/index-state' -import { indexWorkspaceFileForSearch } from '@/lib/workspace-files/search/indexing' +import { + indexWorkspaceFileForSearch, + markWorkspaceFileSearchIndexFailed, +} from '@/lib/workspace-files/search/indexing' async function requireDocxFormat() { const format = await getE2BDocFormat('report.docx') @@ -911,6 +914,45 @@ describe('owner-scoped indexed file search on PostgreSQL and stored bytes', () = expect(unchanged.buildId).toBe(ready.buildId) } ) + check('keeps a skipped artifact build on later reads but requeues a failed one', async () => { + const f = await fixture() + const source = 'binaryartifactsource' + const generated = await create(f, source, { name: 'binary.docx' }) + await storeCompiledDoc( + f.owner, + source, + 'docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + Buffer.concat([Buffer.from('PK\x03\x04'), Buffer.alloc(64), Buffer.from([0xff, 0xfe])]), + fileDocumentCacheIdentity(source, await requireDocxFormat(), 'text/plain') + ) + const input = { projectId: f.projectId, fileId: generated.file.id, maxBytes: 1_000_000 } + const revisionOf = async () => { + const [row] = await db + .select() + .from(workspaceFileSearchRevision) + .where(eq(workspaceFileSearchRevision.fileId, generated.file.id)) + return row + } + await readProjectFileArtifact.execute({ principal: f.reader, input }) + await markWorkspaceFileSearchIndexFailed({ + owner: f.owner, + ...(await claim(generated.file.id)), + }) + expect((await revisionOf()).status).toBe('failed') + await readProjectFileArtifact.execute({ principal: f.reader, input }) + expect((await revisionOf()).status).toBe('pending') + await indexWorkspaceFileForSearch( + { owner: f.owner, ...(await claim(generated.file.id)) }, + signal + ) + const skipped = await revisionOf() + expect(skipped).toMatchObject({ status: 'skipped', failureReason: 'binary_or_degraded' }) + await readProjectFileArtifact.execute({ principal: f.reader, input }) + const unchanged = await revisionOf() + expect(unchanged.status).toBe('skipped') + expect(unchanged.buildId).toBe(skipped.buildId) + }) check('retains workspace generated-input provenance in indexed snippet delivery', async () => { const f = await fixture() diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 00c1df9ad8f..860420b253a 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -169,7 +169,8 @@ describe('proxy matcher', () => { expect(matches('/api/desktop/tool/file')).toBe(false) }) - it('leaves the document snapshot persist unbuffered, so a whole snapshot reaches the route', () => { + it('leaves the document snapshot persists unbuffered, so a whole snapshot reaches the route', () => { + expect(matches('/api/internal/file-doc/persist')).toBe(false) expect(matches('/api/internal/project-file-doc/project-1/file-1/persist')).toBe(false) }) @@ -180,5 +181,7 @@ describe('proxy matcher', () => { expect(matches('/api/files/upload')).toBe(true) expect(matches('/api/internal/project-file-doc/project-1/file-1/seed')).toBe(true) expect(matches('/api/internal/project-file-doc/project-1/file-1/persist/other')).toBe(true) + expect(matches('/api/internal/file-doc/persist/other')).toBe(true) + expect(matches('/api/internal/file-doc/seed')).toBe(true) }) }) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index e72a1962c6d..ad803018890 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -470,10 +470,10 @@ export const config = { '/login', '/signup', '/invite/:path*', // Match invitation routes - // Runtime CORS. The desktop's raw upload routes and the realtime relay's document persist are + // Runtime CORS. The desktop's raw upload routes and the realtime relay's document persists are // left out: running the proxy makes Next buffer the body, cutting it off at its 10 MB proxy // limit, and those bodies are whole files or document snapshots. - '/api/((?!desktop/tool/(?:import|file)$|internal/project-file-doc/[^/]+/[^/]+/persist$).*)', + '/api/((?!desktop/tool/(?:import|file)$|internal/file-doc/persist$|internal/project-file-doc/[^/]+/[^/]+/persist$).*)', // Next's image optimizer fetches public editorial images without a User-Agent. '/((?!api/|api$|_next/static|_next/image|(?:blog|changelog)/.*\\.(?:avif|gif|jpe?g|png|svg|webp)$|ingest|favicon.ico|logo/|landing/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)', ], diff --git a/knip.jsonc b/knip.jsonc index 640b77d6370..99112b5952e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -64,7 +64,6 @@ "lib/api/contracts/project-file-extraction.ts": ["exports", "types"], "lib/api/contracts/v2/project-file-downloads.ts": ["exports", "types"], "lib/api/contracts/v2/project-file-extraction.ts": ["exports", "types"], - "lib/api/contracts/v2/project-file-search.ts": ["exports", "types"], "components/mcp/index.ts": ["files"], "triggers/quickbooks/index.ts": ["files"], "lib/api/contracts/selectors/index.ts": ["files"],