mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(redis): TLS SNI override for IP-based REDIS_URL + zod schema fixes (#4635)
* feat(redis): allow TLS SNI override for IP-based REDIS_URL
When trigger.dev's hosted workers reach our ElastiCache via PrivateLink,
their REDIS_URL contains the VPCE-assigned IP, not a DNS name. Default
ioredis TLS verification fails because the ElastiCache cert is issued for
the cluster's DNS, not the IP.
Add REDIS_TLS_SERVERNAME env var; when REDIS_URL is rediss:// + IP host,
pass `tls: { servername }` to ioredis so cert hostname verification
matches against the DNS name instead. Throws at client construction if
REDIS_TLS_SERVERNAME is unset in this scenario (fail fast — no silent
TLS bypass).
No-op for in-VPC connections (DNS host), so the always-on Sim app keeps
using default verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(confluence-schemas): expose extendable bases before .superRefine
confluenceCommentScopedSchema and confluenceBlogPostScopedSchema were
built with .extend(...).superRefine(...). superRefine returns a
ZodEffects which has no .extend method, so the three downstream
.extend() calls (confluenceUpdateCommentBodySchema,
confluenceGetBlogPostBodySchema, confluenceUpdateBlogPostBodySchema)
threw at module-init time.
Next.js lazy-loads route code per-request and never executed this
top-level chain, hiding the issue. Trigger.dev's bundler eagerly
evaluates all task-reachable modules at startup, which is why the
trigger.dev deploy surfaced it as "confluenceCommentScopedSchema.extend
is not a function" across every background task that transitively
imports this file.
Fix: introduce un-superRefined base schemas and use them as the .extend
target downstream; apply superRefine after each .extend so validation
behavior is preserved for every consumer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(storage-transfer): use z.string().url() for Zod 3 compat
z.url() is Zod 4 top-level syntax. The hoisted node_modules/zod
resolves to v3.25.76 (despite apps/sim/package.json declaring 4.3.6 —
a workspace resolution conflict), so z.url is undefined at runtime.
Trigger.dev's bundler eagerly evaluates all task-reachable modules at
startup and hits this with `external_exports.url is not a function`.
Next.js dev only evaluates routes per-request so the call site never
fires.
Quick fix: revert to the chained .string().url() form which works on
both Zod 3 and Zod 4 (deprecated in 4 but still supported). The
underlying version-resolution conflict is a separate cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mongodb-schemas): expose extendable base before .refine
mongoConnectionBodySchema was built with z.object(...).refine(...). Five
downstream schemas (mongodbQueryBodySchema, mongodbExecuteBodySchema,
mongodbInsertBodySchema, mongodbUpdateBodySchema, mongodbDeleteBodySchema)
.extend() that result, which threw at module-init in the trigger.dev
bundle (same root cause as the confluence and storage-transfer fixes:
.refine returns ZodEffects with no .extend method, and the resolved
zod is v3 even though package.json declares v4).
Fix: keep the un-refined mongoConnectionBaseSchema for downstream
.extend() targets. The pairing-validation refine isn't reattached
because the downstream extensions were never actually evaluating it
(module init threw before they could).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(redis,mongodb): address PR review comments
- redis.ts: move resolveTlsOptions call outside the try/catch in
getRedisClient so config errors surface instead of being swallowed
into a silent null return.
- mongodb.ts: re-attach mongoUsernamePasswordPaired .refine after each
of the five downstream .extend()s. Mirrors the confluence pattern
and restores the pairing constraint that the original chain dropped.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
67e18f747d
commit
bd9e692b91
@@ -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 = <TBody extends z.ZodType>(path: string, body: TBody) =>
|
||||
defineRouteContract({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
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 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 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 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,
|
||||
})
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user