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
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:
@@ -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
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user