6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
210 lines
9.4 KiB
TypeScript
210 lines
9.4 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 = {
|
|
'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',
|
|
},
|
|
'tables-v2-api': {
|
|
description:
|
|
'Gate the v2 tables HTTP API — the public read API (GET /api/v2/tables, POST ' +
|
|
'/api/v2/tables/[tableId]/query) and the internal predicate-grammar query route (POST ' +
|
|
'/api/table/[tableId]/query). When off, those routes return 404 as if the surface does not ' +
|
|
'exist. Gated by userId/orgId/admins via AppConfig; off-AppConfig falls back to TABLES_V2_API.',
|
|
fallback: 'TABLES_V2_API',
|
|
},
|
|
'table-locks': {
|
|
description:
|
|
'Per-table mutation locks (schema/insert/update/delete) an admin toggles to make a table ' +
|
|
'append-only or read-only. Gates the ability to SET locks (the settings UI + the PATCH ' +
|
|
'locks branch); enforcement of any lock already stored always runs. Since new tables are ' +
|
|
'created unlocked and forks reset locks, an off flag means no table can become locked, so ' +
|
|
'the woven-in asserts stay no-ops. Off-AppConfig falls back to TABLE_LOCKS.',
|
|
fallback: 'TABLE_LOCKS',
|
|
},
|
|
'table-views': {
|
|
description:
|
|
'Saved table views (named filter/sort/column-visibility presets) plus the column show/hide ' +
|
|
'menu, in the table-detail options bar. UI-only gate: resolved in the table page (server) ' +
|
|
"and passed down, so the table falls back to today's Filter/Sort bar when off. The routes " +
|
|
'and the table_views table ship ungated — they are inert with no UI to call them, and a view ' +
|
|
'saved during a rollout must survive the flag being toggled back off. Embedded (mothership) ' +
|
|
'tables render without views regardless, since no server context resolves the flag there. ' +
|
|
'Off-AppConfig falls back to TABLE_VIEWS.',
|
|
fallback: 'TABLE_VIEWS',
|
|
},
|
|
} 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)
|
|
}
|