From acb1873fccc0db40f1de5d2cd4fefcff3f6edd06 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 27 Apr 2026 17:38:19 -0700 Subject: [PATCH 1/8] Add feature flags v1: schema, evaluator, routes, dashboard Definitions live in branch config (free branch/env overrides). Pure deterministic evaluator shared by backend and (future) SDKs, MurmurHash3 bucketing, kill switch, dependsOn, holdouts, weighted multivariate. Adds /feature-flags/{bootstrap,evaluate} routes, dashboard CRUD, seed data, and an example demo page. --- apps/backend/prisma/seed.ts | 80 +++ .../latest/feature-flags/bootstrap/route.ts | 61 +++ .../latest/feature-flags/evaluate/route.ts | 97 ++++ .../feature-flags/[flagId]/page-client.tsx | 445 +++++++++++++++ .../feature-flags/[flagId]/page.tsx | 3 + .../[projectId]/feature-flags/page-client.tsx | 194 +++++++ .../[projectId]/feature-flags/page.tsx | 3 + apps/dashboard/src/lib/apps-frontend.tsx | 17 +- .../endpoints/api/v1/feature-flags.test.ts | 351 ++++++++++++ docs/src/components/mdx/app-card.tsx | 4 +- .../demo/src/app/feature-flags-demo/page.tsx | 130 +++++ packages/stack-shared/src/apps/apps-config.ts | 6 + .../src/config/schema-fuzzer.test.ts | 59 ++ packages/stack-shared/src/config/schema.ts | 153 ++++++ .../src/feature-flags/evaluator.ts | 517 ++++++++++++++++++ .../stack-shared/src/feature-flags/hashing.ts | 146 +++++ .../stack-shared/src/feature-flags/types.ts | 102 ++++ .../src/interface/crud/feature-flags.ts | 20 + 18 files changed, 2385 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/app/api/latest/feature-flags/bootstrap/route.ts create mode 100644 apps/backend/src/app/api/latest/feature-flags/evaluate/route.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/[flagId]/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/[flagId]/page.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/page.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/feature-flags.test.ts create mode 100644 examples/demo/src/app/feature-flags-demo/page.tsx create mode 100644 packages/stack-shared/src/feature-flags/evaluator.ts create mode 100644 packages/stack-shared/src/feature-flags/hashing.ts create mode 100644 packages/stack-shared/src/feature-flags/types.ts create mode 100644 packages/stack-shared/src/interface/crud/feature-flags.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 6081d98af0..c0279641e6 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -136,6 +136,86 @@ export async function seed() { } } }, + featureFlags: { + // Realistic-ish flags so the dashboard has something to demo on first run of the + // internal project. The /feature-flags-demo page in examples/demo evaluates these. + flags: { + "demo-new-checkout": { + key: "new-checkout", + description: "Roll out the redesigned checkout to a slice of users", + type: "boolean", + enabled: true, + killSwitch: false, + defaultVariantKey: "off", + variants: { + on: { value: true }, + off: { value: false }, + }, + rules: { + "ramp-25": { + priority: 10, + enabled: true, + rolloutPercentage: 25, + rolloutSeed: "new-checkout-2026-04", + variantKey: "on", + }, + }, + }, + "demo-pricing-experiment": { + key: "pricing-experiment", + description: "A/B test for the pricing page hero", + type: "multivariate", + enabled: true, + killSwitch: false, + defaultVariantKey: "control", + variants: { + control: { value: "control" }, + "treatment-a": { value: "treatment-a" }, + "treatment-b": { value: "treatment-b" }, + }, + rules: { + "all-traffic": { + priority: 0, + enabled: true, + rolloutPercentage: 100, + variantWeights: { + control: 0.5, + "treatment-a": 0.25, + "treatment-b": 0.25, + }, + }, + }, + }, + "demo-internal-tools": { + key: "internal-tools", + description: "Show internal tooling to @stack-auth.com addresses only", + type: "boolean", + enabled: true, + killSwitch: false, + defaultVariantKey: "off", + variants: { + on: { value: true }, + off: { value: false }, + }, + rules: { + "internal-emails": { + priority: 100, + enabled: true, + rolloutPercentage: 100, + variantKey: "on", + conditions: { + "is-employee": { + attribute: "user.email", + operator: "contains", + value: "@stack-auth.com", + }, + }, + }, + }, + }, + }, + holdouts: {}, + }, payments: { productLines: { plans: { diff --git a/apps/backend/src/app/api/latest/feature-flags/bootstrap/route.ts b/apps/backend/src/app/api/latest/feature-flags/bootstrap/route.ts new file mode 100644 index 0000000000..4ff236473c --- /dev/null +++ b/apps/backend/src/app/api/latest/feature-flags/bootstrap/route.ts @@ -0,0 +1,61 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { _internal as hashingInternal } from "@stackframe/stack-shared/dist/feature-flags/hashing"; +import type { FeatureFlagsConfig, FlagDef } from "@stackframe/stack-shared/dist/feature-flags/types"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Bootstrap feature flag definitions for SDK local evaluation", + description: "Returns the full set of feature flag definitions for the resolved tenancy. Clients cache the payload and evaluate locally; the server's evaluator and the SDK's evaluator are byte-identical.", + tags: ["Feature Flags"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + flags: yupMixed().defined(), + // Maps developer-facing flag keys to the opaque config ids used by evaluator references. + flag_ids_by_key: yupMixed().defined(), + holdouts: yupMixed().defined(), + // Bumped whenever the rendered config changes; SDKs use it as an ETag for polling. + version: yupString().defined(), + }).defined(), + }), + handler: async ({ auth }) => { + const config: FeatureFlagsConfig = auth.tenancy.config.featureFlags; + const flagsById: Record> = {}; + const flagIdsByKey: Record = {}; + for (const [id, def] of Object.entries(config.flags ?? {})) { + if (!def?.key) continue; + // ownerUserId is operator-facing metadata, never needed for evaluation. Strip it from the + // bootstrap payload so we don't leak admin user ids to client SDKs. + const { ownerUserId: _ownerUserId, ...rest } = def; + flagsById[id] = rest; + flagIdsByKey[def.key] = id; + } + + const holdouts = config.holdouts ?? {}; + // Stable content-addressed version: SDKs hit this endpoint with `If-None-Match: ` and + // we (eventually) 304 when nothing changed. Using murmur3 keeps this fast enough to recompute + // per request without caching. + const version = hashingInternal.murmur3_32(JSON.stringify({ flags: flagsById, flagIdsByKey, holdouts })).toString(16); + + return { + statusCode: 200, + bodyType: "json", + body: { + flags: flagsById, + flag_ids_by_key: flagIdsByKey, + holdouts, + version, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/feature-flags/evaluate/route.ts b/apps/backend/src/app/api/latest/feature-flags/evaluate/route.ts new file mode 100644 index 0000000000..739d0ea8a9 --- /dev/null +++ b/apps/backend/src/app/api/latest/feature-flags/evaluate/route.ts @@ -0,0 +1,97 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { evaluateFlag, evaluateFlags, findFlagIdByKey } from "@stackframe/stack-shared/dist/feature-flags/evaluator"; +import type { EvalContext, EvalResult, FeatureFlagsConfig } from "@stackframe/stack-shared/dist/feature-flags/types"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const evalResultSchema = yupObject({ + flag_key: yupString().defined(), + variant_key: yupString().nullable().defined(), + value: yupMixed(), + reason: yupString().defined(), + rule_id: yupString().nullable().defined(), +}); + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Evaluate feature flags for a given context", + description: "Resolves feature flag variants for a user/team/anonymous context. Definitions are read from the project's branch+environment config; evaluation is deterministic and matches the SDK's local evaluator byte-for-byte.", + tags: ["Feature Flags"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.optional(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + body: yupObject({ + // Caller-provided distinct identifier for sticky bucketing. If omitted we fall back to + // auth.user.id so authenticated callers get a stable bucket without any extra wiring. + distinct_id: yupString().optional(), + user_id: yupString().optional(), + team_id: yupString().optional(), + user: yupRecord(yupString(), yupMixed()).optional(), + team: yupRecord(yupString(), yupMixed()).optional(), + context: yupRecord(yupString(), yupMixed()).optional(), + cohorts: yupRecord(yupString(), yupMixed()).optional(), + // Subset of flag keys to evaluate. If omitted, every flag in the tenancy config is evaluated. + flag_keys: yupArray(yupString().defined()).optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + results: yupRecord(yupString(), evalResultSchema).defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const config: FeatureFlagsConfig = auth.tenancy.config.featureFlags; + + const evalContext: EvalContext = { + distinctId: body.distinct_id ?? body.user_id ?? auth.user?.id, + userId: body.user_id ?? auth.user?.id, + teamId: body.team_id, + user: body.user as Record | undefined, + team: body.team as Record | undefined, + context: body.context as Record | undefined, + cohorts: body.cohorts + ? Object.fromEntries(Object.entries(body.cohorts).map(([k, v]) => [k, Boolean(v)])) + : undefined, + }; + + const results: Record> = {}; + if (body.flag_keys) { + for (const requestedKey of body.flag_keys) { + const flagId = findFlagIdByKey(config, requestedKey); + const result = flagId === undefined + ? { flagKey: requestedKey, variantKey: undefined, value: undefined, reason: "missing" } satisfies EvalResult + : evaluateFlag(flagId, config, evalContext); + results[requestedKey] = shape(requestedKey, result); + } + } else { + const evaluated = evaluateFlags(config, evalContext); + for (const [id, result] of Object.entries(evaluated)) { + const flagDef = config.flags?.[id]; + const userKey = flagDef?.key ?? id; + results[userKey] = shape(userKey, result); + } + } + + return { + statusCode: 200, + bodyType: "json", + body: { results }, + }; + }, +}); + +function shape(flagKey: string, result: EvalResult) { + return { + flag_key: flagKey, + variant_key: result.variantKey ?? null, + value: result.value, + reason: result.reason, + rule_id: result.ruleId ?? null, + }; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/[flagId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/[flagId]/page-client.tsx new file mode 100644 index 0000000000..f194e8bc35 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/feature-flags/[flagId]/page-client.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { + DesignButton, + DesignInput, +} from "@/components/design-components"; +import { + ActionDialog, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Textarea, + toast, + Typography, +} from "@/components/ui"; +import { useUpdateConfig } from "@/lib/config-update"; +import { ArrowLeftIcon, TrashIcon } from "@phosphor-icons/react"; +import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; +import { evaluateFlag } from "@stackframe/stack-shared/dist/feature-flags/evaluator"; +import { featureFlagConditionOperators, type ConditionOperator, type EvalContext, type FeatureFlagsConfig, type FlagCondition, type FlagDef, type FlagRule, type FlagVariant } from "@stackframe/stack-shared/dist/feature-flags/types"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { useParams } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useRouter } from "../../../../../../../components/router"; +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; + +function tryParseJson(text: string): { ok: true, value: unknown } | { ok: false, error: string } { + try { + return { ok: true, value: JSON.parse(text) }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +function configUpdate(path: string, value: unknown): EnvironmentConfigOverrideOverride { + // Dynamic dotted config paths are validated by the config schema before saving, but TS cannot + // express arbitrary `featureFlags.flags.${flagId}...` keys. Keep the escape hatch narrow. + return { [path]: value } as EnvironmentConfigOverrideOverride; +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function isConditionOperator(value: unknown): value is ConditionOperator { + return featureFlagConditionOperators.some(operator => operator === value); +} + +function validateVariants(value: unknown): { ok: true, value: Record } | { ok: false, error: string } { + if (!isRecord(value)) return { ok: false, error: "Variants must be a JSON object keyed by variant id" }; + const variants: Record = {}; + for (const [variantKey, variant] of Object.entries(value)) { + if (!isRecord(variant)) return { ok: false, error: `Variant "${variantKey}" must be an object` }; + variants[variantKey] = { value: variant.value }; + } + return { ok: true, value: variants }; +} + +function validateRules(value: unknown): { ok: true, value: Record } | { ok: false, error: string } { + if (!isRecord(value)) return { ok: false, error: "Rules must be a JSON object keyed by rule id" }; + const rules: Record = {}; + for (const [ruleKey, rule] of Object.entries(value)) { + if (!isRecord(rule)) return { ok: false, error: `Rule "${ruleKey}" must be an object` }; + const outputRule: FlagRule = {}; + const priority = rule.priority; + if (priority !== undefined) { + if (typeof priority !== "number" || !Number.isInteger(priority) || priority < 0) { + return { ok: false, error: `Rule "${ruleKey}" priority must be a non-negative integer` }; + } + outputRule.priority = priority; + } + const enabled = rule.enabled; + if (enabled !== undefined) { + if (typeof enabled !== "boolean") return { ok: false, error: `Rule "${ruleKey}" enabled must be a boolean` }; + outputRule.enabled = enabled; + } + const rolloutPercentage = rule.rolloutPercentage; + if (rolloutPercentage !== undefined) { + if (typeof rolloutPercentage !== "number" || rolloutPercentage < 0 || rolloutPercentage > 100) { + return { ok: false, error: `Rule "${ruleKey}" rolloutPercentage must be a number from 0 to 100` }; + } + outputRule.rolloutPercentage = rolloutPercentage; + } + const rolloutSeed = rule.rolloutSeed; + if (rolloutSeed !== undefined) { + if (typeof rolloutSeed !== "string") return { ok: false, error: `Rule "${ruleKey}" rolloutSeed must be a string` }; + outputRule.rolloutSeed = rolloutSeed; + } + const stickyBy = rule.stickyBy; + if (stickyBy !== undefined) { + if (stickyBy !== "userId" && stickyBy !== "teamId" && stickyBy !== "distinctId") { + return { ok: false, error: `Rule "${ruleKey}" stickyBy must be userId, teamId, or distinctId` }; + } + outputRule.stickyBy = stickyBy; + } + const variantKey = rule.variantKey; + if (variantKey !== undefined) { + if (typeof variantKey !== "string") return { ok: false, error: `Rule "${ruleKey}" variantKey must be a string` }; + outputRule.variantKey = variantKey; + } + const variantWeights = rule.variantWeights; + if (variantWeights !== undefined) { + if (!isRecord(variantWeights)) return { ok: false, error: `Rule "${ruleKey}" variantWeights must be an object` }; + const outputVariantWeights: Record = {}; + for (const [variantWeightKey, weight] of Object.entries(variantWeights)) { + if (typeof weight !== "number" || weight < 0 || weight > 1) { + return { ok: false, error: `Rule "${ruleKey}" variantWeights.${variantWeightKey} must be a number from 0 to 1` }; + } + outputVariantWeights[variantWeightKey] = weight; + } + if (Object.values(outputVariantWeights).length === 0 || !Object.values(outputVariantWeights).some(weight => weight > 0)) { + return { ok: false, error: `Rule "${ruleKey}" variantWeights must include at least one positive weight` }; + } + outputRule.variantWeights = outputVariantWeights; + } + if ((outputRule.variantKey !== undefined) === (outputRule.variantWeights !== undefined)) { + return { ok: false, error: `Rule "${ruleKey}" must specify exactly one of variantKey or variantWeights` }; + } + const conditions = rule.conditions; + if (conditions !== undefined) { + if (!isRecord(conditions)) return { ok: false, error: `Rule "${ruleKey}" conditions must be an object` }; + const outputConditions: Record = {}; + for (const [conditionKey, condition] of Object.entries(conditions)) { + if (!isRecord(condition)) return { ok: false, error: `Condition "${conditionKey}" in rule "${ruleKey}" must be an object` }; + if (typeof condition.attribute !== "string") return { ok: false, error: `Condition "${conditionKey}" in rule "${ruleKey}" needs a string attribute` }; + if (!isConditionOperator(condition.operator)) return { ok: false, error: `Condition "${conditionKey}" in rule "${ruleKey}" has an invalid operator` }; + outputConditions[conditionKey] = { + attribute: condition.attribute, + operator: condition.operator, + value: condition.value, + }; + } + outputRule.conditions = outputConditions; + } + rules[ruleKey] = outputRule; + } + return { ok: true, value: rules }; +} + +function validateEvalContext(value: unknown): { ok: true, value: EvalContext } | { ok: false, error: string } { + if (!isRecord(value)) return { ok: false, error: "Context must be a JSON object" }; + for (const key of ["distinctId", "userId", "teamId"]) { + if (key in value && value[key] !== undefined && typeof value[key] !== "string") { + return { ok: false, error: `${key} must be a string` }; + } + } + for (const key of ["user", "team", "context"]) { + if (key in value && value[key] !== undefined && !isRecord(value[key])) { + return { ok: false, error: `${key} must be an object` }; + } + } + if ("cohorts" in value && value.cohorts !== undefined) { + if (!isRecord(value.cohorts)) return { ok: false, error: "cohorts must be an object" }; + for (const [cohortKey, isMember] of Object.entries(value.cohorts)) { + if (typeof isMember !== "boolean") return { ok: false, error: `cohorts.${cohortKey} must be a boolean` }; + } + } + return { ok: true, value }; +} + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const router = useRouter(); + const updateConfig = useUpdateConfig(); + const { flagId } = useParams<{ flagId: string }>(); + + const config = project.useConfig(); + const flag = config.featureFlags.flags[flagId] as FlagDef | undefined; + + // Local edit buffers — variants and rules round-trip as JSON in v1; the visual rule builder lives + // in a follow-up. State is initialized lazily from the canonical config and the operator hits + // "Save" to push. + const initialVariantsJson = useMemo(() => JSON.stringify(flag?.variants ?? {}, null, 2), [flag?.variants]); + const initialRulesJson = useMemo(() => JSON.stringify(flag?.rules ?? {}, null, 2), [flag?.rules]); + + const [description, setDescription] = useState(flag?.description ?? ""); + const [defaultVariantKey, setDefaultVariantKey] = useState(flag?.defaultVariantKey ?? ""); + const [variantsJson, setVariantsJson] = useState(initialVariantsJson); + const [rulesJson, setRulesJson] = useState(initialRulesJson); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + // Debug evaluator panel. + const [debugContextJson, setDebugContextJson] = useState('{\n "distinctId": "test-user",\n "user": { "email": "alice@example.com" }\n}'); + + if (!flag) { + return ( + + + router.push(`/projects/${project.id}/feature-flags`)}> + Back to flags + + + + ); + } + + const handleToggleEnabled = async (next: boolean) => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { [`featureFlags.flags.${flagId}.enabled`]: next }, + pushable: true, + }); + }; + + const handleToggleKillSwitch = async (next: boolean) => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { [`featureFlags.flags.${flagId}.killSwitch`]: next }, + pushable: true, + }); + }; + + const handleSaveMetadata = async () => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + ...configUpdate(`featureFlags.flags.${flagId}.description`, description.trim() || null), + ...configUpdate(`featureFlags.flags.${flagId}.defaultVariantKey`, defaultVariantKey.trim() || null), + }, + pushable: true, + }); + toast({ title: "Saved" }); + }; + + const handleSaveVariants = async () => { + const parsed = tryParseJson(variantsJson); + if (!parsed.ok) { + alert(`Invalid JSON: ${parsed.error}`); + return; + } + const variants = validateVariants(parsed.value); + if (!variants.ok) { + alert(variants.error); + return; + } + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: configUpdate(`featureFlags.flags.${flagId}.variants`, variants.value), + pushable: true, + }); + toast({ title: "Variants saved" }); + }; + + const handleSaveRules = async () => { + const parsed = tryParseJson(rulesJson); + if (!parsed.ok) { + alert(`Invalid JSON: ${parsed.error}`); + return; + } + const rules = validateRules(parsed.value); + if (!rules.ok) { + alert(rules.error); + return; + } + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: configUpdate(`featureFlags.flags.${flagId}.rules`, rules.value), + pushable: true, + }); + toast({ title: "Rules saved" }); + }; + + const handleDelete = async () => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { [`featureFlags.flags.${flagId}`]: null }, + pushable: true, + }); + toast({ title: "Flag deleted" }); + router.push(`/projects/${project.id}/feature-flags`); + }; + + // Live evaluation against current config + the JSON-edited debug context. + const debugResult = (() => { + const parsed = tryParseJson(debugContextJson); + if (!parsed.ok) return { error: parsed.error }; + const ctx = validateEvalContext(parsed.value); + if (!ctx.ok) return { error: ctx.error }; + const cfg: FeatureFlagsConfig = config.featureFlags; + return { result: evaluateFlag(flagId, cfg, ctx.value) }; + })(); + + const variantOptions = typedEntries(flag.variants ?? {}).map(([key]) => key); + + return ( + + setIsDeleteOpen(true)}> + Delete + + } + > +
+ + + Status + Kill switch overrides everything; disabled hides the flag from rules. + + +
+
+ + When off, the flag returns its default variant. +
+ +
+
+
+ + Force the default for everyone, ignoring rules. +
+ +
+
+
+ + + + Metadata + + +
+ + setDescription(e.target.value)} + placeholder="What does this flag control?" + /> +
+
+ + {variantOptions.length > 0 ? ( + + ) : ( + setDefaultVariantKey(e.target.value)} + placeholder="e.g., off" + /> + )} + + Returned when no rule matches, when the flag is disabled, or when the kill switch is on. + +
+ Save metadata +
+
+ + + + Variants + JSON keyed by variant id. Each variant has a `value`; split weights live on rules as `variantWeights`. + + +