diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index 6a838019661..8243361e8cf 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -100,17 +100,24 @@ function addAlphanumericIdIssue( } } -export const confluenceCommentScopedSchema = confluenceBaseSchema - .extend({ - commentId: z.string().min(1, 'Comment ID is required'), - }) - .superRefine((data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx)) +// Keep the un-superRefined base separate so downstream schemas can .extend it. +// .superRefine returns a ZodEffects which has no .extend method, so extending +// the refined schema directly throws at module-init time (caught by bundlers +// like esbuild/Trigger.dev that eagerly evaluate; Next.js lazy-loads per-route +// and hides the issue). +const confluenceCommentScopedBaseSchema = confluenceBaseSchema.extend({ + commentId: z.string().min(1, 'Comment ID is required'), +}) +export const confluenceCommentScopedSchema = confluenceCommentScopedBaseSchema.superRefine( + (data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx) +) -export const confluenceBlogPostScopedSchema = confluenceBaseSchema - .extend({ - blogPostId: z.string({ error: 'Blog post ID is required' }).min(1, 'Blog post ID is required'), - }) - .superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx)) +const confluenceBlogPostScopedBaseSchema = confluenceBaseSchema.extend({ + blogPostId: z.string({ error: 'Blog post ID is required' }).min(1, 'Blog post ID is required'), +}) +export const confluenceBlogPostScopedSchema = confluenceBlogPostScopedBaseSchema.superRefine( + (data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx) +) export const confluenceDeleteAttachmentBodySchema = confluenceBaseSchema.extend({ attachmentId: z @@ -133,9 +140,11 @@ export const confluenceListCommentsQuerySchema = confluencePageScopedSchema.exte cursor: z.string().optional(), }) -export const confluenceUpdateCommentBodySchema = confluenceCommentScopedSchema.extend({ - comment: z.string().min(1, 'Comment is required'), -}) +export const confluenceUpdateCommentBodySchema = confluenceCommentScopedBaseSchema + .extend({ + comment: z.string().min(1, 'Comment is required'), + }) + .superRefine((data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx)) export const confluenceCreatePageBodySchema = confluenceSpaceScopedSchema.extend({ title: z.string({ error: 'Title is required' }).min(1, 'Title is required'), @@ -276,9 +285,11 @@ export const confluenceUserBodySchema = confluenceBaseSchema.extend({ accountId: z.string({ error: 'Account ID is required' }).min(1, 'Account ID is required'), }) -export const confluenceGetBlogPostBodySchema = confluenceBlogPostScopedSchema.extend({ - bodyFormat: z.string().optional(), -}) +export const confluenceGetBlogPostBodySchema = confluenceBlogPostScopedBaseSchema + .extend({ + bodyFormat: z.string().optional(), + }) + .superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx)) export const confluenceCreateBlogPostBodySchema = confluenceSpaceScopedSchema.extend({ title: z.string({ error: 'Title is required' }).min(1, 'Title is required'), @@ -298,10 +309,12 @@ export const confluenceListBlogPostsQuerySchema = confluenceBaseSchema.extend({ cursor: z.string().optional(), }) -export const confluenceUpdateBlogPostBodySchema = confluenceBlogPostScopedSchema.extend({ - title: z.string().optional(), - content: z.string().optional(), -}) +export const confluenceUpdateBlogPostBodySchema = confluenceBlogPostScopedBaseSchema + .extend({ + title: z.string().optional(), + content: z.string().optional(), + }) + .superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx)) const defineConfluencePostContract = (path: string, body: TBody) => defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 7142c65ea90..45e42a7832d 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -290,6 +290,7 @@ export const fileDownloadBodySchema = z isExecutionFile: z.boolean().optional(), context: downloadContextSchema.optional(), url: z + .string() .url() .refine((value) => ['http:', 'https:'].includes(new URL(value).protocol), { message: 'URL must use http or https', diff --git a/apps/sim/lib/api/contracts/tools/databases/mongodb.ts b/apps/sim/lib/api/contracts/tools/databases/mongodb.ts index 59c79abfd11..b12d5cbe577 100644 --- a/apps/sim/lib/api/contracts/tools/databases/mongodb.ts +++ b/apps/sim/lib/api/contracts/tools/databases/mongodb.ts @@ -10,20 +10,24 @@ import { defineRouteContract, } from '@/lib/api/contracts/types' -const mongoConnectionBodySchema = z - .object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), - database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required').optional(), - password: z.string().min(1, 'Password is required').optional(), - authSource: z.string().optional(), - ssl: sslModeSchema, - }) - .refine((data) => Boolean(data.username) === Boolean(data.password), { - message: 'Username and password must be provided together', - path: ['password'], - }) +// Un-refined base so the downstream operation schemas can .extend it; each +// reattaches mongoUsernamePasswordPaired after its own .extend. +const mongoConnectionBaseSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required').optional(), + password: z.string().min(1, 'Password is required').optional(), + authSource: z.string().optional(), + ssl: sslModeSchema, +}) + +const mongoUsernamePasswordPaired = (data: { username?: string; password?: string }) => + Boolean(data.username) === Boolean(data.password) +const mongoUsernamePasswordPairedError = { + message: 'Username and password must be provided together', + path: ['password' as const], +} const mongoJsonStringOrObjectSchema = (message: string) => z @@ -45,92 +49,102 @@ const booleanStringSchema = z return false }) -export const mongodbQueryBodySchema = mongoConnectionBodySchema.extend({ - collection: z.string().min(1, 'Collection name is required'), - query: z - .union([z.string(), z.object({}).passthrough()]) - .optional() - .default('{}') - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val || '{}' - }), - limit: z - .union([z.coerce.number().int().positive(), z.literal(''), z.undefined()]) - .optional() - .transform((val) => { - if (val === '' || val === undefined || val === null) { - return 100 - } - return val - }), - sort: z - .union([z.string(), z.object({}).passthrough(), z.null()]) - .optional() - .transform((val) => { - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val) - } - return val - }), -}) - -export const mongodbExecuteBodySchema = mongoConnectionBodySchema.extend({ - collection: z.string().min(1, 'Collection name is required'), - pipeline: z - .union([z.string(), z.array(z.object({}).passthrough())]) - .transform((val) => { - if (Array.isArray(val)) { - return JSON.stringify(val) - } - return val - }) - .refine((val) => val && val.trim() !== '', { - message: 'Pipeline is required', - }), -}) - -export const mongodbInsertBodySchema = mongoConnectionBodySchema.extend({ - collection: z.string().min(1, 'Collection name is required'), - documents: z - .union([z.array(z.record(z.string(), z.unknown())), z.string()]) - .transform((val) => { - if (typeof val === 'string') { - try { - const parsed = JSON.parse(val) - return Array.isArray(parsed) ? parsed : [parsed] - } catch { - throw new Error('Invalid JSON in documents field') +export const mongodbQueryBodySchema = mongoConnectionBaseSchema + .extend({ + collection: z.string().min(1, 'Collection name is required'), + query: z + .union([z.string(), z.object({}).passthrough()]) + .optional() + .default('{}') + .transform((val) => { + if (typeof val === 'object' && val !== null) { + return JSON.stringify(val) } - } - return val - }) - .refine((val) => Array.isArray(val) && val.length > 0, { - message: 'At least one document is required', - }), -}) - -export const mongodbUpdateBodySchema = mongoConnectionBodySchema.extend({ - collection: z.string().min(1, 'Collection name is required'), - filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Update').refine( - (val) => val !== '{}', - { message: 'Filter is required for MongoDB Update' } - ), - update: mongoJsonStringOrObjectSchema('Update is required'), - upsert: booleanStringSchema, - multi: booleanStringSchema, -}) - -export const mongodbDeleteBodySchema = mongoConnectionBodySchema.extend({ - collection: z.string().min(1, 'Collection name is required'), - filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Delete').refine( - (val) => val !== '{}', - { message: 'Filter is required for MongoDB Delete' } - ), - multi: booleanStringSchema, -}) + return val || '{}' + }), + limit: z + .union([z.coerce.number().int().positive(), z.literal(''), z.undefined()]) + .optional() + .transform((val) => { + if (val === '' || val === undefined || val === null) { + return 100 + } + return val + }), + sort: z + .union([z.string(), z.object({}).passthrough(), z.null()]) + .optional() + .transform((val) => { + if (typeof val === 'object' && val !== null) { + return JSON.stringify(val) + } + return val + }), + }) + .refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError) + +export const mongodbExecuteBodySchema = mongoConnectionBaseSchema + .extend({ + collection: z.string().min(1, 'Collection name is required'), + pipeline: z + .union([z.string(), z.array(z.object({}).passthrough())]) + .transform((val) => { + if (Array.isArray(val)) { + return JSON.stringify(val) + } + return val + }) + .refine((val) => val && val.trim() !== '', { + message: 'Pipeline is required', + }), + }) + .refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError) + +export const mongodbInsertBodySchema = mongoConnectionBaseSchema + .extend({ + collection: z.string().min(1, 'Collection name is required'), + documents: z + .union([z.array(z.record(z.string(), z.unknown())), z.string()]) + .transform((val) => { + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val) + return Array.isArray(parsed) ? parsed : [parsed] + } catch { + throw new Error('Invalid JSON in documents field') + } + } + return val + }) + .refine((val) => Array.isArray(val) && val.length > 0, { + message: 'At least one document is required', + }), + }) + .refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError) + +export const mongodbUpdateBodySchema = mongoConnectionBaseSchema + .extend({ + collection: z.string().min(1, 'Collection name is required'), + filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Update').refine( + (val) => val !== '{}', + { message: 'Filter is required for MongoDB Update' } + ), + update: mongoJsonStringOrObjectSchema('Update is required'), + upsert: booleanStringSchema, + multi: booleanStringSchema, + }) + .refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError) + +export const mongodbDeleteBodySchema = mongoConnectionBaseSchema + .extend({ + collection: z.string().min(1, 'Collection name is required'), + filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Delete').refine( + (val) => val !== '{}', + { message: 'Filter is required for MongoDB Delete' } + ), + multi: booleanStringSchema, + }) + .refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError) export const mongodbIntrospectBodySchema = z .object({ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 26d4cd4ad42..7f408439f16 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -48,6 +48,7 @@ export const env = createEnv({ // Database & Storage REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions + REDIS_TLS_SERVERNAME: z.string().min(1).optional(), // TLS SNI override; required when REDIS_URL targets an IP over rediss:// (e.g. trigger.dev PrivateLink VPCE IP) so cert hostname verification matches the ElastiCache cert's CN // Payment & Billing STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing diff --git a/apps/sim/lib/core/config/redis.ts b/apps/sim/lib/core/config/redis.ts index ae303b4d0a7..aea707daa00 100644 --- a/apps/sim/lib/core/config/redis.ts +++ b/apps/sim/lib/core/config/redis.ts @@ -8,6 +8,35 @@ const logger = createLogger('Redis') const redisUrl = env.REDIS_URL +/** + * When REDIS_URL targets a bare IP over `rediss://` (e.g. trigger.dev's + * PrivateLink VPCE IP), default TLS hostname verification fails — the cert + * is issued for the ElastiCache DNS name, not the IP. Override SNI with + * REDIS_TLS_SERVERNAME (set to the DNS the cert was issued for). + * + * For DNS hosts: no override needed, default verification works. + */ +function resolveTlsOptions(url: string | undefined): { servername: string } | undefined { + if (!url) return undefined + let parsed: URL + try { + parsed = new URL(url) + } catch { + return undefined + } + if (parsed.protocol !== 'rediss:') return undefined + const hostIsIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname) + if (!hostIsIp) return undefined + if (!env.REDIS_TLS_SERVERNAME) { + throw new Error( + 'REDIS_TLS_SERVERNAME must be set when REDIS_URL targets an IP over rediss://. ' + + 'TLS cert hostname verification cannot match an IP — set REDIS_TLS_SERVERNAME ' + + 'to the DNS name the cert was issued for (the ElastiCache primary endpoint).' + ) + } + return { servername: env.REDIS_TLS_SERVERNAME } +} + let globalRedisClient: Redis | null = null let pingFailures = 0 let pingInterval: NodeJS.Timeout | null = null @@ -87,6 +116,9 @@ export function getRedisClient(): Redis | null { if (!redisUrl) return null if (globalRedisClient) return globalRedisClient + // Outside the try/catch so config errors aren't silently swallowed. + const tls = resolveTlsOptions(redisUrl) + try { logger.info('Initializing Redis client') @@ -96,6 +128,7 @@ export function getRedisClient(): Redis | null { commandTimeout: 5000, maxRetriesPerRequest: 5, enableOfflineQueue: true, + ...(tls ? { tls } : {}), retryStrategy: (times) => { if (times > 10) {