Skip to content

Commit dbe8e51

Browse files
v0.6.83: redis TLS SNI override for IP-based REDIS_URL, zod schema fixes
v0.6.83: redis TLS SNI override for IP-based REDIS_URL, zod schema fixes
2 parents db7f1c1 + bd9e692 commit dbe8e51

5 files changed

Lines changed: 181 additions & 119 deletions

File tree

apps/sim/lib/api/contracts/selectors/confluence.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,24 @@ function addAlphanumericIdIssue(
100100
}
101101
}
102102

103-
export const confluenceCommentScopedSchema = confluenceBaseSchema
104-
.extend({
105-
commentId: z.string().min(1, 'Comment ID is required'),
106-
})
107-
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx))
103+
// Keep the un-superRefined base separate so downstream schemas can .extend it.
104+
// .superRefine returns a ZodEffects which has no .extend method, so extending
105+
// the refined schema directly throws at module-init time (caught by bundlers
106+
// like esbuild/Trigger.dev that eagerly evaluate; Next.js lazy-loads per-route
107+
// and hides the issue).
108+
const confluenceCommentScopedBaseSchema = confluenceBaseSchema.extend({
109+
commentId: z.string().min(1, 'Comment ID is required'),
110+
})
111+
export const confluenceCommentScopedSchema = confluenceCommentScopedBaseSchema.superRefine(
112+
(data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx)
113+
)
108114

109-
export const confluenceBlogPostScopedSchema = confluenceBaseSchema
110-
.extend({
111-
blogPostId: z.string({ error: 'Blog post ID is required' }).min(1, 'Blog post ID is required'),
112-
})
113-
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx))
115+
const confluenceBlogPostScopedBaseSchema = confluenceBaseSchema.extend({
116+
blogPostId: z.string({ error: 'Blog post ID is required' }).min(1, 'Blog post ID is required'),
117+
})
118+
export const confluenceBlogPostScopedSchema = confluenceBlogPostScopedBaseSchema.superRefine(
119+
(data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx)
120+
)
114121

115122
export const confluenceDeleteAttachmentBodySchema = confluenceBaseSchema.extend({
116123
attachmentId: z
@@ -133,9 +140,11 @@ export const confluenceListCommentsQuerySchema = confluencePageScopedSchema.exte
133140
cursor: z.string().optional(),
134141
})
135142

136-
export const confluenceUpdateCommentBodySchema = confluenceCommentScopedSchema.extend({
137-
comment: z.string().min(1, 'Comment is required'),
138-
})
143+
export const confluenceUpdateCommentBodySchema = confluenceCommentScopedBaseSchema
144+
.extend({
145+
comment: z.string().min(1, 'Comment is required'),
146+
})
147+
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'commentId', 'comment ID', ctx))
139148

140149
export const confluenceCreatePageBodySchema = confluenceSpaceScopedSchema.extend({
141150
title: z.string({ error: 'Title is required' }).min(1, 'Title is required'),
@@ -276,9 +285,11 @@ export const confluenceUserBodySchema = confluenceBaseSchema.extend({
276285
accountId: z.string({ error: 'Account ID is required' }).min(1, 'Account ID is required'),
277286
})
278287

279-
export const confluenceGetBlogPostBodySchema = confluenceBlogPostScopedSchema.extend({
280-
bodyFormat: z.string().optional(),
281-
})
288+
export const confluenceGetBlogPostBodySchema = confluenceBlogPostScopedBaseSchema
289+
.extend({
290+
bodyFormat: z.string().optional(),
291+
})
292+
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx))
282293

283294
export const confluenceCreateBlogPostBodySchema = confluenceSpaceScopedSchema.extend({
284295
title: z.string({ error: 'Title is required' }).min(1, 'Title is required'),
@@ -298,10 +309,12 @@ export const confluenceListBlogPostsQuerySchema = confluenceBaseSchema.extend({
298309
cursor: z.string().optional(),
299310
})
300311

301-
export const confluenceUpdateBlogPostBodySchema = confluenceBlogPostScopedSchema.extend({
302-
title: z.string().optional(),
303-
content: z.string().optional(),
304-
})
312+
export const confluenceUpdateBlogPostBodySchema = confluenceBlogPostScopedBaseSchema
313+
.extend({
314+
title: z.string().optional(),
315+
content: z.string().optional(),
316+
})
317+
.superRefine((data, ctx) => addAlphanumericIdIssue(data, 'blogPostId', 'blog post ID', ctx))
305318

306319
const defineConfluencePostContract = <TBody extends z.ZodType>(path: string, body: TBody) =>
307320
defineRouteContract({

apps/sim/lib/api/contracts/storage-transfer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export const fileDownloadBodySchema = z
290290
isExecutionFile: z.boolean().optional(),
291291
context: downloadContextSchema.optional(),
292292
url: z
293+
.string()
293294
.url()
294295
.refine((value) => ['http:', 'https:'].includes(new URL(value).protocol), {
295296
message: 'URL must use http or https',

apps/sim/lib/api/contracts/tools/databases/mongodb.ts

Lines changed: 113 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ import {
1010
defineRouteContract,
1111
} from '@/lib/api/contracts/types'
1212

13-
const mongoConnectionBodySchema = z
14-
.object({
15-
host: z.string().min(1, 'Host is required'),
16-
port: z.coerce.number().int().positive('Port must be a positive integer'),
17-
database: z.string().min(1, 'Database name is required'),
18-
username: z.string().min(1, 'Username is required').optional(),
19-
password: z.string().min(1, 'Password is required').optional(),
20-
authSource: z.string().optional(),
21-
ssl: sslModeSchema,
22-
})
23-
.refine((data) => Boolean(data.username) === Boolean(data.password), {
24-
message: 'Username and password must be provided together',
25-
path: ['password'],
26-
})
13+
// Un-refined base so the downstream operation schemas can .extend it; each
14+
// reattaches mongoUsernamePasswordPaired after its own .extend.
15+
const mongoConnectionBaseSchema = z.object({
16+
host: z.string().min(1, 'Host is required'),
17+
port: z.coerce.number().int().positive('Port must be a positive integer'),
18+
database: z.string().min(1, 'Database name is required'),
19+
username: z.string().min(1, 'Username is required').optional(),
20+
password: z.string().min(1, 'Password is required').optional(),
21+
authSource: z.string().optional(),
22+
ssl: sslModeSchema,
23+
})
24+
25+
const mongoUsernamePasswordPaired = (data: { username?: string; password?: string }) =>
26+
Boolean(data.username) === Boolean(data.password)
27+
const mongoUsernamePasswordPairedError = {
28+
message: 'Username and password must be provided together',
29+
path: ['password' as const],
30+
}
2731

2832
const mongoJsonStringOrObjectSchema = (message: string) =>
2933
z
@@ -45,92 +49,102 @@ const booleanStringSchema = z
4549
return false
4650
})
4751

48-
export const mongodbQueryBodySchema = mongoConnectionBodySchema.extend({
49-
collection: z.string().min(1, 'Collection name is required'),
50-
query: z
51-
.union([z.string(), z.object({}).passthrough()])
52-
.optional()
53-
.default('{}')
54-
.transform((val) => {
55-
if (typeof val === 'object' && val !== null) {
56-
return JSON.stringify(val)
57-
}
58-
return val || '{}'
59-
}),
60-
limit: z
61-
.union([z.coerce.number().int().positive(), z.literal(''), z.undefined()])
62-
.optional()
63-
.transform((val) => {
64-
if (val === '' || val === undefined || val === null) {
65-
return 100
66-
}
67-
return val
68-
}),
69-
sort: z
70-
.union([z.string(), z.object({}).passthrough(), z.null()])
71-
.optional()
72-
.transform((val) => {
73-
if (typeof val === 'object' && val !== null) {
74-
return JSON.stringify(val)
75-
}
76-
return val
77-
}),
78-
})
79-
80-
export const mongodbExecuteBodySchema = mongoConnectionBodySchema.extend({
81-
collection: z.string().min(1, 'Collection name is required'),
82-
pipeline: z
83-
.union([z.string(), z.array(z.object({}).passthrough())])
84-
.transform((val) => {
85-
if (Array.isArray(val)) {
86-
return JSON.stringify(val)
87-
}
88-
return val
89-
})
90-
.refine((val) => val && val.trim() !== '', {
91-
message: 'Pipeline is required',
92-
}),
93-
})
94-
95-
export const mongodbInsertBodySchema = mongoConnectionBodySchema.extend({
96-
collection: z.string().min(1, 'Collection name is required'),
97-
documents: z
98-
.union([z.array(z.record(z.string(), z.unknown())), z.string()])
99-
.transform((val) => {
100-
if (typeof val === 'string') {
101-
try {
102-
const parsed = JSON.parse(val)
103-
return Array.isArray(parsed) ? parsed : [parsed]
104-
} catch {
105-
throw new Error('Invalid JSON in documents field')
52+
export const mongodbQueryBodySchema = mongoConnectionBaseSchema
53+
.extend({
54+
collection: z.string().min(1, 'Collection name is required'),
55+
query: z
56+
.union([z.string(), z.object({}).passthrough()])
57+
.optional()
58+
.default('{}')
59+
.transform((val) => {
60+
if (typeof val === 'object' && val !== null) {
61+
return JSON.stringify(val)
10662
}
107-
}
108-
return val
109-
})
110-
.refine((val) => Array.isArray(val) && val.length > 0, {
111-
message: 'At least one document is required',
112-
}),
113-
})
114-
115-
export const mongodbUpdateBodySchema = mongoConnectionBodySchema.extend({
116-
collection: z.string().min(1, 'Collection name is required'),
117-
filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Update').refine(
118-
(val) => val !== '{}',
119-
{ message: 'Filter is required for MongoDB Update' }
120-
),
121-
update: mongoJsonStringOrObjectSchema('Update is required'),
122-
upsert: booleanStringSchema,
123-
multi: booleanStringSchema,
124-
})
125-
126-
export const mongodbDeleteBodySchema = mongoConnectionBodySchema.extend({
127-
collection: z.string().min(1, 'Collection name is required'),
128-
filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Delete').refine(
129-
(val) => val !== '{}',
130-
{ message: 'Filter is required for MongoDB Delete' }
131-
),
132-
multi: booleanStringSchema,
133-
})
63+
return val || '{}'
64+
}),
65+
limit: z
66+
.union([z.coerce.number().int().positive(), z.literal(''), z.undefined()])
67+
.optional()
68+
.transform((val) => {
69+
if (val === '' || val === undefined || val === null) {
70+
return 100
71+
}
72+
return val
73+
}),
74+
sort: z
75+
.union([z.string(), z.object({}).passthrough(), z.null()])
76+
.optional()
77+
.transform((val) => {
78+
if (typeof val === 'object' && val !== null) {
79+
return JSON.stringify(val)
80+
}
81+
return val
82+
}),
83+
})
84+
.refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError)
85+
86+
export const mongodbExecuteBodySchema = mongoConnectionBaseSchema
87+
.extend({
88+
collection: z.string().min(1, 'Collection name is required'),
89+
pipeline: z
90+
.union([z.string(), z.array(z.object({}).passthrough())])
91+
.transform((val) => {
92+
if (Array.isArray(val)) {
93+
return JSON.stringify(val)
94+
}
95+
return val
96+
})
97+
.refine((val) => val && val.trim() !== '', {
98+
message: 'Pipeline is required',
99+
}),
100+
})
101+
.refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError)
102+
103+
export const mongodbInsertBodySchema = mongoConnectionBaseSchema
104+
.extend({
105+
collection: z.string().min(1, 'Collection name is required'),
106+
documents: z
107+
.union([z.array(z.record(z.string(), z.unknown())), z.string()])
108+
.transform((val) => {
109+
if (typeof val === 'string') {
110+
try {
111+
const parsed = JSON.parse(val)
112+
return Array.isArray(parsed) ? parsed : [parsed]
113+
} catch {
114+
throw new Error('Invalid JSON in documents field')
115+
}
116+
}
117+
return val
118+
})
119+
.refine((val) => Array.isArray(val) && val.length > 0, {
120+
message: 'At least one document is required',
121+
}),
122+
})
123+
.refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError)
124+
125+
export const mongodbUpdateBodySchema = mongoConnectionBaseSchema
126+
.extend({
127+
collection: z.string().min(1, 'Collection name is required'),
128+
filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Update').refine(
129+
(val) => val !== '{}',
130+
{ message: 'Filter is required for MongoDB Update' }
131+
),
132+
update: mongoJsonStringOrObjectSchema('Update is required'),
133+
upsert: booleanStringSchema,
134+
multi: booleanStringSchema,
135+
})
136+
.refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError)
137+
138+
export const mongodbDeleteBodySchema = mongoConnectionBaseSchema
139+
.extend({
140+
collection: z.string().min(1, 'Collection name is required'),
141+
filter: mongoJsonStringOrObjectSchema('Filter is required for MongoDB Delete').refine(
142+
(val) => val !== '{}',
143+
{ message: 'Filter is required for MongoDB Delete' }
144+
),
145+
multi: booleanStringSchema,
146+
})
147+
.refine(mongoUsernamePasswordPaired, mongoUsernamePasswordPairedError)
134148

135149
export const mongodbIntrospectBodySchema = z
136150
.object({

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const env = createEnv({
4848

4949
// Database & Storage
5050
REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions
51+
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
5152

5253
// Payment & Billing
5354
STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing

apps/sim/lib/core/config/redis.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@ const logger = createLogger('Redis')
88

99
const redisUrl = env.REDIS_URL
1010

11+
/**
12+
* When REDIS_URL targets a bare IP over `rediss://` (e.g. trigger.dev's
13+
* PrivateLink VPCE IP), default TLS hostname verification fails — the cert
14+
* is issued for the ElastiCache DNS name, not the IP. Override SNI with
15+
* REDIS_TLS_SERVERNAME (set to the DNS the cert was issued for).
16+
*
17+
* For DNS hosts: no override needed, default verification works.
18+
*/
19+
function resolveTlsOptions(url: string | undefined): { servername: string } | undefined {
20+
if (!url) return undefined
21+
let parsed: URL
22+
try {
23+
parsed = new URL(url)
24+
} catch {
25+
return undefined
26+
}
27+
if (parsed.protocol !== 'rediss:') return undefined
28+
const hostIsIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname)
29+
if (!hostIsIp) return undefined
30+
if (!env.REDIS_TLS_SERVERNAME) {
31+
throw new Error(
32+
'REDIS_TLS_SERVERNAME must be set when REDIS_URL targets an IP over rediss://. ' +
33+
'TLS cert hostname verification cannot match an IP — set REDIS_TLS_SERVERNAME ' +
34+
'to the DNS name the cert was issued for (the ElastiCache primary endpoint).'
35+
)
36+
}
37+
return { servername: env.REDIS_TLS_SERVERNAME }
38+
}
39+
1140
let globalRedisClient: Redis | null = null
1241
let pingFailures = 0
1342
let pingInterval: NodeJS.Timeout | null = null
@@ -87,6 +116,9 @@ export function getRedisClient(): Redis | null {
87116
if (!redisUrl) return null
88117
if (globalRedisClient) return globalRedisClient
89118

119+
// Outside the try/catch so config errors aren't silently swallowed.
120+
const tls = resolveTlsOptions(redisUrl)
121+
90122
try {
91123
logger.info('Initializing Redis client')
92124

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

100133
retryStrategy: (times) => {
101134
if (times > 10) {

0 commit comments

Comments
 (0)