diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index 4eaf161b37f..9f0c7c1ce1f 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -97,6 +97,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { message: + // utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw + // must surface the fixed copy rather than its own text — getErrorMessage would + // pass a thrown string straight through. error instanceof Error ? error.message : 'Failed to send password reset email. Please try again later.', diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts index 268992e07a7..469738fd04f 100644 --- a/apps/sim/app/api/auth/reset-password/route.ts +++ b/apps/sim/app/api/auth/reset-password/route.ts @@ -60,6 +60,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { message: + // utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw + // must surface the fixed copy rather than its own text — getErrorMessage would + // pass a thrown string straight through. error instanceof Error ? error.message : 'Failed to reset password. Please try again or request a new reset link.', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 0533b52c5d7..b62f1e0b292 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -274,9 +274,7 @@ export function TeamManagement({ portalWindow?.close() logger.error('Failed to open billing portal from transfer dialog', { error }) setTransferPortalError( - error instanceof Error - ? error.message - : 'Failed to open Stripe billing portal. Please try again.' + getErrorMessage(error, 'Failed to open Stripe billing portal. Please try again.') ) }, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts index 564eb796400..e40b1eb28c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts @@ -1,7 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { Edge } from 'reactflow' -import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { putWorkflowNormalizedStateContract, @@ -100,12 +99,7 @@ export async function applyAutoLayoutAndUpdateStore( }, }) } catch (error) { - const errorMessage = - error instanceof ApiClientError - ? error.message - : error instanceof Error - ? error.message - : 'Auto layout failed' + const errorMessage = getErrorMessage(error, 'Auto layout failed') logger.error('Auto layout API call failed:', { error: errorMessage }) return { success: false, error: errorMessage } } diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index 23729afe105..c03a1f4a082 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -211,9 +211,7 @@ export function useSubscriptionUpgrade() { error: transferError instanceof ApiClientError ? (transferError.rawBody ?? transferError.message) - : transferError instanceof Error - ? transferError.message - : 'Unknown error', + : getErrorMessage(transferError, 'Unknown error'), }) } } catch (error) { diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index a9c9e39c7fc..4faed387663 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -109,6 +109,8 @@ function isE2BExecutionTimeout(error: unknown): boolean { ? error.name : '' const message = + // utils-lint-allow: probes E2B's own error shape — a record-like carrying `message` + // or `value` — which getErrorMessage cannot express. error instanceof Error ? error.message : isRecordLike(error) diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index ccaa56eb12c..4e35dba8467 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -3,7 +3,7 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -733,9 +733,7 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { error ) throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Teams subscription. Please try again.' + getErrorMessage(error, 'Failed to create Teams subscription. Please try again.') ) } }, diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts index 9720bccd60b..da7303800ed 100644 --- a/apps/sim/lib/webhooks/providers/telegram.ts +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -1,5 +1,6 @@ import { db, webhook, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull, ne } from 'drizzle-orm' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -170,9 +171,7 @@ export const telegramHandler: WebhookProviderHandler = { error ) throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Telegram webhook. Please try again.' + getErrorMessage(error, 'Failed to create Telegram webhook. Please try again.') ) } }, diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index e8a7384009b..e366366868a 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' +import { getErrorMessage } from '@sim/utils/errors' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -168,9 +169,7 @@ export const typeformHandler: WebhookProviderHandler = { error ) throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Typeform webhook. Please try again.' + getErrorMessage(error, 'Failed to create Typeform webhook. Please try again.') ) } }, diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 489ca8af9b3..9e4ba04d537 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode, toError } from '@sim/utils/errors' import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { FolderPathError } from '@/lib/folders/paths' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' @@ -385,10 +385,10 @@ export async function performMoveWorkspaceFileItems( ) { return { success: false, - error: - error instanceof Error - ? error.message - : 'A file or folder with this name already exists in the destination folder', + error: getErrorMessage( + error, + 'A file or folder with this name already exists in the destination folder' + ), errorCode: 'conflict', } } diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 083b116d848..b424332918c 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateRandomHex } from '@sim/utils/random' import { create } from 'zustand' import { devtools } from 'zustand/middleware' @@ -206,10 +207,10 @@ export const useWorkflowRegistry = create()( logger.info(`Switched to workflow ${workflowId}`) } catch (error) { - const message = - error instanceof Error - ? error.message - : `Failed to load workflow ${workflowId}: Unknown error` + const message = getErrorMessage( + error, + `Failed to load workflow ${workflowId}: Unknown error` + ) logger.error(message) const currentHydration = get().hydration diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 857bab94901..3239ef8aa9c 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -2,9 +2,17 @@ /** * Enforces use of shared @sim/utils helpers over inline implementations. * - * Biome's noRestrictedImports covers import-based bans (nanoid, uuid, crypto named imports). - * This script catches patterns that static import analysis misses — global property access, - * inline idioms, and reimplemented helpers that should live in @sim/utils. + * Biome's noRestrictedImports covers the import-based bans it lists — today `nanoid` and + * `uuid`. It does NOT cover named crypto imports; `import { randomBytes } from 'node:crypto'` + * passes both gates, and deliberately so, since server code building cipher IVs and tokens + * wants node's crypto rather than the cross-context wrapper in `@sim/utils/random`. + * + * This script catches what static import analysis misses — global property access, inline + * idioms, and reimplemented helpers that should live in @sim/utils. + * + * Patterns are matched against the whole file, not line by line: every idiom banned here is a + * multi-token expression that the formatter wraps at 100 columns, and a line-scoped scan sees + * none of the wrapped forms. Deliberate exceptions carry `// utils-lint-allow: `. */ import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' @@ -108,6 +116,48 @@ interface Violation { snippet: string } +/** Escape hatch for a deliberate use, mirroring `rq-lint-allow:` in check-react-query-patterns.ts. */ +const ALLOW = 'utils-lint-allow:' + +/** Offset of the first character of each line, for mapping a match index back to a line number. */ +function buildLineStarts(content: string): number[] { + const starts = [0] + for (let i = 0; i < content.length; i++) { + if (content[i] === '\n') starts.push(i + 1) + } + return starts +} + +/** 1-based line containing `offset`, by binary search over {@link buildLineStarts}. */ +function lineAt(lineStarts: number[], offset: number): number { + let low = 0 + let high = lineStarts.length - 1 + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (lineStarts[mid] <= offset) low = mid + else high = mid - 1 + } + return low + 1 +} + +/** + * True if a `// utils-lint-allow: ` annotation sits just above `line` (1-based). + * + * The reason must be non-empty: an annotation that does not say why is the thing this + * check exists to prevent. Scans up to three comment lines above, so the annotation can + * carry context lines with it. + */ +function hasAllow(lines: string[], line: number): boolean { + for (let i = line - 2; i >= 0 && i >= line - 5; i--) { + const text = lines[i]?.trim() ?? '' + if (text.includes(ALLOW)) { + return text.slice(text.indexOf(ALLOW) + ALLOW.length).trim().length > 0 + } + if (text.length > 0 && !text.startsWith('//') && !text.startsWith('*')) break + } + return false +} + async function main() { const allFiles: string[] = [] for (const dir of SCAN_DIRS) { @@ -122,20 +172,20 @@ async function main() { const content = await readFile(file, 'utf8') const lines = content.split('\n') + const lineStarts = buildLineStarts(content) - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - for (const { pattern, description, suggestion } of BANNED_PATTERNS) { - pattern.lastIndex = 0 - if (pattern.test(line)) { - violations.push({ - file: rel, - line: i + 1, - description, - suggestion, - snippet: line.trim(), - }) - } + for (const { pattern, description, suggestion } of BANNED_PATTERNS) { + pattern.lastIndex = 0 + for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) { + const line = lineAt(lineStarts, match.index) + if (hasAllow(lines, line)) continue + violations.push({ + file: rel, + line, + description, + suggestion, + snippet: (lines[line - 1] ?? '').trim(), + }) } } }