From 2f7f936b0470a64f6a35833570bfd34de9ea2eef Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:39:27 -0700 Subject: [PATCH 1/8] fix(file-search): keep a skipped build published so rendered reads stop requeueing it A skipped publication retired its build and cleared the revision's build id, so a compiled document whose index build skipped never matched its rendered artifact key: every rendered read requeued it, and the worker downloaded, compiled and skipped it again. Skipped builds now stay published with their bound artifact key (and no text), and the artifact-current checks accept a skipped revision. Failed revisions still requeue. --- .../workspace-files/search/artifact-ready.ts | 11 +++-- .../lib/workspace-files/search/index-state.ts | 14 ++++-- .../search/owners.integration.ts | 44 ++++++++++++++++++- 3 files changed, 61 insertions(+), 8 deletions(-) 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..19b20b5eebf 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.from([0, 0xff, 0, 0xfe, 0, 0xfd]), + 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() From dff273e377d3d79c100e8b3a9fc677e071a8a2c8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:39:54 -0700 Subject: [PATCH 2/8] fix(public-shares): answer a shared Project page with a foreign embed as not found renderSimPageDocument throws ProjectPageForeignEmbedError for a Project page that embeds another owner's file. The authenticated artifact read maps it to not_found, but the public content and inline reads let it escape as a 500. Map it the same way at both public render sites. --- .../sim/lib/public-shares/application/read.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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 = From f2ec4562c61d887d762548966e39352dddaa016c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:41:11 -0700 Subject: [PATCH 3/8] fix(file-doc): let the workspace document persist take a full snapshot The relay's workspace persist ran behind the proxy with the default 10 MB body cap, so a collaborative state above ~7.5 MiB (12 MiB is allowed) was refused with 413 while the Project persist route took it. Exclude the workspace persist from the proxy matcher and give it the same bypassesProxy body cap, now shared from collab-doc state, and the same off-app-host refusal. --- apps/sim/app/api/internal/file-doc/persist/route.ts | 11 ++++++++++- .../[projectId]/[fileId]/persist/route.ts | 7 ++----- apps/sim/lib/collab-doc/state.ts | 7 +++++++ apps/sim/proxy.test.ts | 5 ++++- apps/sim/proxy.ts | 4 ++-- 5 files changed, 25 insertions(+), 9 deletions(-) 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/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).*)', ], From b63a102ed78cfb68eb6f106b585f9569a1d8f8c7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:41:22 -0700 Subject: [PATCH 4/8] chore(knip): drop the stale ignore for the v2 Project file search contract Its only export is now imported by the v2 route and the OpenAPI spec. --- knip.jsonc | 1 - 1 file changed, 1 deletion(-) 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"], From b6f44c550656e80f523179712812b594bd415391 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:42:23 -0700 Subject: [PATCH 5/8] fix(memory): let a rejected key spend memory cleanup attempts A 401 from the worker waited hourly without consuming an attempt, like a missing route, so a permanently revoked self-hosted key kept its cleanup rows pending forever. A 401 still waits an hour but now spends an attempt, so it dead-letters after the default attempts (about ten hours), past a worker rollout. 404, 405 and 501 keep waiting for free. --- .../lib/mothership/memories/cleanup-outbox.ts | 25 +++++++++++++++---- .../memories/cleanup.integration.ts | 15 ++++++++--- 2 files changed, 31 insertions(+), 9 deletions(-) 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) From f46d5ac4f8ff45399c9415a46725777e363ddcfd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:43:42 -0700 Subject: [PATCH 6/8] fix(public-shares): log expected public file share client errors as warnings The public file share routes logged every caught error at ERROR, including classified client failures such as a missing share (404) or a password exchange against a non-password share (400). Log a classified 4xx as a warning and keep ERROR for unclassified failures. --- apps/sim/app/api/files/public/[token]/content/route.ts | 3 ++- apps/sim/app/api/files/public/[token]/inline/route.ts | 3 ++- apps/sim/app/api/files/public/[token]/otp/route.ts | 4 ++-- apps/sim/app/api/files/public/[token]/route.ts | 5 +++-- apps/sim/app/api/files/public/[token]/sso/route.ts | 8 ++++++-- apps/sim/lib/public-shares/api/index.ts | 1 + apps/sim/lib/public-shares/api/responses.ts | 7 +++++++ 7 files changed, 23 insertions(+), 8 deletions(-) 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..367467535df 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -48,7 +48,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 +69,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/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) From c67f345d3c93b8966c08ac7e27d7141b58ff0457 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:49:29 -0700 Subject: [PATCH 7/8] fix(public-shares): import the error logger in the OTP route --- apps/sim/app/api/files/public/[token]/otp/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 367467535df..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, From 660b5e10f6ce7f1cd90954f966c55e3b77b43930 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 22:03:05 -0700 Subject: [PATCH 8/8] test(file-search): skip on a corrupt compiled document the parsers reject --- apps/sim/lib/workspace-files/search/owners.integration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/workspace-files/search/owners.integration.ts b/apps/sim/lib/workspace-files/search/owners.integration.ts index 19b20b5eebf..3eede26617d 100644 --- a/apps/sim/lib/workspace-files/search/owners.integration.ts +++ b/apps/sim/lib/workspace-files/search/owners.integration.ts @@ -923,7 +923,7 @@ describe('owner-scoped indexed file search on PostgreSQL and stored bytes', () = source, 'docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - Buffer.from([0, 0xff, 0, 0xfe, 0, 0xfd]), + 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 }