Files
simstudioai--sim/apps/sim/lib/core/config/feature-flags.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

190 lines
8.1 KiB
TypeScript

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)
}