chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import { env } from '@/lib/core/config/env'
/**
* Rotates through available API keys for a provider
* @param provider - The provider to get a key for (e.g., 'openai')
* @returns The selected API key
* @throws Error if no API keys are configured for rotation
*/
export function getRotatingApiKey(provider: string): string {
if (
provider !== 'openai' &&
provider !== 'anthropic' &&
provider !== 'gemini' &&
provider !== 'cohere' &&
provider !== 'zai' &&
provider !== 'xai'
) {
throw new Error(`No rotation implemented for provider: ${provider}`)
}
const keys = []
if (provider === 'openai') {
if (env.OPENAI_API_KEY_1) keys.push(env.OPENAI_API_KEY_1)
if (env.OPENAI_API_KEY_2) keys.push(env.OPENAI_API_KEY_2)
if (env.OPENAI_API_KEY_3) keys.push(env.OPENAI_API_KEY_3)
} else if (provider === 'anthropic') {
if (env.ANTHROPIC_API_KEY_1) keys.push(env.ANTHROPIC_API_KEY_1)
if (env.ANTHROPIC_API_KEY_2) keys.push(env.ANTHROPIC_API_KEY_2)
if (env.ANTHROPIC_API_KEY_3) keys.push(env.ANTHROPIC_API_KEY_3)
} else if (provider === 'gemini') {
if (env.GEMINI_API_KEY_1) keys.push(env.GEMINI_API_KEY_1)
if (env.GEMINI_API_KEY_2) keys.push(env.GEMINI_API_KEY_2)
if (env.GEMINI_API_KEY_3) keys.push(env.GEMINI_API_KEY_3)
} else if (provider === 'cohere') {
if (env.COHERE_API_KEY_1) keys.push(env.COHERE_API_KEY_1)
if (env.COHERE_API_KEY_2) keys.push(env.COHERE_API_KEY_2)
if (env.COHERE_API_KEY_3) keys.push(env.COHERE_API_KEY_3)
} else if (provider === 'zai') {
if (env.ZAI_API_KEY_1) keys.push(env.ZAI_API_KEY_1)
if (env.ZAI_API_KEY_2) keys.push(env.ZAI_API_KEY_2)
if (env.ZAI_API_KEY_3) keys.push(env.ZAI_API_KEY_3)
} else if (provider === 'xai') {
if (env.XAI_API_KEY_1) keys.push(env.XAI_API_KEY_1)
if (env.XAI_API_KEY_2) keys.push(env.XAI_API_KEY_2)
if (env.XAI_API_KEY_3) keys.push(env.XAI_API_KEY_3)
}
if (keys.length === 0) {
throw new Error(
`No API keys configured for rotation. Please configure ${provider.toUpperCase()}_API_KEY_1, ${provider.toUpperCase()}_API_KEY_2, or ${provider.toUpperCase()}_API_KEY_3.`
)
}
// Simple round-robin rotation based on current minute
// This distributes load across keys and is stateless
const currentMinute = new Date().getMinutes()
const keyIndex = currentMinute % keys.length
return keys[keyIndex]
}
@@ -0,0 +1,69 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { matchesRule, normalizeRule, parseGateConfig } from '@/lib/core/config/appconfig-rules'
describe('normalizeRule', () => {
it('returns null for non-object values', () => {
expect(normalizeRule('nope')).toBeNull()
expect(normalizeRule(null)).toBeNull()
expect(normalizeRule(42)).toBeNull()
})
it('keeps only boolean enabled/adminEnabled', () => {
expect(normalizeRule({ enabled: 'true', adminEnabled: 1 })).toEqual({})
expect(normalizeRule({ enabled: true, adminEnabled: false })).toEqual({
enabled: true,
adminEnabled: false,
})
})
it('trims, dedupes, and drops empty ids', () => {
expect(normalizeRule({ orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' })).toEqual({
orgIds: ['Org_1', 'org_1', 'org_2'],
})
})
})
describe('parseGateConfig', () => {
it('drops malformed entries and coerces the rest', () => {
const rules = parseGateConfig({
a: { enabled: true },
b: 'not-an-object',
c: { userIds: ['u1'] },
})
expect(rules.a).toEqual({ enabled: true })
expect(rules.b).toBeUndefined()
expect(rules.c).toEqual({ userIds: ['u1'] })
})
it('degrades to an empty map on a malformed document', () => {
expect(parseGateConfig('not-an-object')).toEqual({})
expect(parseGateConfig(null)).toEqual({})
})
})
describe('matchesRule', () => {
it('returns false for a missing rule', () => {
expect(matchesRule(undefined, { userId: 'u1' }, true)).toBe(false)
})
it('matches the global enabled clause', () => {
expect(matchesRule({ enabled: true }, {}, false)).toBe(true)
expect(matchesRule({ enabled: false }, {}, false)).toBe(false)
})
it('matches the userId and orgId allowlists', () => {
expect(matchesRule({ userIds: ['u1'] }, { userId: 'u1' }, false)).toBe(true)
expect(matchesRule({ userIds: ['u1'] }, { userId: 'u2' }, false)).toBe(false)
expect(matchesRule({ orgIds: ['o1'] }, { orgId: 'o1' }, false)).toBe(true)
expect(matchesRule({ orgIds: ['o1'] }, {}, false)).toBe(false)
})
it('matches the admin clause only with the supplied isAdmin', () => {
expect(matchesRule({ adminEnabled: true }, { userId: 'u1' }, true)).toBe(true)
expect(matchesRule({ adminEnabled: true }, { userId: 'u1' }, false)).toBe(false)
expect(matchesRule({ enabled: false }, { userId: 'u1' }, true)).toBe(false)
})
})
@@ -0,0 +1,80 @@
/**
* Shared parsing and clause evaluation for AppConfig gating documents
* (`feature-flags`, `block-visibility`). Both documents are maps of key →
* gate rule with identical rule shapes; this module is the single copy of the
* security-sensitive normalization that prevents a malformed document from
* granting access. Admin-resolution *scheduling* deliberately stays with the
* callers (feature-flags resolves lazily per rule; block-visibility resolves
* once per document), so {@link matchesRule} takes an explicit `isAdmin`.
*/
/**
* A single gating rule. A gate is open for a context when ANY clause matches:
* the global `enabled` default, the org/user allowlists, or `adminEnabled` for
* platform admins. An absent clause never matches.
*/
export interface AppConfigGateRule {
enabled?: boolean
orgIds?: string[]
userIds?: string[]
adminEnabled?: boolean
}
/**
* Per-request evaluation context. Pass only the ids you have — a missing id
* skips its clause. `isAdmin` is a fast-path override for callers that already
* resolved platform-admin status.
*/
export interface AppConfigGateContext {
userId?: string | null
orgId?: string | null
isAdmin?: boolean
}
function normalizeIds(values: unknown): string[] | undefined {
if (!Array.isArray(values)) return undefined
const ids = Array.from(new Set(values.map((v) => String(v).trim()).filter(Boolean)))
return ids.length > 0 ? ids : undefined
}
/** Coerce a single arbitrary JSON value into a rule, or `null` when malformed. */
export function normalizeRule(value: unknown): AppConfigGateRule | null {
if (!value || typeof value !== 'object') return null
const obj = value as Record<string, unknown>
const rule: AppConfigGateRule = {}
if (typeof obj.enabled === 'boolean') rule.enabled = obj.enabled
if (typeof obj.adminEnabled === 'boolean') rule.adminEnabled = obj.adminEnabled
const orgIds = normalizeIds(obj.orgIds)
if (orgIds) rule.orgIds = orgIds
const userIds = normalizeIds(obj.userIds)
if (userIds) rule.userIds = userIds
return rule
}
/** Coerce an arbitrary AppConfig/JSON document into a rule map, dropping malformed entries. */
export function parseGateConfig(json: unknown): Record<string, AppConfigGateRule> {
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
const rules: Record<string, AppConfigGateRule> = {}
for (const [key, value] of Object.entries(obj)) {
const rule = normalizeRule(value)
if (rule) rules[key] = rule
}
return rules
}
/**
* Pure OR-of-clauses check. The caller supplies `isAdmin` — pass `false` to
* evaluate only the non-admin clauses (for lazy admin resolution).
*/
export function matchesRule(
rule: AppConfigGateRule | undefined,
ctx: AppConfigGateContext,
isAdmin: boolean
): boolean {
if (!rule) return false
if (rule.enabled) return true
if (ctx.userId && rule.userIds?.includes(ctx.userId)) return true
if (ctx.orgId && rule.orgIds?.includes(ctx.orgId)) return true
if (rule.adminEnabled && isAdmin) return true
return false
}
+136
View File
@@ -0,0 +1,136 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSend } = vi.hoisted(() => ({
mockSend: vi.fn(),
}))
vi.mock('@aws-sdk/client-appconfigdata', () => ({
AppConfigDataClient: class {
send = mockSend
},
StartConfigurationSessionCommand: class {
__type = 'start'
constructor(public input: unknown) {}
},
GetLatestConfigurationCommand: class {
__type = 'get'
constructor(public input: unknown) {}
},
}))
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value))
let counter = 0
/** Unique identifiers per test so the module-level cache never bleeds across tests. */
function uniqueIds() {
counter += 1
return { application: `app-${counter}`, environment: `env-${counter}`, profile: 'access-control' }
}
describe('fetchAppConfigProfile', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts a session then returns the parsed configuration', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: encode({ blockedSignupDomains: ['spam.example'] }),
NextPollConfigurationToken: 'tok-2',
})
})
const result = await fetchAppConfigProfile(
uniqueIds(),
(json) => json as Record<string, unknown>
)
expect(result).toEqual({ blockedSignupDomains: ['spam.example'] })
const sentTypes = mockSend.mock.calls.map(([c]) => c.__type)
expect(sentTypes).toEqual(['start', 'get'])
})
it('returns null when the cold fetch fails (never throws)', async () => {
mockSend.mockRejectedValue(new Error('appconfig down'))
const result = await fetchAppConfigProfile(uniqueIds(), (json) => json)
expect(result).toBeNull()
})
it('applies the parse function to the decoded JSON', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: encode({ count: 2 }),
NextPollConfigurationToken: 'tok-2',
})
})
const result = await fetchAppConfigProfile(
uniqueIds(),
(json) => (json as { count: number }).count * 10
)
expect(result).toBe(20)
})
it('warms the cache on an empty payload and does not re-poll (unseeded profile)', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: new Uint8Array(),
NextPollConfigurationToken: 'tok-2',
NextPollIntervalInSeconds: 60,
})
})
const ids = uniqueIds()
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
const callsAfterFirst = mockSend.mock.calls.length
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
expect(mockSend.mock.calls.length).toBe(callsAfterFirst)
})
it('keeps the session on a parse error (no re-StartConfigurationSession, no throw)', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: new TextEncoder().encode('not json{'),
NextPollConfigurationToken: 'tok-2',
NextPollIntervalInSeconds: 60,
})
})
const ids = uniqueIds()
expect(await fetchAppConfigProfile(ids, (json) => json)).toBeNull()
// Network round trip succeeded, so exactly one session was started despite the
// parse failure — the rotated token was preserved, not discarded.
expect(mockSend.mock.calls.filter(([c]) => c.__type === 'start')).toHaveLength(1)
})
it('dedupes concurrent cold fetches into a single poll', async () => {
mockSend.mockImplementation((command: { __type: string }) => {
if (command.__type === 'start') return Promise.resolve({ InitialConfigurationToken: 'tok-1' })
return Promise.resolve({
Configuration: encode({ x: 1 }),
NextPollConfigurationToken: 'tok-2',
})
})
const ids = uniqueIds()
const [a, b] = await Promise.all([
fetchAppConfigProfile(ids, (json) => json),
fetchAppConfigProfile(ids, (json) => json),
])
expect(a).toEqual({ x: 1 })
expect(b).toEqual({ x: 1 })
expect(mockSend.mock.calls.map(([c]) => c.__type)).toEqual(['start', 'get'])
})
})
+167
View File
@@ -0,0 +1,167 @@
import {
AppConfigDataClient,
GetLatestConfigurationCommand,
type GetLatestConfigurationCommandOutput,
StartConfigurationSessionCommand,
} from '@aws-sdk/client-appconfigdata'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
import { env } from '@/lib/core/config/env'
const logger = createLogger('AppConfig')
const DEFAULT_TTL_MS = 30_000
export interface AppConfigProfileIdentifiers {
application: string
environment: string
profile: string
}
interface CacheEntry<T> {
/** Last successfully parsed value, or `null` if the config is empty/unseeded. */
value: T | null
/** True once any poll has completed (success, empty payload, or error). */
loaded: boolean
/** Token for the next `GetLatestConfiguration` poll, rotated on each call. */
nextToken: string | undefined
expiresAt: number
/** In-flight poll, shared so concurrent callers don't each hit AppConfig. */
inflight: Promise<T | null> | null
}
const cache = new Map<string, CacheEntry<unknown>>()
let client: AppConfigDataClient | null = null
/**
* Lazily construct the AppConfig data-plane client. Never instantiated unless a
* caller actually fetches a profile, so deployments without AppConfig configured
* never reach for AWS credentials.
*/
function getClient(): AppConfigDataClient {
if (!client) {
client = new AppConfigDataClient({
region: env.AWS_REGION,
credentials: getAwsCredentialsFromEnv(),
})
}
return client
}
function cacheKey(ids: AppConfigProfileIdentifiers): string {
return `${ids.application}/${ids.environment}/${ids.profile}`
}
/**
* Run one AppConfig poll for `entry`: starts a session if no token is held, then
* calls `GetLatestConfiguration`. An empty payload means "unchanged" (or an
* unseeded profile) and the previous value is kept. Any error is logged and the
* last good value is retained. Marks the entry `loaded` on any outcome so callers
* never re-block on the cold path, and honors AppConfig's `NextPollInterval` so we
* don't poll faster than the server allows (which would throttle).
*/
async function poll<T>(
ids: AppConfigProfileIdentifiers,
parse: (json: unknown) => T,
entry: CacheEntry<T>
): Promise<T | null> {
let response: GetLatestConfigurationCommandOutput
try {
const dataClient = getClient()
if (!entry.nextToken) {
const session = await dataClient.send(
new StartConfigurationSessionCommand({
ApplicationIdentifier: ids.application,
EnvironmentIdentifier: ids.environment,
ConfigurationProfileIdentifier: ids.profile,
})
)
entry.nextToken = session.InitialConfigurationToken
}
response = await dataClient.send(
new GetLatestConfigurationCommand({ ConfigurationToken: entry.nextToken })
)
entry.nextToken = response.NextPollConfigurationToken ?? entry.nextToken
} catch (error) {
// Network/session failure: drop the token so the next attempt starts a fresh
// session (handles expired or invalid tokens). Mark loaded + back off so we
// serve the fallback and retry in the background rather than blocking every
// request during an outage.
entry.nextToken = undefined
entry.expiresAt = Date.now() + DEFAULT_TTL_MS
entry.loaded = true
logger.error('AppConfig fetch failed; serving last known value', {
profile: cacheKey(ids),
error: getErrorMessage(error),
})
return entry.value
}
// Parse outside the network try: a decode/parse error must NOT discard the
// already-rotated session token — the round trip succeeded, so the next poll
// can reuse it instead of opening a new session. Keep the last good value.
try {
if (response.Configuration && response.Configuration.length > 0) {
const text = new TextDecoder().decode(response.Configuration)
entry.value = parse(JSON.parse(text))
}
} catch (error) {
logger.error('AppConfig response parse failed; serving last known value', {
profile: cacheKey(ids),
error: getErrorMessage(error),
})
}
const intervalMs = (response.NextPollIntervalInSeconds ?? 60) * 1000
entry.expiresAt = Date.now() + Math.max(DEFAULT_TTL_MS, intervalMs)
entry.loaded = true
return entry.value
}
/**
* Fetch and cache a single AppConfig configuration profile as JSON.
*
* Profile-agnostic: pass the `application`/`environment` (from env) and a
* `profile` constant owned by the calling feature. Uses an in-process TTL cache
* with stale-while-revalidate — a warm cache returns immediately and refreshes
* in the background once the TTL lapses, so no request blocks on the AppConfig
* round trip after the first (cold) fetch. Concurrent callers share one in-flight
* poll (avoids racing the rotating session token). Returns `null` when the config
* is empty/unseeded or the first fetch fails.
*/
export async function fetchAppConfigProfile<T>(
ids: AppConfigProfileIdentifiers,
parse: (json: unknown) => T
): Promise<T | null> {
const key = cacheKey(ids)
const entry = (cache.get(key) as CacheEntry<T> | undefined) ?? {
value: null,
loaded: false,
nextToken: undefined,
expiresAt: 0,
inflight: null,
}
cache.set(key, entry)
// Cold: never polled — await a single shared poll so concurrent callers don't
// each hit AppConfig (and don't race the rotating session token).
if (!entry.loaded) {
entry.inflight ??= poll(ids, parse, entry).finally(() => {
entry.inflight = null
})
return entry.inflight
}
// Warm but stale: serve cached value, refresh once in the background.
if (Date.now() >= entry.expiresAt && !entry.inflight) {
entry.inflight = poll(ids, parse, entry).finally(() => {
entry.inflight = null
})
}
return entry.value
}
+24
View File
@@ -0,0 +1,24 @@
import { env } from '@/lib/core/config/env'
interface AwsCredentials {
accessKeyId: string
secretAccessKey: string
}
/**
* Explicit AWS credentials from the environment, or `undefined` to defer to the
* default AWS provider chain (the ECS task role in our deployments).
*
* Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is
* identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and
* `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise
* the instance/task role.
*/
export function getAwsCredentialsFromEnv(): AwsCredentials | undefined {
return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
? {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
}
: undefined
}
@@ -0,0 +1,157 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockIsPlatformAdmin: vi.fn(),
envRef: {
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
},
flagRef: { isAppConfigEnabled: false, previewBlocks: [] as string[] },
}))
vi.mock('@/lib/core/config/appconfig', () => ({
fetchAppConfigProfile: mockFetch,
}))
vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isAppConfigEnabled() {
return flagRef.isAppConfigEnabled
},
getPreviewBlocksFromEnv: () => flagRef.previewBlocks,
}))
vi.mock('@/lib/permissions/super-user', () => ({
isPlatformAdmin: mockIsPlatformAdmin,
}))
import { getBlockVisibility } from '@/lib/core/config/block-visibility'
/** Make `getBlockVisibility` resolve `doc` via the AppConfig path (also exercises parsing). */
function withAppConfig(doc: unknown) {
flagRef.isAppConfigEnabled = true
mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc)))
}
describe('getBlockVisibility', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
flagRef.previewBlocks = []
})
describe('off-AppConfig (env fallback)', () => {
it('reveals and preview-tags the PREVIEW_BLOCKS types without fetching', async () => {
flagRef.previewBlocks = ['gmail_v2', 'notion_v3']
const vis = await getBlockVisibility({ userId: 'u1' })
expect(vis.revealed).toEqual(new Set(['gmail_v2', 'notion_v3']))
expect(vis.previewTagged).toEqual(new Set(['gmail_v2', 'notion_v3']))
expect(vis.disabled.size).toBe(0)
expect(mockFetch).not.toHaveBeenCalled()
})
it('returns empty state when PREVIEW_BLOCKS is unset', async () => {
const vis = await getBlockVisibility()
expect(vis.revealed.size).toBe(0)
expect(vis.disabled.size).toBe(0)
expect(vis.previewTagged.size).toBe(0)
})
})
it('fetches the block-visibility profile', async () => {
withAppConfig({})
await getBlockVisibility()
expect(mockFetch).toHaveBeenCalledWith(
{ application: 'sim-staging', environment: 'staging', profile: 'block-visibility' },
expect.any(Function)
)
})
it('GA rule (enabled: true) reveals without a preview tag', async () => {
withAppConfig({ gmail_v2: { enabled: true } })
const vis = await getBlockVisibility({ userId: 'u1' })
expect(vis.revealed.has('gmail_v2')).toBe(true)
expect(vis.previewTagged.has('gmail_v2')).toBe(false)
expect(vis.disabled.has('gmail_v2')).toBe(false)
})
it('allowlist rule reveals with a preview tag; non-matching viewers get disabled', async () => {
withAppConfig({ gmail_v2: { enabled: false, orgIds: ['o1'], userIds: ['u9'] } })
const allowedOrg = await getBlockVisibility({ orgId: 'o1' })
expect(allowedOrg.revealed.has('gmail_v2')).toBe(true)
expect(allowedOrg.previewTagged.has('gmail_v2')).toBe(true)
const allowedUser = await getBlockVisibility({ userId: 'u9' })
expect(allowedUser.revealed.has('gmail_v2')).toBe(true)
const denied = await getBlockVisibility({ userId: 'u1', orgId: 'o2' })
expect(denied.revealed.has('gmail_v2')).toBe(false)
expect(denied.disabled.has('gmail_v2')).toBe(true)
})
it('kill switch (enabled: false, no allowlists) disables for everyone', async () => {
withAppConfig({ slack: { enabled: false } })
const vis = await getBlockVisibility({ userId: 'u1', orgId: 'o1' })
expect(vis.disabled.has('slack')).toBe(true)
expect(vis.revealed.has('slack')).toBe(false)
})
it('drops custom_block_* keys so custom blocks can never be gated', async () => {
withAppConfig({ custom_block_abc123: { enabled: false }, gmail_v2: { enabled: true } })
const vis = await getBlockVisibility({ userId: 'u1' })
expect(vis.disabled.has('custom_block_abc123')).toBe(false)
expect(vis.revealed.has('custom_block_abc123')).toBe(false)
expect(vis.revealed.has('gmail_v2')).toBe(true)
})
it('drops malformed entries', async () => {
withAppConfig({ a: 'nope', b: { enabled: false, orgIds: [' o1 ', ''] } })
const vis = await getBlockVisibility({ orgId: 'o1' })
expect(vis.disabled.has('a')).toBe(false)
expect(vis.revealed.has('b')).toBe(true)
})
describe('admin resolution (once per call)', () => {
it('resolves admin exactly once for a document with multiple adminEnabled rules', async () => {
withAppConfig({
a: { enabled: false, adminEnabled: true },
b: { enabled: false, adminEnabled: true },
c: { enabled: false },
})
mockIsPlatformAdmin.mockResolvedValue(true)
const vis = await getBlockVisibility({ userId: 'u1' })
expect(mockIsPlatformAdmin).toHaveBeenCalledTimes(1)
expect(vis.revealed).toEqual(new Set(['a', 'b']))
expect(vis.previewTagged).toEqual(new Set(['a', 'b']))
expect(vis.disabled).toEqual(new Set(['c']))
})
it('uses the isAdmin fast-path without querying', async () => {
withAppConfig({ a: { enabled: false, adminEnabled: true } })
const vis = await getBlockVisibility({ userId: 'u1', isAdmin: true })
expect(vis.revealed.has('a')).toBe(true)
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
})
it('does not query when no rule has adminEnabled or when userId is absent', async () => {
withAppConfig({ a: { enabled: false, orgIds: ['o1'] } })
await getBlockVisibility({ userId: 'u1' })
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
withAppConfig({ a: { enabled: false, adminEnabled: true } })
const vis = await getBlockVisibility({ orgId: 'o1' })
expect(vis.disabled.has('a')).toBe(true)
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,110 @@
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
import type { AppConfigGateContext, AppConfigGateRule } from '@/lib/core/config/appconfig-rules'
import { matchesRule, parseGateConfig } from '@/lib/core/config/appconfig-rules'
import { env } from '@/lib/core/config/env'
import { getPreviewBlocksFromEnv, isAppConfigEnabled } from '@/lib/core/config/env-flags'
/**
* Name of the AppConfig configuration profile holding per-block visibility rules.
* Cross-repo contract: must match the `CfnConfigurationProfile` name created by
* the infra stack (`BLOCK_VISIBILITY_PROFILE_NAME`).
*/
const BLOCK_VISIBILITY_PROFILE = 'block-visibility'
/**
* Custom (deploy-as-block) block types are org-scoped and managed by their own
* enabled/disabled lifecycle — the visibility document must never gate them.
* Literal mirrors `CUSTOM_BLOCK_TYPE_PREFIX` in `@/blocks/custom/build-config`,
* not imported to keep the blocks graph out of this config module.
*/
const CUSTOM_BLOCK_KEY_PREFIX = 'custom_block_'
/** Per-request evaluation context; same shape as the feature-flag context. */
export type BlockVisibilityContext = AppConfigGateContext
/**
* The evaluated per-viewer visibility projection.
*
* - `revealed` — preview block types this viewer may see.
* - `disabled` — types whose rule exists but matched no clause; hides
* non-preview (shipped) blocks from discovery surfaces (the kill switch).
* - `previewTagged` — revealed types not globally GA (`enabled !== true`);
* the registry appends " (Preview)" to their names.
*
* All three are needed: `revealed \ previewTagged` is the "GA'd via config while
* `preview: true` is still in code" window, and `disabled` targets a disjoint
* (non-preview) population.
*/
export interface BlockVisibilityState {
revealed: Set<string>
disabled: Set<string>
previewTagged: Set<string>
}
function parseVisibilityConfig(json: unknown): Record<string, AppConfigGateRule> {
const rules = parseGateConfig(json)
for (const key of Object.keys(rules)) {
if (key.startsWith(CUSTOM_BLOCK_KEY_PREFIX)) delete rules[key]
}
return rules
}
/**
* Resolve platform-admin status lazily. Dynamically imported so the DB-backed
* helper (and `@sim/db`) stay out of this config module's load graph for callers
* that never reach an admin-gated rule.
*/
async function resolveAdmin(userId: string): Promise<boolean> {
const { isPlatformAdmin } = await import('@/lib/permissions/super-user')
return isPlatformAdmin(userId)
}
/**
* Evaluate the block-visibility document for a viewer.
*
* On hosted deployments the rules come from the AppConfig profile (cached,
* ~30s TTL); off-AppConfig the `PREVIEW_BLOCKS` env allowlist is the only
* reveal path and nothing is disabled.
*
* Unlike feature-flags (one rule per call, admin resolved lazily per rule),
* this evaluates the whole document, so platform-admin status is resolved at
* most ONCE per call — and only when some rule actually has `adminEnabled` and
* the caller didn't already supply `ctx.isAdmin`.
*/
export async function getBlockVisibility(
ctx: BlockVisibilityContext = {}
): Promise<BlockVisibilityState> {
if (!isAppConfigEnabled) {
const revealed = new Set(getPreviewBlocksFromEnv())
return { revealed, disabled: new Set(), previewTagged: new Set(revealed) }
}
const rules =
(await fetchAppConfigProfile(
{
application: env.APPCONFIG_APPLICATION as string,
environment: env.APPCONFIG_ENVIRONMENT as string,
profile: BLOCK_VISIBILITY_PROFILE,
},
parseVisibilityConfig
)) ?? {}
const needsAdmin =
ctx.isAdmin === undefined &&
Boolean(ctx.userId) &&
Object.values(rules).some((rule) => rule.adminEnabled)
const isAdmin = ctx.isAdmin ?? (needsAdmin ? await resolveAdmin(ctx.userId as string) : false)
const revealed = new Set<string>()
const disabled = new Set<string>()
const previewTagged = new Set<string>()
for (const [type, rule] of Object.entries(rules)) {
if (matchesRule(rule, ctx, isAdmin)) {
revealed.add(type)
if (rule.enabled !== true) previewTagged.add(type)
} else {
disabled.add(type)
}
}
return { revealed, disabled, previewTagged }
}
+354
View File
@@ -0,0 +1,354 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
import { env, getEnv, isFalsy, isTruthy } from './env'
/**
* Is the application running in production mode
*/
export const isProd = env.NODE_ENV === 'production'
/**
* Is the application running in development mode
*/
export const isDev = env.NODE_ENV === 'development'
/**
* Is the application running in test mode
*/
export const isTest = env.NODE_ENV === 'test'
/**
* Is this the hosted version of the application.
* True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai).
*/
const appUrl = getEnv('NEXT_PUBLIC_APP_URL')
let appHostname = ''
try {
appHostname = appUrl ? new URL(appUrl).hostname : ''
} catch {
// invalid URL — isHosted stays false
}
export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai')
/**
* Is billing enforcement enabled
*/
export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
/**
* Block free-plan accounts from programmatic workflow execution (API key, public
* API, MCP server, generic webhooks, cross-origin chat embeds).
* Gated behind {@link isBillingEnabled}; off by default so the paywall can ship
* dark and be enabled per-deployment once verified.
*/
export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_ENABLED)
/**
* Is email verification enabled
*/
export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED)
/**
* Is authentication disabled (for self-hosted deployments behind private networks)
* This flag is blocked when isHosted is true.
*/
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted
if (isTruthy(env.DISABLE_AUTH)) {
import('@sim/logger')
.then(({ createLogger }) => {
const logger = createLogger('EnvFlags')
if (isHosted) {
logger.error(
'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.'
)
} else {
logger.warn(
'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.'
)
}
})
.catch(() => {
// Fallback during config compilation when logger is unavailable
})
}
/**
* Whether database/connector tools may connect to private, reserved, or loopback
* hosts (e.g. Docker/K8s service names, localhost). Off by default: the SSRF guard
* in {@link validateDatabaseHost} blocks these so an untrusted user cannot pivot
* into the deployment's internal network. Self-hosted operators can opt in when
* their database lives on the same private network. Blocked on the hosted platform
* regardless of the env var, mirroring {@link isAuthDisabled}.
*/
export const isPrivateDatabaseHostsAllowed = isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS) && !isHosted
if (isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS)) {
import('@sim/logger')
.then(({ createLogger }) => {
const logger = createLogger('EnvFlags')
if (isHosted) {
logger.error(
'ALLOW_PRIVATE_DATABASE_HOSTS is set but ignored on hosted environment. Private/reserved database hosts remain blocked for security.'
)
} else {
logger.warn(
'ALLOW_PRIVATE_DATABASE_HOSTS is enabled. Database/connector tools may reach private, reserved, and loopback hosts. Only use this in trusted private networks.'
)
}
})
.catch(() => {
// Fallback during config compilation when logger is unavailable
})
}
/**
* Is user registration disabled
*/
export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION)
/**
* Is email/password authentication enabled (defaults to true)
*/
export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED)
/**
* Is MX-based signup validation enabled (blocks no-MX domains and denylisted shared spam
* mail backends). Opt-in to avoid adding a DNS dependency or blocking legitimate signups on
* self-hosted deployments with non-standard mail setups; enable on abuse-targeted deployments.
*/
export const isSignupMxValidationEnabled = isTruthy(env.SIGNUP_MX_VALIDATION_ENABLED)
/**
* Is AWS AppConfig the source of truth for the signup/login gating lists.
* Hosted-only and requires both AppConfig identifiers (injected by the infra
* stack). Self-hosted/OSS deployments always use the env-var fallback, so the
* AppConfig client is never reached off-hosted.
*/
export const isAppConfigEnabled =
isHosted && Boolean(env.APPCONFIG_APPLICATION && env.APPCONFIG_ENVIRONMENT)
/**
* Is Trigger.dev enabled for async job processing
*/
export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
/**
* Is SSO enabled for enterprise authentication
*/
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
/**
* Is access control (permission groups) enabled via env var override
* This bypasses plan requirements for self-hosted deployments
*/
export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED)
/**
* Is organizations enabled
* True if billing is enabled (orgs come with billing), OR explicitly enabled via env var,
* OR if access control is enabled (access control requires organizations)
*/
export const isOrganizationsEnabled =
isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled
/**
* Is inbox (Sim Mailer) enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isInboxEnabled = isTruthy(env.INBOX_ENABLED)
/**
* Is whitelabeling enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED)
/**
* Is audit logs enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED)
/**
* Is data retention enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED)
/**
* Is data drains enabled via env var override
* This bypasses hosted requirements for self-hosted deployments
*/
export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED)
/**
* Is workspace forking enabled via env var override
* This bypasses hosted (Enterprise) requirements for self-hosted deployments
*/
export const isForkingEnabled = isTruthy(env.FORKING_ENABLED)
/**
* Is E2B enabled for remote code execution
*/
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
/**
* Whether the E2B document-generation sandbox is enabled.
*
* Requires E2B (with an API key) AND a dedicated doc-generation template id.
* When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node
* (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python
* (reportlab/openpyxl). When false, compilation stays on the JavaScript
* (isolated-vm) path, byte-identical to its prior behavior (and xlsx is
* unavailable). Drives both the Sim compile backend and the `docCompiler` flag
* sent to the copilot file subagent so the agent's output and compiler agree.
*/
export const isE2BDocEnabled =
isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID)
/**
* Whether Ollama is configured (OLLAMA_URL is set).
* When true, models that are not in the static cloud model list and have no
* slash-prefixed provider namespace are assumed to be Ollama models
* and do not require an API key.
*/
export const isOllamaConfigured = Boolean(env.OLLAMA_URL)
/**
* Whether Azure OpenAI / Azure Anthropic credentials are pre-configured at the server level
* (via AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_ANTHROPIC_ENDPOINT, etc.).
* When true, the endpoint, API key, and API version fields are hidden in the Agent block UI.
* Set NEXT_PUBLIC_AZURE_CONFIGURED=true in self-hosted deployments on Azure.
*/
export const isAzureConfigured = isTruthy(getEnv('NEXT_PUBLIC_AZURE_CONFIGURED'))
/**
* Whether a Cohere API key is pre-configured server-side for the Knowledge block reranker
* (`COHERE_API_KEY` or `COHERE_API_KEY_1/2/3`). When true, the Cohere API Key field is hidden
* in the Knowledge block UI.
* Set NEXT_PUBLIC_COHERE_CONFIGURED=true in self-hosted deployments that ship a Cohere key.
*/
export const isCohereConfigured = isTruthy(getEnv('NEXT_PUBLIC_COHERE_CONFIGURED'))
/**
* Are invitations disabled globally
* When true, workspace invitations are disabled for all users
*/
export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS)
/**
* Is public API access disabled globally
* When true, the public API toggle is hidden and public API access is blocked
*/
export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API)
/**
* Is Google OAuth login disabled
* When true, the Google OAuth login button is hidden even when credentials are configured
*/
export const isGoogleAuthDisabled = isTruthy(env.DISABLE_GOOGLE_AUTH)
/**
* Is GitHub OAuth login disabled
* When true, the GitHub OAuth login button is hidden even when credentials are configured
*/
export const isGithubAuthDisabled = isTruthy(env.DISABLE_GITHUB_AUTH)
/**
* Is Microsoft OAuth login disabled
* When true, the Microsoft OAuth login button is hidden even when credentials are configured
*/
export const isMicrosoftAuthDisabled = isTruthy(env.DISABLE_MICROSOFT_AUTH)
/**
* Is email/password signup disabled
* When true, new registrations via email/password are blocked at the server level.
* Existing users can still sign in with email/password.
*/
export const isEmailSignupDisabled = isTruthy(env.DISABLE_EMAIL_SIGNUP)
/**
* Is React Grab enabled for UI element debugging
* When true and in development mode, enables React Grab for copying UI element context to clipboard
*/
export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED)
/**
* Is React Scan enabled for performance debugging
* When true and in development mode, enables React Scan for detecting render performance issues
*/
export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED)
/**
* Returns the parsed allowlist of integration block types from the environment variable.
* If not set or empty, returns null (meaning all integrations are allowed).
*/
export function getAllowedIntegrationsFromEnv(): string[] | null {
if (!env.ALLOWED_INTEGRATIONS) return null
const parsed = env.ALLOWED_INTEGRATIONS.split(',')
.map((i) => i.trim().toLowerCase())
.filter(Boolean)
return parsed.length > 0 ? parsed : null
}
/**
* Returns the preview block types revealed via the environment variable — the
* off-AppConfig reveal path for self-hosters and local dev. If not set or empty,
* returns an empty array (all `preview: true` blocks stay hidden). Block types
* are already lowercase snake_case, so entries are trimmed but not lowercased.
*/
export function getPreviewBlocksFromEnv(): string[] {
if (!env.PREVIEW_BLOCKS) return []
return env.PREVIEW_BLOCKS.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
/**
* Returns the list of blacklisted provider IDs from the environment variable.
* If not set or empty, returns an empty array (meaning no providers are blacklisted).
*/
export function getBlacklistedProvidersFromEnv(): string[] {
if (!env.BLACKLISTED_PROVIDERS) return []
return env.BLACKLISTED_PROVIDERS.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
}
/**
* Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var.
* Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com").
* Extracts the hostname in either case.
*/
function normalizeDomainEntry(entry: string): string {
const trimmed = entry.trim().toLowerCase()
if (!trimmed) return ''
if (trimmed.includes('://')) {
try {
return new URL(trimmed).hostname
} catch {
return trimmed
}
}
return trimmed
}
/**
* Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var.
* Returns null if not set (all domains allowed), or parsed array of lowercase hostnames.
* Accepts both bare hostnames and full URLs in the env var value.
*/
export function getAllowedMcpDomainsFromEnv(): string[] | null {
if (!env.ALLOWED_MCP_DOMAINS) return null
const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean)
return parsed.length > 0 ? parsed : null
}
/**
* Get cost multiplier based on environment
*/
export function getCostMultiplier(): number {
return isProd ? (env.COST_MULTIPLIER ?? 1) : 1
}
+13
View File
@@ -0,0 +1,13 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { envNumber } from '@/lib/core/config/env'
describe('envNumber', () => {
it('can require integer env values for count-like settings', () => {
expect(envNumber('5', 1, { min: 1, integer: true })).toBe(5)
expect(envNumber('5.5', 1, { min: 1, integer: true })).toBe(1)
expect(envNumber(5.5, 1, { min: 1, integer: true })).toBe(1)
})
})
+648
View File
@@ -0,0 +1,648 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
/**
* Reads NEXT_PUBLIC_* env vars in both client and server contexts.
* Client reads `window.__ENV` (populated by `<PublicEnvScript>`); server reads `process.env`.
* We do not use next-runtime-env's `env()` helper because it calls `unstable_noStore()`,
* which Next 16.2+ rejects outside a request scope.
*/
const getEnv = (variable: string): string | undefined => {
if (typeof window === 'undefined') return process.env[variable]
return window.__ENV?.[variable] ?? process.env[variable]
}
// biome-ignore format: keep alignment for readability
export const env = createEnv({
skipValidation: true,
server: {
// Core Database & Authentication
DATABASE_URL: z.string().url(), // Primary database connection string
DATABASE_REPLICA_URL: z.string().url().optional(), // Read-replica connection string; opt-in reads fall back to the primary when unset
DB_APP_NAME: z.string().optional(), // Postgres application_name for query attribution (sim-app/sim-trigger/sim-realtime)
SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(), // Per-process pool profile selector (read directly by @sim/db)
DATABASE_URL_WEB: z.string().url().optional(), // Per-role primary URL override; @sim/db falls back to DATABASE_URL
DATABASE_URL_TRIGGER: z.string().url().optional(), // Per-role primary URL override (trigger)
DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime)
DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL
DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger)
DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime)
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Enable email/password authentication (server-side enforcement)
DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session)
ALLOW_PRIVATE_DATABASE_HOSTS: z.boolean().optional(), // Opt-in (self-hosted only): let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names). Loosens the SSRF boundary; ignored on the hosted platform.
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
BLOCKED_EMAILS: z.string().optional(), // Comma-separated list of specific email addresses banned from the platform (signup, sign-in, executions)
SIGNUP_MX_VALIDATION_ENABLED: z.boolean().optional(), // Opt-in: validate the email's MX backend at signup (blocks no-MX domains and denylisted shared spam backends). Off by default; enable on hosted/abuse-targeted deployments.
BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matched against the domain's resolved MX backend to catch throwaway domains that share a mail backend. No defaults — operators supply their own list. Only used when SIGNUP_MX_VALIDATION_ENABLED is set.
TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins.
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
INTERNAL_JWT_SECRET: z.string().min(32).optional(), // Dedicated signing key for internal JWTs (falls back to INTERNAL_API_SECRET); separating limits blast radius if one leaks
// Copilot
COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
COPILOT_SOURCE_ENV: z.enum(['dev', 'staging', 'prod']).optional(), // Source Sim environment sent to mothership for callbacks
COPILOT_DEV_URL: z.string().url().optional(), // Sim agent API URL for the dev mothership environment
COPILOT_STAGING_URL: z.string().url().optional(), // Sim agent API URL for the staging mothership environment
COPILOT_PROD_URL: z.string().url().optional(), // Sim agent API URL for the production mothership environment
AGENT_INDEXER_URL: z.string().url().optional(), // URL for agent training data indexer
AGENT_INDEXER_API_KEY: z.string().min(1).optional(), // API key for agent indexer authentication
COPILOT_STREAM_TTL_SECONDS: z.number().optional(), // Redis TTL for copilot SSE buffer
COPILOT_STREAM_EVENT_LIMIT: z.number().optional(), // Max events retained per stream
// 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
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret
STRIPE_FREE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for free tier
FREE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for free tier users
FREE_STORAGE_LIMIT_GB: z.number().optional().default(5), // Storage limit in GB for free tier users
STRIPE_PRO_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for pro tier
PRO_TIER_COST_LIMIT: z.number().optional(), // Cost limit for pro tier users
PRO_STORAGE_LIMIT_GB: z.number().optional().default(50), // Storage limit in GB for pro tier users
STRIPE_TEAM_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for team tier
TEAM_TIER_COST_LIMIT: z.number().optional(), // Cost limit for team tier users
TEAM_STORAGE_LIMIT_GB: z.number().optional().default(500), // Storage limit in GB for team tier organizations (pooled)
STRIPE_ENTERPRISE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for enterprise tier
ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users
ENTERPRISE_STORAGE_LIMIT_GB: z.number().optional().default(500), // Default storage limit in GB for enterprise tier (can be overridden per org)
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
FREE_API_DEPLOYMENT_GATE_ENABLED: z.boolean().optional(), // Block free-plan accounts from programmatic execution (API/MCP/A2A/generic webhooks/chat embeds). Requires BILLING_ENABLED. Off by default for dark rollout
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
PII_GRANULAR_REDACTION: z.boolean().optional(), // Expose the execution-altering PII redaction stages (redact workflow input + block outputs in-flight) in the Data Retention config; layered on top of PII_REDACTION
TRIGGER_EU_REGION: z.boolean().optional(), // Route Trigger.dev runs to eu-central-1 instead of the default us-east-1 (fallback for the trigger-eu-region flag when AppConfig is not the source of truth)
// Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans.
FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5)
FREE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on free tier (default: 50000)
PRO_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on pro tier (default: 100)
PRO_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on pro tier (default: 100000)
TEAM_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on team tier (default: 1000)
TEAM_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on team tier (default: 500000)
ENTERPRISE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on enterprise tier (default: 10000)
ENTERPRISE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on enterprise tier (default: 1000000)
TABLE_MAX_ROW_SIZE_BYTES: z.number().optional(), // Max serialized size in bytes of a single user-table row (default: 409600)
TABLE_MAX_PAGE_BYTES: z.number().optional(), // Dev-preview: byte budget per row-page read; pages cut early past it (unset = disabled)
// Credit-tier Stripe prices (monthly)
STRIPE_PRICE_TIER_25_MO: z.string().min(1).optional(), // Pro: $25/mo (6,000 credits)
STRIPE_PRICE_TIER_100_MO: z.string().min(1).optional(), // Max: $100/mo (25,000 credits)
// Credit-tier Stripe prices (annual, 15% discount)
STRIPE_PRICE_TIER_25_YR: z.string().min(1).optional(), // Pro: $255/yr (15% off $300)
STRIPE_PRICE_TIER_100_YR: z.string().min(1).optional(), // Max: $1,020/yr (15% off $1,200)
// Team-specific Stripe prices (separate products for Billing Portal compat)
STRIPE_PRICE_TEAM_25_MO: z.string().min(1).optional(), // Team Pro: $25/seat/mo
STRIPE_PRICE_TEAM_25_YR: z.string().min(1).optional(), // Team Pro: $255/seat/yr
STRIPE_PRICE_TEAM_100_MO: z.string().min(1).optional(), // Team Max: $100/seat/mo
STRIPE_PRICE_TEAM_100_YR: z.string().min(1).optional(), // Team Max: $1,020/seat/yr
OVERAGE_THRESHOLD_DOLLARS: z.number().optional().default(100), // Dollar threshold for incremental overage billing (default: $100)
// Email & Communication
EMAIL_VERIFICATION_ENABLED: z.boolean().optional(), // Enable email verification for user registration and login (defaults to false)
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim <noreply@domain.com>" or "noreply@domain.com")
PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
AWS_SES_REGION: z.string().min(1).optional(), // AWS region for SES (credentials resolved via default SDK provider chain)
SMTP_HOST: z.string().min(1).optional(), // SMTP server hostname
SMTP_PORT: z.coerce.number().int().min(1).max(65535).optional(),
SMTP_USER: z.string().min(1).optional(), // SMTP username
SMTP_PASS: z.string().min(1).optional(), // SMTP password
SMTP_SECURE: z.boolean().optional(), // Force TLS on connect (defaults to true on port 465); read via envBoolean to handle string values from process.env
// SMS & Messaging
TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending
TWILIO_AUTH_TOKEN: z.string().min(1).optional(), // Twilio Auth Token for API authentication
TWILIO_PHONE_NUMBER: z.string().min(1).optional(), // Twilio phone number for sending SMS
// AI/LLM Provider API Keys
OPENAI_API_KEY: z.string().min(1).optional(), // Primary OpenAI API key
OPENAI_API_KEY_1: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_2: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_3: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
MISTRAL_API_KEY: z.string().min(1).optional(), // Mistral AI API key
ANTHROPIC_API_KEY_1: z.string().min(1).optional(), // Primary Anthropic Claude API key
ANTHROPIC_API_KEY_2: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
ANTHROPIC_API_KEY_3: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
GEMINI_API_KEY: z.string().min(1).optional(), // Singular Gemini API key (used as fallback when rotation keys are unset)
GEMINI_API_KEY_1: z.string().min(1).optional(), // Primary Gemini API key
GEMINI_API_KEY_2: z.string().min(1).optional(), // Additional Gemini API key for load balancing
GEMINI_API_KEY_3: z.string().min(1).optional(), // Additional Gemini API key for load balancing
ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing
ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing
ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing
XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing
XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing
XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing
OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL
VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible)
VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM
LITELLM_BASE_URL: z.string().url().optional(), // LiteLLM proxy base URL (OpenAI-compatible)
LITELLM_API_KEY: z.string().optional(), // Optional bearer token for LiteLLM
FIREWORKS_API_KEY: z.string().optional(), // Optional Fireworks AI API key for model listing
TOGETHER_API_KEY: z.string().optional(), // Optional Together AI API key for model listing and inference
BASETEN_API_KEY: z.string().optional(), // Optional Baseten API key for model listing and inference
COHERE_API_KEY: z.string().min(1).optional(), // Cohere API key for reranker (rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5)
COHERE_API_KEY_1: z.string().min(1).optional(), // Primary Cohere API key for rotation
COHERE_API_KEY_2: z.string().min(1).optional(), // Additional Cohere API key for load balancing
COHERE_API_KEY_3: z.string().min(1).optional(), // Additional Cohere API key for load balancing
ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat
SERPER_API_KEY: z.string().min(1).optional(), // Serper API key for online search
EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.
PREVIEW_BLOCKS: z.string().optional(), // Comma-separated preview block types to reveal off-AppConfig (e.g., "gmail_v2,notion_v3"). Empty = all preview blocks hidden.
// Azure Configuration - Shared credentials with feature-specific models
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint
AZURE_OPENAI_API_VERSION: z.string().optional(), // Shared Azure OpenAI API version
AZURE_OPENAI_API_KEY: z.string().min(1).optional(), // Shared Azure OpenAI API key
AZURE_ANTHROPIC_ENDPOINT: z.string().url().optional(), // Azure Anthropic service endpoint
AZURE_ANTHROPIC_API_KEY: z.string().min(1).optional(), // Azure Anthropic API key
AZURE_ANTHROPIC_API_VERSION: z.string().min(1).optional(), // Azure Anthropic API version (e.g. 2023-06-01)
KB_OPENAI_MODEL_NAME: z.string().optional(), // Azure deployment name serving the configured KB embedding model (used only when AZURE_OPENAI_* credentials are set).
KB_EMBEDDING_MODEL: z.string().optional(), // Embedding model used for all new knowledge bases. Must be one of the supported model ids; defaults to text-embedding-3-small.
WAND_OPENAI_MODEL_NAME: z.string().optional(), // Wand generation OpenAI model name (works with both regular OpenAI and Azure OpenAI)
OCR_AZURE_ENDPOINT: z.string().url().optional(), // Azure Mistral OCR service endpoint
OCR_AZURE_MODEL_NAME: z.string().optional(), // Azure Mistral OCR model name for document processing
OCR_AZURE_API_KEY: z.string().min(1).optional(), // Azure Mistral OCR API key
// Vertex AI Configuration
VERTEX_PROJECT: z.string().optional(), // Google Cloud project ID for Vertex AI
VERTEX_LOCATION: z.string().optional(), // Google Cloud location/region for Vertex AI (defaults to us-central1)
// Monitoring & Analytics
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
PROFOUND_API_KEY: z.string().min(1).optional(), // Profound analytics API key
PROFOUND_ENDPOINT: z.string().url().optional(), // Profound analytics endpoint
GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics.
GRAFANA_OTLP_HEADERS: z.string().min(1).optional(), // Comma-separated key=value headers for OTLP requests (e.g., "Authorization=Basic <base64(instanceId:token)>"). Same format as the OTEL_EXPORTER_OTLP_HEADERS spec.
GRAFANA_DEPLOYMENT_ENVIRONMENT: z.string().min(1).optional(), // Deployment tier label (e.g., "production", "staging", "development"). Emitted as the stable `deployment.environment.name` resource attribute on Trigger.dev telemetry to match the rest of the Sim OTEL stack.
// External Services
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
BROWSERBASE_PROJECT_ID: z.string().min(1).optional(), // Browserbase project ID
GITHUB_TOKEN: z.string().optional(), // GitHub personal access token for API access
// Admin API
ADMIN_API_KEY: z.string().min(32).optional(), // Admin API key for self-hosted GitOps access (generate with: openssl rand -hex 32)
// Mothership Admin
MOTHERSHIP_API_ADMIN_KEY: z.string().min(1).optional(), // Admin API key for mothership/copilot admin endpoints
MOTHERSHIP_DEV_URL: z.string().url().optional(), // Mothership dev environment URL
MOTHERSHIP_STAGING_URL: z.string().url().optional(), // Mothership staging environment URL
MOTHERSHIP_PROD_URL: z.string().url().optional(), // Mothership production environment URL
// Infrastructure & Deployment
NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment
DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment
// Background Jobs & Scheduling
TRIGGER_PROJECT_ID: z.string().optional(), // Trigger.dev project ID
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
JOB_RETENTION_DAYS: z.string().optional().default('1'), // Days to retain job logs/data
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('30'),
WORKFLOW_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('75'),
WEBHOOK_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('75'),
RESUME_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('50'),
SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER: z.string().optional().default('2'),
SCHEDULE_JITTER_MAX_MS: z.string().optional().default('30000'),
SCHEDULE_INFRA_RETRY_BASE_MS: z.string().optional().default('60000'),
SCHEDULE_INFRA_RETRY_MAX_MS: z.string().optional().default('300000'),
SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS: z.string().optional().default('10'),
// Cloud Storage - AWS S3
AWS_REGION: z.string().optional(), // AWS region for S3 buckets
AWS_ACCESS_KEY_ID: z.string().optional(), // AWS access key ID
AWS_SECRET_ACCESS_KEY: z.string().optional(), // AWS secret access key
S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage
S3_LOGS_BUCKET_NAME: z.string().optional(), // S3 bucket for storing logs
S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files
S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
S3_PROFILE_PICTURES_BUCKET_NAME: z.string().optional(), // S3 bucket for profile pictures
S3_OG_IMAGES_BUCKET_NAME: z.string().optional(), // S3 bucket for OpenGraph images
S3_WORKSPACE_LOGOS_BUCKET_NAME: z.string().optional(), // S3 bucket for workspace logos
S3_ENDPOINT: z.string().optional(), // Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3
S3_FORCE_PATH_STYLE: z.string().optional(), // Force path-style addressing (MinIO/Ceph RGW). Defaults to false (AWS S3, R2). Coerced via envBoolean at the consumption site
// Dynamic config - AWS AppConfig (hosted source of truth for signup/login gating lists; unset => env-var fallback)
APPCONFIG_APPLICATION: z.string().optional(), // AppConfig application id/name. On hosted deployments, when set with APPCONFIG_ENVIRONMENT, gating lists come from AppConfig instead of env vars
APPCONFIG_ENVIRONMENT: z.string().optional(), // AppConfig environment id/name. Profile name is an app-side constant ('access-control'), not an env var
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
AZURE_ACCOUNT_KEY: z.string().optional(), // Azure storage account key
AZURE_CONNECTION_STRING: z.string().optional(), // Azure storage connection string
AZURE_STORAGE_CONTAINER_NAME: z.string().optional(), // Azure container for general files
AZURE_STORAGE_KB_CONTAINER_NAME: z.string().optional(), // Azure container for knowledge base files
AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME: z.string().optional(), // Azure container for workflow execution files
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME: z.string().optional(), // Azure container for profile pictures
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: z.string().optional(), // Azure container for OpenGraph images
AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME: z.string().optional(), // Azure container for workspace logos
// Admission & Burst Protection
ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod
API_MAX_JSON_BODY_BYTES: z.string().optional().default('52428800'),// Default max JSON request body size for contract routes (50 MB)
CHAT_MAX_REQUEST_BYTES: z.string().optional().default('230686720'),// Max request body size for the public deployed-chat endpoint (220 MB; covers 15 base64 file attachments)
WEBHOOK_MAX_REQUEST_BYTES: z.string().optional().default('10485760'),// Max request body size for public webhook receiver endpoints (10 MB; provider payloads rarely exceed a few MB)
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
RATE_LIMIT_FREE_SYNC: z.string().optional().default('50'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('200'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('150'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('1000'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('300'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('2500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
// Timeout Configuration
EXECUTION_TIMEOUT_FREE: z.string().optional().default('300'), // 5 minutes
EXECUTION_TIMEOUT_PRO: z.string().optional().default('3000'), // 50 minutes
EXECUTION_TIMEOUT_TEAM: z.string().optional().default('3000'), // 50 minutes
EXECUTION_TIMEOUT_ENTERPRISE: z.string().optional().default('3000'), // 50 minutes
EXECUTION_TIMEOUT_ASYNC_FREE: z.string().optional().default('5400'), // 90 minutes
EXECUTION_TIMEOUT_ASYNC_PRO: z.string().optional().default('5400'), // 90 minutes
EXECUTION_TIMEOUT_ASYNC_TEAM: z.string().optional().default('5400'), // 90 minutes
EXECUTION_TIMEOUT_ASYNC_ENTERPRISE: z.string().optional().default('5400'), // 90 minutes
// Isolated-VM Worker Pool Configuration
IVM_POOL_SIZE: z.string().optional().default('4'), // Max worker processes in pool
IVM_MAX_CONCURRENT: z.string().optional().default('10000'), // Max concurrent executions globally
IVM_MAX_PER_WORKER: z.string().optional().default('2500'), // Max concurrent executions per worker
IVM_WORKER_IDLE_TIMEOUT_MS: z.string().optional().default('60000'), // Worker idle cleanup timeout (ms)
IVM_MAX_QUEUE_SIZE: z.string().optional().default('10000'), // Max pending queued executions in memory
IVM_MAX_FETCH_RESPONSE_BYTES: z.string().optional().default('8388608'),// Max bytes read from sandbox fetch responses
IVM_MAX_FETCH_RESPONSE_CHARS: z.string().optional().default('4000000'),// Max chars returned to sandbox from fetch body
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox fetch options
IVM_MAX_FETCH_URL_LENGTH: z.string().optional().default('8192'), // Max URL length accepted by sandbox fetch
IVM_MAX_STDOUT_CHARS: z.string().optional().default('200000'), // Max captured stdout characters per execution
IVM_MAX_ACTIVE_PER_OWNER: z.string().optional().default('200'), // Max active executions per owner (per process)
IVM_MAX_QUEUED_PER_OWNER: z.string().optional().default('2000'), // Max queued executions per owner (per process)
IVM_MAX_OWNER_WEIGHT: z.string().optional().default('5'), // Max accepted weight for weighted owner scheduling
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER:z.string().optional().default('2200'), // Max owner in-flight leases across replicas
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: z.string().optional().default('120000'), // Min TTL for distributed in-flight leases (ms)
IVM_QUEUE_TIMEOUT_MS: z.string().optional().default('300000'), // Max queue wait before rejection (ms)
IVM_MAX_EXECUTIONS_PER_WORKER: z.string().optional().default('200'), // Max lifetime executions before worker is recycled
IVM_MAX_BROKER_ARGS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox task broker args (isolate→host)
IVM_MAX_BROKER_RESULT_JSON_CHARS: z.string().optional().default('16777216'),// Max JSON payload size for sandbox task broker results (host→isolate)
IVM_MAX_BROKERS_PER_EXECUTION: z.string().optional().default('1000'), // Max broker calls per sandbox task execution
// Knowledge Base Processing Configuration - Shared across all processing methods
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)
KB_CONFIG_MAX_ATTEMPTS: z.number().optional().default(3), // Max retry attempts
KB_CONFIG_RETRY_FACTOR: z.number().optional().default(2), // Retry backoff factor
KB_CONFIG_MIN_TIMEOUT: z.number().optional().default(1000), // Min timeout in ms
KB_CONFIG_MAX_TIMEOUT: z.number().optional().default(10000), // Max timeout in ms
KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(50), // Concurrent embedding API calls
KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch
KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(0), // Delay between batches in ms (0 for max speed)
KB_CONFIG_DELAY_BETWEEN_DOCUMENTS: z.number().optional().default(50), // Delay between documents in ms
KB_CONFIG_CHUNK_CONCURRENCY: z.number().optional().default(10), // Concurrent PDF chunk OCR processing
// Real-time Communication
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
PORT: z.number().optional(), // Main application port
INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000)
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
PII_URL: z.string().optional(), // Presidio PII service base URL serving /analyze + /anonymize (standalone ECS service; default http://localhost:5001 for local dev)
PII_MASK_CHUNK_CONCURRENCY: z.coerce.number().int().positive().optional(), // Max in-flight mask-batch requests per redaction (default 64); tune to the Presidio fleet size behind the internal ALB, lower to 1 for a single instance
PII_REF_CONCURRENCY: z.coerce.number().int().positive().optional(), // Max large-value refs hydrated+masked+re-stored in parallel per payload (default 4); multiplies with PII_MASK_CHUNK_CONCURRENCY for total in-flight Presidio load
PII_SERVICE_CHUNK_CONCURRENCY: z.coerce.number().int().positive().optional(), // Max Presidio requests in flight from a single mask-batch call (route -> Presidio fan-out, default 4); inner to PII_MASK_CHUNK_CONCURRENCY
// OAuth Integration Credentials - All optional, enables third-party integrations
GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for Google services
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
DISABLE_GOOGLE_AUTH: z.boolean().optional(), // Disable Google OAuth login even when credentials are configured
DISABLE_GITHUB_AUTH: z.boolean().optional(), // Disable GitHub OAuth login even when credentials are configured
DISABLE_MICROSOFT_AUTH: z.boolean().optional(), // Disable Microsoft OAuth login even when credentials are configured
DISABLE_EMAIL_SIGNUP: z.boolean().optional(), // Block new email/password registrations while keeping email login working
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
TIKTOK_CLIENT_ID: z.string().optional(), // TikTok OAuth client key (TikTok calls this "client_key")
TIKTOK_CLIENT_SECRET: z.string().optional(), // TikTok OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID
CONFLUENCE_CLIENT_SECRET: z.string().optional(), // Atlassian Confluence OAuth client secret
JIRA_CLIENT_ID: z.string().optional(), // Atlassian Jira OAuth client ID
JIRA_CLIENT_SECRET: z.string().optional(), // Atlassian Jira OAuth client secret
ASANA_CLIENT_ID: z.string().optional(), // Asana OAuth client ID
ASANA_CLIENT_SECRET: z.string().optional(), // Asana OAuth client secret
AIRTABLE_CLIENT_ID: z.string().optional(), // Airtable OAuth client ID
AIRTABLE_CLIENT_SECRET: z.string().optional(), // Airtable OAuth client secret
APOLLO_API_KEY: z.string().optional(), // Apollo API key (optional system-wide config)
SUPABASE_CLIENT_ID: z.string().optional(), // Supabase OAuth client ID
SUPABASE_CLIENT_SECRET: z.string().optional(), // Supabase OAuth client secret
NOTION_CLIENT_ID: z.string().optional(), // Notion OAuth client ID
NOTION_CLIENT_SECRET: z.string().optional(), // Notion OAuth client secret
MONDAY_CLIENT_ID: z.string().optional(), // Monday.com OAuth client ID
MONDAY_CLIENT_SECRET: z.string().optional(), // Monday.com OAuth client secret
DISCORD_CLIENT_ID: z.string().optional(), // Discord OAuth client ID
DISCORD_CLIENT_SECRET: z.string().optional(), // Discord OAuth client secret
DOCUSIGN_CLIENT_ID: z.string().optional(), // DocuSign OAuth client ID
DOCUSIGN_CLIENT_SECRET: z.string().optional(), // DocuSign OAuth client secret
MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams
MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret
HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID
HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret
SALESFORCE_CLIENT_ID: z.string().optional(), // Salesforce OAuth client ID
SALESFORCE_CLIENT_SECRET: z.string().optional(), // Salesforce OAuth client secret
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID
PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID
LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret
BOX_CLIENT_ID: z.string().optional(), // Box OAuth client ID
BOX_CLIENT_SECRET: z.string().optional(), // Box OAuth client secret
DROPBOX_CLIENT_ID: z.string().optional(), // Dropbox OAuth client ID
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger)
REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID
WEBFLOW_CLIENT_SECRET: z.string().optional(), // Webflow OAuth client secret
TRELLO_API_KEY: z.string().optional(), // Trello API Key
LINKEDIN_CLIENT_ID: z.string().optional(), // LinkedIn OAuth client ID
LINKEDIN_CLIENT_SECRET: z.string().optional(), // LinkedIn OAuth client secret
SHOPIFY_CLIENT_ID: z.string().optional(), // Shopify OAuth client ID
SHOPIFY_CLIENT_SECRET: z.string().optional(), // Shopify OAuth client secret
ZOOM_CLIENT_ID: z.string().optional(), // Zoom OAuth client ID
ZOOM_CLIENT_SECRET: z.string().optional(), // Zoom OAuth client secret
WORDPRESS_CLIENT_ID: z.string().optional(), // WordPress.com OAuth client ID
WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret
SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID
SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret
CALCOM_CLIENT_ID: z.string().optional(), // Cal.com OAuth client ID
ATTIO_CLIENT_ID: z.string().optional(), // Attio OAuth client ID
ATTIO_CLIENT_SECRET: z.string().optional(), // Attio OAuth client secret
// AgentMail - Mothership Email Inbox
AGENTMAIL_API_KEY: z.string().min(1).optional(), // AgentMail API key for mothership email inbox
AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to)
INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements)
// E2B Remote Code Execution
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path
E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Pi Coding Agent cloud mode)
// Access Control (Permission Groups) - for self-hosted deployments
ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements)
// Enterprise Feature Overrides - for self-hosted deployments
WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block)
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
// Invitations - for self-hosted deployments
DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments)
MOTHERSHIP_BETA_FEATURES: z.boolean().optional(), // Enable beta Mothership planning/changelog artifact surfaces
// Development Tools
REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only)
REACT_SCAN_ENABLED: z.boolean().optional(), // Enable React Scan for performance debugging (dev only)
// SSO Configuration (for script-based registration)
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type
SSO_PROVIDER_ID: z.string().optional(), // [REQUIRED] SSO provider ID
SSO_ISSUER: z.string().optional(), // [REQUIRED] SSO issuer URL
SSO_DOMAIN: z.string().optional(), // [REQUIRED] SSO email domain
SSO_USER_EMAIL: z.string().optional(), // [REQUIRED] User email for SSO registration
SSO_ORGANIZATION_ID: z.string().optional(), // Organization ID for SSO registration (optional)
SSO_TRUSTED_PROVIDER_IDS: z.string().optional(), // Comma-separated SSO provider IDs to trust for automatic account linking when an existing account shares the same email. Use for IdPs that do not assert email_verified. Merged into Better Auth accountLinking.trustedProviders.
// SSO Mapping Configuration (optional - sensible defaults provided)
SSO_MAPPING_ID: z.string().optional(), // Custom ID claim mapping (default: sub for OIDC, nameidentifier for SAML)
SSO_MAPPING_EMAIL: z.string().optional(), // Custom email claim mapping (default: email for OIDC, emailaddress for SAML)
SSO_MAPPING_NAME: z.string().optional(), // Custom name claim mapping (default: name for both)
SSO_MAPPING_IMAGE: z.string().optional(), // Custom image claim mapping (default: picture for OIDC)
// SSO OIDC Configuration
SSO_OIDC_CLIENT_ID: z.string().optional(), // [REQUIRED for OIDC] OIDC client ID
SSO_OIDC_CLIENT_SECRET: z.string().optional(), // [REQUIRED for OIDC] OIDC client secret
SSO_OIDC_SCOPES: z.string().optional(), // OIDC scopes (default: openid,profile,email)
SSO_OIDC_PKCE: z.string().optional(), // Enable PKCE (default: true)
SSO_OIDC_AUTHORIZATION_ENDPOINT: z.string().optional(), // OIDC authorization endpoint (optional, uses discovery)
SSO_OIDC_TOKEN_ENDPOINT: z.string().optional(), // OIDC token endpoint (optional, uses discovery)
SSO_OIDC_USERINFO_ENDPOINT: z.string().optional(), // OIDC userinfo endpoint (optional, uses discovery)
SSO_OIDC_JWKS_ENDPOINT: z.string().optional(), // OIDC JWKS endpoint (optional, uses discovery)
SSO_OIDC_DISCOVERY_ENDPOINT: z.string().optional(), // OIDC discovery endpoint (default: {issuer}/.well-known/openid-configuration)
// SSO SAML Configuration
SSO_SAML_ENTRY_POINT: z.string().optional(), // [REQUIRED for SAML] SAML IdP SSO URL
SSO_SAML_CERT: z.string().optional(), // [REQUIRED for SAML] SAML IdP certificate
SSO_SAML_CALLBACK_URL: z.string().optional(), // SAML callback URL (default: {issuer}/callback)
SSO_SAML_SP_METADATA: z.string().optional(), // SAML SP metadata XML (auto-generated if not provided)
SSO_SAML_IDP_METADATA: z.string().optional(), // SAML IdP metadata XML (optional)
SSO_SAML_AUDIENCE: z.string().optional(), // SAML audience restriction (default: issuer URL)
SSO_SAML_WANT_ASSERTIONS_SIGNED: z.string().optional(), // Require signed SAML assertions (default: false)
SSO_SAML_SIGNATURE_ALGORITHM: z.string().optional(), // SAML signature algorithm (optional)
SSO_SAML_DIGEST_ALGORITHM: z.string().optional(), // SAML digest algorithm (optional)
SSO_SAML_IDENTIFIER_FORMAT: z.string().optional(), // SAML identifier format (optional)
},
client: {
// Core Application URLs - Required for frontend functionality
NEXT_PUBLIC_APP_URL: z.string().url(), // Base URL of the application (e.g., https://www.sim.ai)
// Client-side Services
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features
// Billing
NEXT_PUBLIC_BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking (client-side)
// Analytics & Tracking
NEXT_PUBLIC_POSTHOG_ENABLED: z.boolean().optional(), // Enable PostHog analytics (client-side)
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), // PostHog project API key
// UI Branding & Whitelabeling
NEXT_PUBLIC_BRAND_NAME: z.string().optional(), // Custom brand name (defaults to "Sim")
NEXT_PUBLIC_BRAND_LOGO_URL: z.string().url().optional(), // Custom logo URL
NEXT_PUBLIC_BRAND_FAVICON_URL: z.string().url().optional(), // Custom favicon URL
NEXT_PUBLIC_CUSTOM_CSS_URL: z.string().url().optional(), // Custom CSS stylesheet URL
NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS: z.string().optional(), // Hide Bedrock credential fields when deployment uses AWS default credential chain (IAM roles, instance profiles, ECS task roles, IRSA)
NEXT_PUBLIC_AZURE_CONFIGURED: z.string().optional(), // Hide Azure credential fields when endpoint/key/version are pre-configured server-side
NEXT_PUBLIC_COHERE_CONFIGURED: z.string().optional(), // Hide Cohere API key field on Knowledge block when COHERE_API_KEY is pre-configured server-side
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
// Theme Customization
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#33c482")
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand color (hex format)
NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand hover state (hex format)
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format)
// Feature Flags
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: z.boolean().optional(), // Show the "Workflow" column type in user tables (defaults to false)
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget
},
// Variables available on both server and client
shared: {
NODE_ENV: z.enum(['development', 'test', 'production']).optional(), // Runtime environment
NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection
},
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL,
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME,
NEXT_PUBLIC_BRAND_LOGO_URL: process.env.NEXT_PUBLIC_BRAND_LOGO_URL,
NEXT_PUBLIC_BRAND_FAVICON_URL: process.env.NEXT_PUBLIC_BRAND_FAVICON_URL,
NEXT_PUBLIC_CUSTOM_CSS_URL: process.env.NEXT_PUBLIC_CUSTOM_CSS_URL,
NEXT_PUBLIC_SUPPORT_EMAIL: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
NEXT_PUBLIC_DOCUMENTATION_URL: process.env.NEXT_PUBLIC_DOCUMENTATION_URL,
NEXT_PUBLIC_TERMS_URL: process.env.NEXT_PUBLIC_TERMS_URL,
NEXT_PUBLIC_PRIVACY_URL: process.env.NEXT_PUBLIC_PRIVACY_URL,
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR,
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR,
NEXT_PUBLIC_BRAND_ACCENT_COLOR: process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR,
NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR,
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR,
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED,
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
NEXT_PUBLIC_DATA_DRAINS_ENABLED: process.env.NEXT_PUBLIC_DATA_DRAINS_ENABLED,
NEXT_PUBLIC_FORKING_ENABLED: process.env.NEXT_PUBLIC_FORKING_ENABLED,
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: process.env.NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED,
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS: process.env.NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS,
NEXT_PUBLIC_AZURE_CONFIGURED: process.env.NEXT_PUBLIC_AZURE_CONFIGURED,
NEXT_PUBLIC_COHERE_CONFIGURED: process.env.NEXT_PUBLIC_COHERE_CONFIGURED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,
NEXT_PUBLIC_POSTHOG_ENABLED: process.env.NEXT_PUBLIC_POSTHOG_ENABLED,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NODE_ENV: process.env.NODE_ENV,
NEXT_TELEMETRY_DISABLED: process.env.NEXT_TELEMETRY_DISABLED,
},
})
// Need this utility because t3-env is returning string for boolean values.
export const isTruthy = (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value)
// Utility to check if a value is explicitly false (defaults to false only if explicitly set)
export const isFalsy = (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false
export { getEnv }
/**
* Coerce an env-derived value to a finite number ≥ `min`, falling back to the
* provided default when the value is unset, empty, non-finite, or below `min`.
* `min` defaults to `0` so configs like `KB_CONFIG_DELAY_BETWEEN_BATCHES=0`
* (meaning "no delay / max throughput") are honored. Pass `min: 1` for configs
* where zero is invalid (e.g. Redis TTLs, capacity limits).
*
* `createEnv` is configured with `skipValidation: true`, so values declared as
* `z.number()` arrive as raw strings when sourced from `process.env` or Helm.
* Use this helper anywhere a numeric env override is consumed to normalize the
* type at the boundary instead of relying on JS implicit coercion.
*/
export function envNumber(
value: number | string | undefined | null,
fallback: number,
options: { min?: number; integer?: boolean } = {}
): number {
const min = options.min ?? 0
if (
typeof value === 'number' &&
Number.isFinite(value) &&
value >= min &&
(!options.integer || Number.isInteger(value))
) {
return value
}
if (value === undefined || value === null || value === '') return fallback
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= min && (!options.integer || Number.isInteger(parsed))
? parsed
: fallback
}
/**
* Coerce an env-derived value to a boolean. Returns `undefined` when unset
* so callers can apply context-aware defaults. Required because
* `Boolean("false") === true`, so `z.coerce.boolean()` would silently flip
* the meaning of `MY_FLAG=false`.
*/
export function envBoolean(value: boolean | string | undefined | null): boolean | undefined {
if (typeof value === 'boolean') return value
if (value === undefined || value === null || value === '') return undefined
const normalized = String(value).trim().toLowerCase()
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
}
@@ -0,0 +1,212 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FeatureFlagContext, FeatureFlagName } from '@/lib/core/config/feature-flags'
const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockIsPlatformAdmin: vi.fn(),
envRef: {
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
FORKING_ENABLED: undefined as boolean | undefined,
DEPLOY_AS_BLOCK: undefined as boolean | undefined,
},
flagRef: { isAppConfigEnabled: false },
}))
vi.mock('@/lib/core/config/appconfig', () => ({
fetchAppConfigProfile: mockFetch,
}))
vi.mock('@/lib/core/config/env', () => ({
isTruthy: (v: unknown) => Boolean(v),
get env() {
return envRef
},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isAppConfigEnabled() {
return flagRef.isAppConfigEnabled
},
}))
vi.mock('@/lib/permissions/super-user', () => ({
isPlatformAdmin: mockIsPlatformAdmin,
}))
import { getFeatureFlags, isFeatureEnabled } from '@/lib/core/config/feature-flags'
/** Make `getFeatureFlags` resolve to `doc` via the AppConfig path (also exercises parseConfig). */
function withAppConfig(doc: unknown) {
flagRef.isAppConfigEnabled = true
mockFetch.mockImplementation((_ids, parse) => Promise.resolve(parse(doc)))
}
/**
* `isFeatureEnabled` only accepts registered `FeatureFlagName`s. These tests
* exercise the evaluation logic with throwaway flag names supplied through the
* AppConfig document, cast to `FeatureFlagName` through this helper.
*/
const enabled = (flag: string, ctx?: FeatureFlagContext) =>
isFeatureEnabled(flag as FeatureFlagName, ctx)
describe('getFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
})
it('derives flags from fallback secrets when AppConfig is disabled, without fetching', async () => {
const flags = await getFeatureFlags()
// All registered flags should be present, disabled (env vars unset in test env)
expect(flags['mothership-beta']).toEqual({ enabled: false })
expect(flags['pii-redaction']).toEqual({ enabled: false })
expect(flags['pii-granular-redaction']).toEqual({ enabled: false })
expect(flags['trigger-eu-region']).toEqual({ enabled: false })
expect(mockFetch).not.toHaveBeenCalled()
})
it('reads the feature-flags profile and normalizes the payload when enabled', async () => {
withAppConfig({
a: { enabled: true },
b: { orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' },
c: 'not-an-object',
})
const flags = await getFeatureFlags()
expect(flags.a).toEqual({ enabled: true })
expect(flags.b).toEqual({ orgIds: ['Org_1', 'org_1', 'org_2'] })
expect(flags.c).toBeUndefined()
expect(mockFetch).toHaveBeenCalledWith(
{ application: 'sim-staging', environment: 'staging', profile: 'feature-flags' },
expect.any(Function)
)
})
it('falls back to the secret-derived document when the fetch yields null', async () => {
flagRef.isAppConfigEnabled = true
mockFetch.mockResolvedValue(null)
const flags = await getFeatureFlags()
expect(flags['mothership-beta']).toEqual({ enabled: false })
expect(flags['pii-redaction']).toEqual({ enabled: false })
expect(flags['pii-granular-redaction']).toEqual({ enabled: false })
expect(flags['trigger-eu-region']).toEqual({ enabled: false })
})
it('degrades gracefully on a malformed document', async () => {
withAppConfig('not-an-object')
expect(await getFeatureFlags()).toMatchObject({})
withAppConfig(null)
expect(await getFeatureFlags()).toMatchObject({})
})
})
describe('isFeatureEnabled', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
envRef.FORKING_ENABLED = undefined
envRef.DEPLOY_AS_BLOCK = undefined
})
describe('workspace-forking flag', () => {
it('falls back to FORKING_ENABLED when AppConfig is disabled', async () => {
envRef.FORKING_ENABLED = undefined
expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(false)
envRef.FORKING_ENABLED = true
expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(true)
})
it('targets specific orgs/users via AppConfig, ignoring the fallback secret', async () => {
envRef.FORKING_ENABLED = undefined
withAppConfig({ 'workspace-forking': { orgIds: ['o1'], userIds: ['u9'] } })
expect(await isFeatureEnabled('workspace-forking', { orgId: 'o1' })).toBe(true)
expect(await isFeatureEnabled('workspace-forking', { userId: 'u9' })).toBe(true)
expect(await isFeatureEnabled('workspace-forking', { orgId: 'o2', userId: 'u1' })).toBe(false)
})
})
describe('deploy-as-block flag', () => {
it('falls back to DEPLOY_AS_BLOCK when AppConfig is disabled', async () => {
envRef.DEPLOY_AS_BLOCK = undefined
expect(await isFeatureEnabled('deploy-as-block', { userId: 'u1', orgId: 'o1' })).toBe(false)
envRef.DEPLOY_AS_BLOCK = true
expect(await isFeatureEnabled('deploy-as-block', { userId: 'u1', orgId: 'o1' })).toBe(true)
})
it('targets specific orgs via AppConfig, ignoring the fallback secret', async () => {
envRef.DEPLOY_AS_BLOCK = undefined
withAppConfig({ 'deploy-as-block': { orgIds: ['o1'] } })
expect(await isFeatureEnabled('deploy-as-block', { orgId: 'o1' })).toBe(true)
expect(await isFeatureEnabled('deploy-as-block', { orgId: 'o2' })).toBe(false)
})
})
it('returns false for an unknown flag', async () => {
withAppConfig({})
expect(await enabled('missing', { userId: 'u1' })).toBe(false)
})
it('matches the global enabled clause', async () => {
withAppConfig({ f: { enabled: true } })
expect(await enabled('f')).toBe(true)
})
it('matches the userId allowlist', async () => {
withAppConfig({ f: { userIds: ['u1'] } })
expect(await enabled('f', { userId: 'u1' })).toBe(true)
expect(await enabled('f', { userId: 'u2' })).toBe(false)
expect(await enabled('f', {})).toBe(false)
})
it('matches the orgId allowlist', async () => {
withAppConfig({ f: { orgIds: ['o1'] } })
expect(await enabled('f', { orgId: 'o1' })).toBe(true)
expect(await enabled('f', { orgId: 'o2' })).toBe(false)
})
describe('admin clause (lazy resolution)', () => {
it('resolves admin from userId when adminEnabled is the deciding clause', async () => {
withAppConfig({ f: { adminEnabled: true } })
mockIsPlatformAdmin.mockResolvedValue(true)
expect(await enabled('f', { userId: 'u1' })).toBe(true)
expect(mockIsPlatformAdmin).toHaveBeenCalledWith('u1')
mockIsPlatformAdmin.mockResolvedValue(false)
expect(await enabled('f', { userId: 'u2' })).toBe(false)
})
it('uses the isAdmin override without querying', async () => {
withAppConfig({ f: { adminEnabled: true } })
expect(await enabled('f', { userId: 'u1', isAdmin: true })).toBe(true)
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
})
it('resolves to false without querying when userId is absent', async () => {
withAppConfig({ f: { adminEnabled: true } })
expect(await enabled('f', { orgId: 'o1' })).toBe(false)
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
})
it('does not query when an earlier clause already matched', async () => {
withAppConfig({ f: { enabled: true, adminEnabled: true } })
expect(await enabled('f', { userId: 'u1' })).toBe(true)
withAppConfig({ g: { userIds: ['u1'], adminEnabled: true } })
expect(await enabled('g', { userId: 'u1' })).toBe(true)
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
})
it('does not query when the rule has no adminEnabled clause', async () => {
withAppConfig({ f: { userIds: ['u2'] } })
expect(await enabled('f', { userId: 'u1' })).toBe(false)
expect(mockIsPlatformAdmin).not.toHaveBeenCalled()
})
})
})
+189
View File
@@ -0,0 +1,189 @@
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
import type { AppConfigGateContext, AppConfigGateRule } from '@/lib/core/config/appconfig-rules'
import { matchesRule, parseGateConfig } from '@/lib/core/config/appconfig-rules'
import { env, isTruthy } from '@/lib/core/config/env'
import { isAppConfigEnabled } from '@/lib/core/config/env-flags'
/**
* Name of the AppConfig configuration profile holding the gated feature flags.
* Cross-repo contract: must match the `CfnConfigurationProfile` name created by
* the infra stack.
*/
const FEATURE_FLAGS_PROFILE = 'feature-flags'
/**
* A single flag's gating rule. A flag is ON for a context when ANY clause matches:
* the global `enabled` default, the org/user allowlists, or `adminEnabled` for
* platform admins. An absent clause never matches. Shape shared with the other
* AppConfig gating documents via {@link AppConfigGateRule}.
*/
export type FeatureFlagRule = AppConfigGateRule
export type FeatureFlagsConfig = Record<string, FeatureFlagRule>
/**
* Per-request evaluation context. Pass only the ids you have — a missing id skips
* its clause. Admin status is resolved internally from `userId`; `isAdmin` is an
* optional fast-path override for callers that already know it (e.g. admin routes).
*/
export type FeatureFlagContext = AppConfigGateContext
/**
* Registry of known feature flags. Each maps to the secret consulted ONLY when
* AppConfig is not the source of truth (self-hosted/OSS, local dev, or hosted
* without APPCONFIG_*). A truthy secret turns the flag on globally.
*
* Gating by org/user/admin is available ONLY through the hosted AppConfig document
* — it deliberately cannot be expressed here, so no environment can grant (e.g.)
* admin access from a code literal. To add a flag, register its name and the secret
* to fall back on.
*/
/**
* The single definition of a feature flag. Everything about a flag lives in one
* place: its name (the registry key), a human-readable `description`, and the
* `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on
* globally).
*
* Gating by org/user/admin is deliberately NOT part of a definition — it lives only
* in the hosted AppConfig document, so no environment can grant access from a code
* literal.
*/
interface FeatureFlagDefinition {
description: string
/** Env/secret key consulted when AppConfig isn't the source of truth. Truthy ⇒ on. */
fallback: keyof typeof env
}
/** The single registry of known flags. To add a flag, add one entry here. */
const FEATURE_FLAGS = {
'mothership-beta': {
description:
'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler. ' +
'Note: userId/orgId targeting only works for WorkspaceVfs (resolved in materialize). ' +
'getE2BDocFormat, resolveInputFiles, and resolveWorkflowAliasForWorkspace evaluate without ' +
'user context — use enabled:true for global rollout rather than per-user targeting.',
fallback: 'MOTHERSHIP_BETA_FEATURES',
},
'table-snapshot-cache': {
description:
'Mount Sim tables into code sandboxes by reference via a version-keyed CSV snapshot in ' +
'object storage (reused across runs until the table mutates) instead of draining the whole ' +
'table into web-process heap. resolveInputFiles evaluates without user context — use ' +
'enabled:true for global rollout rather than per-user targeting.',
fallback: 'TABLE_SNAPSHOT_CACHE',
},
'pii-redaction': {
description:
'Redact PII from workflow logs via configurable Data Retention rules (Presidio at the ' +
'logger persist choke point) and expose the Data Retention config surfaces. Global on/off ' +
'only — evaluated without user/org context so the persist path and config routes always ' +
'agree.',
fallback: 'PII_REDACTION',
},
'pii-granular-redaction': {
description:
'Expose the execution-altering PII redaction stages (redact the workflow input and every ' +
'block output in-flight) in the Data Retention config, layered on top of pii-redaction. ' +
'Global on/off only — gates the config surfaces (route write + UI). Because stored rules ' +
'are the source of truth for the executor, a granular stage can only run once it was ' +
'writable, so the executor is never flag-gated at runtime (avoiding a fail-open leak).',
fallback: 'PII_GRANULAR_REDACTION',
},
'trigger-eu-region': {
description:
'Route Trigger.dev runs to eu-central-1 instead of the default us-east-1. Global on/off ' +
'only — resolved without user/org context at every task-trigger call site via ' +
'resolveTriggerRegion, so the whole deployment switches regions together.',
fallback: 'TRIGGER_EU_REGION',
},
'workspace-forking': {
description:
'Runtime rollout gate for workspace forking (fork/promote/rollback), layered on top of ' +
'the existing FORKING_ENABLED / Enterprise-plan gate at the shared assertForkingEnabled ' +
'choke point. Enforced ONLY where AppConfig is the source of truth (Sim Cloud), so ' +
'operators can dark-launch forking to specific orgs/users/admins without touching ' +
'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.',
fallback: 'FORKING_ENABLED',
},
'deploy-as-block': {
description:
'Publish a deployed workflow as a reusable, org-wide custom block (custom name/SVG icon/' +
'description; Start inputs become block inputs). Gates the Deploy-modal "Block" tab and the ' +
'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.',
fallback: 'DEPLOY_AS_BLOCK',
},
} satisfies Record<string, FeatureFlagDefinition>
/**
* The closed set of known feature flags. Derived from the registry, so a flag
* cannot exist — or be checked — without a definition (and its mandatory fallback).
*/
export type FeatureFlagName = keyof typeof FEATURE_FLAGS
/** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */
function fallbackFlags(): FeatureFlagsConfig {
const flags: FeatureFlagsConfig = {}
for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array<
[string, FeatureFlagDefinition]
>) {
flags[name] = { enabled: isTruthy(env[def.fallback]) }
}
return flags
}
/**
* Resolve platform-admin status lazily. Dynamically imported so the DB-backed
* helper (and `@sim/db`) stay out of this config module's load graph for callers
* that never reach an admin-gated flag.
*/
async function resolveAdmin(userId: string): Promise<boolean> {
const { isPlatformAdmin } = await import('@/lib/permissions/super-user')
return isPlatformAdmin(userId)
}
/**
* The admin clause is resolved last and lazily: a global/userId/orgId match
* short-circuits before any DB read, a rule without `adminEnabled` never queries,
* and a missing `userId` resolves to `false` without a query.
*/
async function evaluate(
rule: FeatureFlagRule | undefined,
ctx: FeatureFlagContext
): Promise<boolean> {
if (!rule) return false
if (matchesRule(rule, ctx, false)) return true
if (rule.adminEnabled) {
const admin = ctx.isAdmin ?? (ctx.userId ? await resolveAdmin(ctx.userId) : false)
if (admin) return true
}
return false
}
/**
* Resolve the full flag document. Reads from AWS AppConfig on hosted deployments
* (cached, ~30s TTL, never blocks after the first fetch), otherwise derives each
* flag's on/off state from its registered fallback secret ({@link fallbackFlags}).
*/
export async function getFeatureFlags(): Promise<FeatureFlagsConfig> {
if (!isAppConfigEnabled) return fallbackFlags()
const value = await fetchAppConfigProfile(
{
application: env.APPCONFIG_APPLICATION as string,
environment: env.APPCONFIG_ENVIRONMENT as string,
profile: FEATURE_FLAGS_PROFILE,
},
parseGateConfig
)
return value ?? fallbackFlags()
}
/** Resolve a single flag for a context. Admin status is resolved internally from `userId`. */
export async function isFeatureEnabled(
flag: FeatureFlagName,
ctx: FeatureFlagContext = {}
): Promise<boolean> {
const flags = await getFeatureFlags()
return evaluate(flags[flag], ctx)
}
+249
View File
@@ -0,0 +1,249 @@
import { createEnvMock, createMockRedis } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { MockRedisConstructor } = vi.hoisted(() => ({
MockRedisConstructor: vi.fn(),
}))
const mockRedisInstance = createMockRedis()
MockRedisConstructor.mockImplementation(
class {
constructor() {
Object.assign(this, mockRedisInstance)
}
}
)
vi.mock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: 'redis://localhost:6379' }))
vi.mock('ioredis', () => ({
default: MockRedisConstructor,
}))
import {
closeRedisConnection,
extendLock,
getRedisClient,
onRedisReconnect,
resetForTesting,
} from '@/lib/core/config/redis'
describe('redis config', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
resetForTesting()
MockRedisConstructor.mockImplementation(
class {
constructor() {
Object.assign(this, mockRedisInstance)
}
}
)
})
afterEach(() => {
vi.useRealTimers()
})
describe('onRedisReconnect', () => {
it('should register and invoke reconnect listeners', async () => {
const listener = vi.fn()
onRedisReconnect(listener)
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(listener).toHaveBeenCalledTimes(1)
})
it('should not invoke listeners when PINGs succeed', async () => {
const listener = vi.fn()
onRedisReconnect(listener)
getRedisClient()
mockRedisInstance.ping.mockResolvedValue('PONG')
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(listener).not.toHaveBeenCalled()
})
it('should reset failure count on successful PING', async () => {
const listener = vi.fn()
onRedisReconnect(listener)
getRedisClient()
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000)
mockRedisInstance.ping.mockResolvedValueOnce('PONG')
await vi.advanceTimersByTimeAsync(15_000)
mockRedisInstance.ping.mockRejectedValueOnce(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000)
expect(listener).not.toHaveBeenCalled()
})
it('should call disconnect(true) after 2 consecutive PING failures', async () => {
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledWith(true)
})
it('should drop the cached client so the next getRedisClient() builds a fresh one', async () => {
getRedisClient()
const callsBefore = MockRedisConstructor.mock.calls.length
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledWith(true)
getRedisClient()
expect(MockRedisConstructor.mock.calls.length).toBe(callsBefore + 1)
})
it('should restart the PING health check against the new client', async () => {
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledTimes(1)
getRedisClient()
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(mockRedisInstance.disconnect).toHaveBeenCalledTimes(2)
})
it('should handle listener errors gracefully without breaking health check', async () => {
const badListener = vi.fn(() => {
throw new Error('listener crashed')
})
const goodListener = vi.fn()
onRedisReconnect(badListener)
onRedisReconnect(goodListener)
getRedisClient()
mockRedisInstance.ping.mockRejectedValue(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000)
await vi.advanceTimersByTimeAsync(15_000)
expect(badListener).toHaveBeenCalledTimes(1)
expect(goodListener).toHaveBeenCalledTimes(1)
})
})
describe('closeRedisConnection', () => {
it('should clear the PING interval', async () => {
getRedisClient()
mockRedisInstance.quit.mockResolvedValue('OK')
await closeRedisConnection()
mockRedisInstance.ping.mockRejectedValue(new Error('timeout'))
await vi.advanceTimersByTimeAsync(15_000 * 5)
expect(mockRedisInstance.disconnect).not.toHaveBeenCalled()
})
})
describe('extendLock', () => {
const lockKey = 'copilot:chat-stream-lock:chat-1'
const value = 'stream-abc'
const ttlSeconds = 60
it('returns true when the caller still owns the lock and EXPIRE succeeds', async () => {
mockRedisInstance.eval.mockResolvedValueOnce(1)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
expect(mockRedisInstance.eval).toHaveBeenCalledWith(
expect.stringContaining('expire'),
1,
lockKey,
value,
ttlSeconds
)
})
it('returns false when the value does not match (lock owned by another)', async () => {
mockRedisInstance.eval.mockResolvedValueOnce(0)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(false)
})
it('returns true as a no-op when Redis is unavailable', async () => {
vi.resetModules()
vi.doMock('@/lib/core/config/env', () =>
createEnvMock({ REDIS_URL: undefined as unknown as string })
)
const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis')
const extended = await extendLockNoRedis(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
vi.doUnmock('@/lib/core/config/env')
})
})
describe('retryStrategy', () => {
function captureRetryStrategy(): (times: number) => number {
let capturedConfig: Record<string, unknown> = {}
MockRedisConstructor.mockImplementation(
class {
constructor(_url: string, config: Record<string, unknown>) {
capturedConfig = config
Object.assign(this, { ping: vi.fn(), on: vi.fn() })
}
}
)
getRedisClient()
return capturedConfig.retryStrategy as (times: number) => number
}
it('should use exponential backoff with jitter', () => {
const retryStrategy = captureRetryStrategy()
expect(retryStrategy).toBeDefined()
const delay1 = retryStrategy(1)
expect(delay1).toBeGreaterThanOrEqual(1000)
expect(delay1).toBeLessThanOrEqual(1300)
const delay3 = retryStrategy(3)
expect(delay3).toBeGreaterThanOrEqual(4000)
expect(delay3).toBeLessThanOrEqual(5200)
const delay5 = retryStrategy(5)
expect(delay5).toBeGreaterThanOrEqual(10000)
expect(delay5).toBeLessThanOrEqual(13000)
})
it('should cap at 30s for attempts beyond 10', () => {
const retryStrategy = captureRetryStrategy()
expect(retryStrategy(11)).toBe(30000)
expect(retryStrategy(100)).toBe(30000)
})
})
})
+315
View File
@@ -0,0 +1,315 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { randomFloat } from '@sim/utils/random'
import Redis, { type RedisOptions } from 'ioredis'
import { env } from '@/lib/core/config/env'
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 resolveRedisTlsOptions(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 }
}
/**
* Shared connection defaults — keepAlive, connectTimeout, enableOfflineQueue,
* and TLS SNI when REDIS_URL targets an IP. Every Redis client we open should
* spread this; callers add their own retry / timeout policy on top.
*/
export function getRedisConnectionDefaults(
url: string | undefined
): Pick<RedisOptions, 'keepAlive' | 'connectTimeout' | 'enableOfflineQueue' | 'tls'> {
const tls = resolveRedisTlsOptions(url)
return {
keepAlive: 1000,
connectTimeout: 10000,
enableOfflineQueue: true,
...(tls ? { tls } : {}),
}
}
interface RedisState {
client: Redis | null
pingFailures: number
pingInterval: NodeJS.Timeout | null
pingInFlight: boolean
reconnectListeners: Array<() => void>
}
const g = globalThis as typeof globalThis & { _redisState?: RedisState }
if (!g._redisState) {
g._redisState = {
client: null,
pingFailures: 0,
pingInterval: null,
pingInFlight: false,
reconnectListeners: [],
}
}
const state = g._redisState
const PING_INTERVAL_MS = 15_000
const MAX_PING_FAILURES = 2
/**
* Register a callback that fires when the PING health check forces a reconnect.
* Useful for resetting cached adapters that hold a stale Redis reference.
*/
export function onRedisReconnect(cb: () => void): void {
state.reconnectListeners.push(cb)
}
function startPingHealthCheck(redis: Redis): void {
if (state.pingInterval) return
state.pingInterval = setInterval(async () => {
if (state.pingInFlight) return
state.pingInFlight = true
try {
await redis.ping()
state.pingFailures = 0
} catch (error) {
state.pingFailures++
logger.warn('Redis PING failed', {
consecutiveFailures: state.pingFailures,
error: toError(error).message,
})
if (state.pingFailures >= MAX_PING_FAILURES) {
logger.error('Redis PING failed consecutive times — forcing reconnect', {
consecutiveFailures: state.pingFailures,
})
state.pingFailures = 0
// Clear before notifying listeners — they may call getRedisClient() and must see the reset state.
state.client = null
if (state.pingInterval) {
clearInterval(state.pingInterval)
state.pingInterval = null
}
for (const cb of state.reconnectListeners) {
try {
cb()
} catch (cbError) {
logger.error('Redis reconnect listener error', { error: cbError })
}
}
try {
redis.disconnect(true)
} catch (disconnectError) {
logger.error('Error during forced Redis disconnect', { error: disconnectError })
}
}
} finally {
state.pingInFlight = false
}
}, PING_INTERVAL_MS)
}
/**
* Get a Redis client instance.
* Uses connection pooling to reuse connections across requests.
*
* ioredis handles command queuing internally via `enableOfflineQueue` (default: true),
* so commands are queued and executed once connected. No manual connection checks needed.
*/
export function getRedisClient(): Redis | null {
if (typeof window !== 'undefined') return null
if (!redisUrl) return null
if (state.client) return state.client
// Outside the try/catch so config errors aren't silently swallowed.
const defaults = getRedisConnectionDefaults(redisUrl)
try {
logger.info('Initializing Redis client')
state.client = new Redis(redisUrl, {
...defaults,
commandTimeout: 5000,
maxRetriesPerRequest: 5,
retryStrategy: (times) => {
if (times > 10) {
logger.error(`Redis reconnection attempt ${times}`, { nextRetryMs: 30000 })
return 30000
}
const base = Math.min(1000 * 2 ** (times - 1), 10000)
const jitter = randomFloat() * base * 0.3
const delay = Math.round(base + jitter)
logger.warn('Redis reconnecting', { attempt: times, nextRetryMs: delay })
return delay
},
reconnectOnError: (err) => {
const targetErrors = ['READONLY', 'ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED']
return targetErrors.some((e) => err.message.includes(e))
},
})
state.client.on('connect', () => logger.info('Redis connected'))
state.client.on('ready', () => logger.info('Redis ready'))
state.client.on('error', (err: Error) => {
logger.error('Redis error', { error: err.message, code: (err as any).code })
})
state.client.on('close', () => logger.warn('Redis connection closed'))
state.client.on('end', () => logger.error('Redis connection ended'))
startPingHealthCheck(state.client)
return state.client
} catch (error) {
logger.error('Failed to initialize Redis client', { error })
return null
}
}
/**
* Lua script for safe lock release.
* Only deletes the key if the value matches (ownership verification).
* Returns 1 if deleted, 0 if not (value mismatch or key doesn't exist).
*/
const RELEASE_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
/**
* Lua script for safe lock TTL extension.
* Only refreshes the expiry if the value matches (ownership verification),
* so a stale heartbeat from a prior owner cannot extend a lock currently
* held by someone else after a TTL eviction.
* Returns 1 if the TTL was extended, 0 if not (value mismatch or key gone).
*/
const EXTEND_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`
/**
* Acquire a distributed lock using Redis SET NX.
* Returns true if lock acquired, false if already held.
*
* When Redis is not available, returns true (lock "acquired") to allow
* single-replica deployments to function without Redis. In multi-replica
* deployments without Redis, the idempotency layer prevents duplicate processing.
*/
export async function acquireLock(
lockKey: string,
value: string,
expirySeconds: number
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
return true // No-op when Redis unavailable; idempotency layer handles duplicates
}
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
return result === 'OK'
}
/**
* Release a distributed lock safely.
* Only releases if the caller owns the lock (value matches).
* Returns true if lock was released, false if not owned or already expired.
*
* When Redis is not available, returns true (no-op) since no lock was held.
*/
export async function releaseLock(lockKey: string, value: string): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
return true // No-op when Redis unavailable; no lock was actually held
}
const result = await redis.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, value)
return result === 1
}
/**
* Extend the TTL of a distributed lock if still owned by the caller.
* Returns true if the caller still owns the lock and the TTL was refreshed,
* false if the lock has been taken over by another owner or has expired.
*
* When Redis is not available, returns true (no-op) to match the behavior
* of `acquireLock` / `releaseLock`: single-replica deployments without
* Redis never held a real lock, so heartbeat success is implicit.
*/
export async function extendLock(
lockKey: string,
value: string,
expirySeconds: number
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
return true
}
const result = await redis.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, value, expirySeconds)
return result === 1
}
/**
* Close the Redis connection.
* Use for graceful shutdown.
*/
export async function closeRedisConnection(): Promise<void> {
if (state.pingInterval) {
clearInterval(state.pingInterval)
state.pingInterval = null
}
if (state.client) {
try {
await state.client.quit()
} catch (error) {
logger.error('Error closing Redis connection', { error })
} finally {
state.client = null
}
}
}
/**
* Reset all module-level state. Only intended for use in tests.
*/
export function resetForTesting(): void {
if (state.pingInterval) {
clearInterval(state.pingInterval)
state.pingInterval = null
}
state.client = null
state.pingFailures = 0
state.pingInFlight = false
state.reconnectListeners.length = 0
}