Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions apps/sim/lib/api/contracts/selectors/confluence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'),
Expand Down Expand Up @@ -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'),
Expand All @@ -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 = <TBody extends z.ZodType>(path: string, body: TBody) =>
defineRouteContract({
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/storage-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
212 changes: 113 additions & 99 deletions apps/sim/lib/api/contracts/tools/databases/mongodb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/lib/core/config/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
if (!hostIsIp) return undefined
Comment thread
TheodoreSpeaks marked this conversation as resolved.
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
Expand Down Expand Up @@ -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')

Expand All @@ -96,6 +128,7 @@ export function getRedisClient(): Redis | null {
commandTimeout: 5000,
maxRetriesPerRequest: 5,
enableOfflineQueue: true,
...(tls ? { tls } : {}),

retryStrategy: (times) => {
if (times > 10) {
Expand Down
Loading