From 1250943537b06800a20f9bfae767ca0ce6fe57b4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 11:50:32 -0700 Subject: [PATCH 1/2] fix(uploads): drop the stray 'use server' directive that enables Server Actions app-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file-utils.server.ts` was the repo's only `'use server'` module, and the sole reason Next's `hasServerActions()` returned true. With actions registered, Next loses its early-404 escape hatch for Server Action requests — and it classifies a request as an action from headers alone, with no body inspection and no auth. Any unauthenticated `POST` with `Content-Type: multipart/form-data` to any App Router path therefore took the non-fetch action path, which bare-throws and surfaces as an HTTP 500. Nothing invokes these functions as Server Actions: every one of the ~77 importers is server-side, with zero `'use client'` importers. The directive was a misuse of `'use server'` where "server-only module" was meant — the `.server.ts` suffix already carries that convention. Extends check-client-boundary-imports.ts to fail on any `'use server'` directive so this cannot regress. --- .../lib/uploads/utils/file-utils.server.ts | 2 - scripts/check-client-boundary-imports.ts | 116 +++++++++++++----- 2 files changed, 88 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index cbf08e549c5..bae6d27c4a5 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -1,5 +1,3 @@ -'use server' - import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' diff --git a/scripts/check-client-boundary-imports.ts b/scripts/check-client-boundary-imports.ts index 6d0ec2d6758..5f18e514390 100644 --- a/scripts/check-client-boundary-imports.ts +++ b/scripts/check-client-boundary-imports.ts @@ -1,6 +1,25 @@ #!/usr/bin/env bun /** - * Guards against the Next.js `'use client'` server-import foot-gun. + * Guards the two Next.js boundary directives: `'use client'` imports and any + * `'use server'` module. + * + * ## `'use server'` + * + * A single `'use server'` module anywhere in the graph flips Next's + * `hasServerActions()` to true, which removes the early 404 for Server Action + * requests. Next classifies a request as a Server Action from HEADERS ALONE — + * no body inspection, no auth — so once actions exist, ANY unauthenticated + * `POST` with `Content-Type: multipart/form-data` to ANY App Router path takes + * the non-fetch action path, which bare-`throw`s and surfaces as an HTTP 500. + * A trickle of such requests is enough to trip the ALB 5xx alarm. Every export + * of a `'use server'` module is also a remotely invocable, unauthenticated + * endpoint. + * + * Sim has no Server Actions — server-only modules use the `.server.ts` suffix + * and are called directly from route handlers. If you genuinely need a Server + * Action, remove this check deliberately and wrap every export in auth. + * + * ## `'use client'` * * Next.js rewrites EVERY export of a `'use client'` module into a client * reference in the server bundle. Server-evaluated code can only *render* such @@ -36,6 +55,8 @@ import path from 'node:path' const ROOT = path.resolve(import.meta.dir, '..') const APP_DIR = path.join(ROOT, 'apps/sim') +/** Everything Next compiles into the app's module graph. */ +const DIRECTIVE_SCAN_DIRS = [path.join(ROOT, 'apps'), path.join(ROOT, 'packages')] /** Server-evaluated, non-JSX surfaces. A file matches if its path passes one. */ function isServerSurface(rel: string): boolean { @@ -69,32 +90,54 @@ async function listFiles(dir: string): Promise { return out } -const useClientCache = new Map() - -async function isUseClientModule(absFile: string): Promise { - const cached = useClientCache.get(absFile) - if (cached !== undefined) return cached - let content: string - try { - content = await readFile(absFile, 'utf8') - } catch { - useClientCache.set(absFile, false) - return false - } - // The directive must be the first statement (comments/blank lines may precede it). - let isClient = false +/** + * Returns the module's leading directive prologue string, if any. A directive + * must be the first statement; comments and blank lines may precede it. + */ +function leadingDirective(content: string): string | null { for (const raw of content.split('\n')) { const line = raw.trim() if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) { continue } - isClient = line === "'use client'" || line === '"use client"' - break + const match = /^(['"])(use [a-z-]+)\1;?$/.exec(line) + return match ? match[2] : null } + return null +} + +const useClientCache = new Map() + +async function isUseClientModule(absFile: string): Promise { + const cached = useClientCache.get(absFile) + if (cached !== undefined) return cached + let isClient = false + try { + isClient = leadingDirective(await readFile(absFile, 'utf8')) === 'use client' + } catch {} useClientCache.set(absFile, isClient) return isClient } +/** + * Locations declaring `'use server'` — module prologue or inline in a function + * body. Either form registers Server Actions app-wide. + */ +async function findUseServerDirectives(): Promise { + const found: string[] = [] + for (const dir of DIRECTIVE_SCAN_DIRS) { + for (const absFile of await listFiles(dir)) { + const lines = (await readFile(absFile, 'utf8')).split('\n') + for (let i = 0; i < lines.length; i++) { + if (/^(['"])use server\1;?$/.test(lines[i].trim())) { + found.push(`${path.relative(ROOT, absFile)}:${i + 1}`) + } + } + } + } + return found +} + /** Resolve an import specifier to an absolute source file, or null if external/unresolved. */ async function resolveSpecifier(spec: string, fromFile: string): Promise { let base: string @@ -188,6 +231,22 @@ interface Violation { async function main() { const checkMode = process.argv.includes('--check') + let failed = false + + const serverDirectives = await findUseServerDirectives() + if (serverDirectives.length === 0) { + console.log("✓ No 'use server' directives (Server Actions stay disabled).") + } else { + failed = true + console.error( + `\n✗ ${serverDirectives.length} 'use server' directive(s) found.\n` + + ` These enable Next's Server Action handling app-wide, which turns any unauthenticated\n` + + ` multipart/form-data POST to any App Router path into a 500, and exposes every export\n` + + ` as an unauthenticated endpoint. Use a '.server.ts' module called from a route handler.\n` + ) + for (const location of serverDirectives) console.error(` ${location}`) + } + const allFiles = await listFiles(APP_DIR) const violations: Violation[] = [] @@ -212,19 +271,20 @@ async function main() { console.log( "✓ Client-boundary import check passed (no server file imports a value from a 'use client' module)." ) - return + } else { + failed = true + console.error( + `\n✗ ${violations.length} server file(s) import a runtime value from a 'use client' module.\n` + + ` On the server these resolve to client-reference stubs and throw when called (e.g. 'X.list is not a function').\n` + + ` Move the imported factory/fetcher/constant into a non-'use client' module (hooks/queries/utils/*-keys.ts or fetch-*.ts).\n` + + ` See .claude/rules/sim-queries.md. Escape hatch: // ${ALLOW_DIRECTIVE}: above the import.\n` + ) + for (const v of violations) { + console.error(` ${v.file}:${v.line} imports from '${v.specifier}'`) + } } - console.error( - `\n✗ ${violations.length} server file(s) import a runtime value from a 'use client' module.\n` + - ` On the server these resolve to client-reference stubs and throw when called (e.g. 'X.list is not a function').\n` + - ` Move the imported factory/fetcher/constant into a non-'use client' module (hooks/queries/utils/*-keys.ts or fetch-*.ts).\n` + - ` See .claude/rules/sim-queries.md. Escape hatch: // ${ALLOW_DIRECTIVE}: above the import.\n` - ) - for (const v of violations) { - console.error(` ${v.file}:${v.line} imports from '${v.specifier}'`) - } - if (checkMode) process.exit(1) + if (failed && checkMode) process.exit(1) } main().catch((error) => { From c39ddeb99aa5ffca56ce0e8807f47124ec53a281 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 12:21:01 -0700 Subject: [PATCH 2/2] fix(scripts): match boundary directives that carry a trailing comment A directive keeps its meaning when a note follows it on the same line, so strip a trailing '//' or block comment before matching. Shared by the 'use client' and 'use server' detectors. --- scripts/check-client-boundary-imports.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/check-client-boundary-imports.ts b/scripts/check-client-boundary-imports.ts index 5f18e514390..d9ad1ea3364 100644 --- a/scripts/check-client-boundary-imports.ts +++ b/scripts/check-client-boundary-imports.ts @@ -90,6 +90,18 @@ async function listFiles(dir: string): Promise { return out } +/** + * Drops a trailing `//` or `/* *\/` comment from an already-trimmed line. A + * directive keeps its meaning when a note follows it on the same line, so the + * comment has to come off before the directive is matched. + */ +function stripTrailingComment(line: string): string { + return line.replace(/(?:\/\/.*|\/\*.*?\*\/)\s*$/, '').trim() +} + +/** A lone directive statement, e.g. `'use server'` or `"use client";`. */ +const DIRECTIVE_STATEMENT = /^(['"])(use [a-z-]+)\1\s*;?$/ + /** * Returns the module's leading directive prologue string, if any. A directive * must be the first statement; comments and blank lines may precede it. @@ -100,7 +112,7 @@ function leadingDirective(content: string): string | null { if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) { continue } - const match = /^(['"])(use [a-z-]+)\1;?$/.exec(line) + const match = DIRECTIVE_STATEMENT.exec(stripTrailingComment(line)) return match ? match[2] : null } return null @@ -129,7 +141,8 @@ async function findUseServerDirectives(): Promise { for (const absFile of await listFiles(dir)) { const lines = (await readFile(absFile, 'utf8')).split('\n') for (let i = 0; i < lines.length; i++) { - if (/^(['"])use server\1;?$/.test(lines[i].trim())) { + const match = DIRECTIVE_STATEMENT.exec(stripTrailingComment(lines[i].trim())) + if (match?.[2] === 'use server') { found.push(`${path.relative(ROOT, absFile)}:${i + 1}`) } }