Skip to main content

Feature Flags (Developer Guide)

This guide covers the feature-flag system from a developer perspective: the flag taxonomy, resolution order, backend service, and frontend primitives used to gate pages, actions, and UI elements.

Flag Taxonomy​

Every flag key is prefixed with its category. Use the correct category - misclassified flags cause the wrong resolution logic to be applied and may show up in incorrect admin UI sections.

CategoryUse forExamples
feature:*Dashboard pages and major feature areasfeature:bots, feature:music
widget:*Overlay widgets only (render inside a broadcast overlay)widget:chat_box, widget:event_list, widget:obs_browser_source
integration:*Third-party tool integrations (modal entries)integration:shopify, integration:obs_websocket
platform:{x}Streaming platform master kill-switchplatform:twitch, platform:youtube
platform:{x}:{type}Platform sub-flag for connection typeplatform:twitch:login, platform:kick:channel
system:*User-scope system controls; resolved by AccountCreationService, not FeatureServicesystem:account_creation
bot_module:*Individual bot moderation modulesbot_module:link_protection

Key rules​

  • feature:* flags gate pages and dashboard features - never use widget:* flags for this purpose.
  • integration:* flags gate integrations modal entries, not streaming platform connections.
  • system:* flags are user-scoped - they include a per-user override layer that other categories lack. Do not resolve system:* flags through get_feature_statuses (it filters them out of the global snapshot); use can_user_create_account().

Resolution Chain​

Account-scope flags (feature:*, widget:*, integration:*, platform:*, bot_module:*)​

The shared account resolver applies the release state first:

  1. Off returns GLOBAL_OFF for every account. The kill switch beats an On override.
  2. Overrides only admits only an explicit On account override; other accounts receive NOT_RELEASED.
  3. Rollout admits an On override or an account already reached by the rollout whose plan includes the flag. A pending account receives NOT_RELEASED; a denied plan receives PLAN_LOCKED.
  4. On applies account override > plan inclusion > default_for_accounts. An Off override returns ACCOUNT_OVERRIDE. Enabled statuses have no reason. A waiting time in Rollout or On can delay a newly created account and supply unlock_at.

Fail-closed rules to keep in mind:

  • A key that is not in the global cache resolves to false in is_enabled() (resolve_enabled returns false for None), so a flag referenced in code but never seeded is off.
  • The pricing-card query (PRICING_FEATURES_SQL in apps/api/src/db/plans.rs) uses COALESCE(pf.enabled, false) - a flag with no plan_features row shows as not-included for that plan.
  • get_feature_statuses / build_resolved_features, by contrast, fall back to the flag's default_for_accounts column when neither a plan row nor an account row exists. Seed the paired plan_features rows rather than relying on that default.

FeatureService::get_feature_statuses(account_id, plan_id) -> anyhow::Result<Vec<FeatureStatus>> returns the statuses for all non-system: flags. It filters system: keys out of the global snapshot - user-scope flags are merged in by the caller.

User-scope flags (system:account_creation)​

Resolved by can_user_create_account() in apps/api/src/services/account_creation.rs:

  1. USER_OVERRIDE - the users.account_creation_override column is explicitly true (allow) or false (deny). A deny override here blocks the user regardless of global state; an allow override grants access regardless of global state.
  2. GLOBAL_OFF - if no per-user override is set and the global system:account_creation flag is disabled.
  3. Enabled - no override, global flag is on.

Cache keys (both TTL 3600 s, TTL_SECS):

KeyHolds
lumio:system:account_creation_enabledThe global flag value
lumio:user:{user_id}:account_creation_overrideThe per-user override

Both entries are invalidated when the flag or the override changes.

FeatureStatus struct​

pub struct FeatureStatus {
pub key: String,
pub enabled: bool,
pub reason: Option<FeatureDisabledReason>,
pub unlock_at: Option<DateTime<Utc>>,
}

pub enum FeatureDisabledReason {
GlobalOff,
NotReleased,
PlanLocked,
AccountOverride,
UserOverride,
}

The two protocols spell the same enum differently, each following its own convention:

ProtocolTypeWire values
GraphQLFeatureDisabledReasonGqlGLOBAL_OFF, NOT_RELEASED, PLAN_LOCKED, ACCOUNT_OVERRIDE, USER_OVERRIDE
RESTFeatureDisabledReasonRestglobal_off, not_released, plan_locked, account_override, user_override
caution

Never use a raw reason as an i18n key or compare it against a literal. The featureGate.* message keys are snake_case, so a GraphQL-sourced GLOBAL_OFF renders the untranslated key instead of a message. Run every wire value through normalizeFeatureReason() first - FeatureProvider, resolveReason() and <FeatureDisabledPage> already do. Unknown values collapse to null, never to a wrong key.

normalizeFeatureReason lives in apps/web/src/lib/feature-reason.ts, which carries no "use client" directive, so server components can import it directly. feature-context.tsx re-exports FeatureDisabledReason, FeatureStatus and FeatureStatusInput as types only - importing the function through that client module from a server component crosses the client/server boundary and fails at request time. Server code imports from @/lib/feature-reason.

Backend Service​

FeatureService (account-scope)​

Located at apps/api/src/services/feature_service.rs. Constructor: FeatureService::new(db: PgPool, redis: RedisClient).

Key methods:

MethodDescription
is_enabled(feature_key, account_id: Option<Uuid>)Boolean check for a single flag key. Unknown key → false. Passing None treats Overrides only and Rollout as off; On uses the global default (no Redis lookup)
is_enabled_global(feature_key)Global-only check straight from the in-memory cache, no DB or Redis hit
get_enabled_features(account_id)Sorted Vec<String> of the flag keys is_enabled(key, Some(account_id)) would allow; both share resolve_enabled
get_feature_statuses(account_id, plan_id)Vec<FeatureStatus> with a reason for every non-system: flag
compute_me_feature_statuses(user_id, active_account_id)Merges account-scope statuses with the user-scope system:account_creation status; the canonical list behind both Me.featureStatuses and GET /v1/users/me
is_provider_enabled / get_enabled_providers / get_enabled_platforms / get_streaming_platformsPlatform-flag helpers over the same cache
refresh_global_cache()Reloads feature_flags into the in-memory cache; a background worker calls it every 30 seconds
invalidate_account_cache(account_id) / invalidate_plan_accounts_cache(plan_id)Invalidate one account after an override, or queue chunked invalidation of plan accounts after a plan change

The resolved per-account map is cached in Redis under lumio:account_features:{account_id} with a TTL of at most one hour, shortened to the next scheduled unlock. Each stored value is the final decision after the kill switch, release state, rollout, waiting time, plan and account override; direct Redis readers must use it as such.

Server-side enforcement coverage​

The client-side route gate is a UX layer, not the security boundary - a disabled flag must be rejected by the API on a direct call. Each user-facing feature:* key is enforced identically on both protocols: GraphQL via FeatureGuard::new("{key}") (composed with the resolver's PermissionGuard via .and(...)) and REST via require_feature(&state.feature_service, "{key}", account_id). Both surface the same fail-closed error (Feature '{key}' is not available, HTTP 403). This table is the audited map; keep it in step with the resolvers so the two protocols never drift apart.

Feature keyGraphQL resolverREST routeEnforced (GraphQL / REST)
feature:multichatchat.rs, youtube_memberships.rschat.rs✅ / ✅
feature:chat-viewer-listchat.rs (chatters)chat.rs (GET /v1/chat/chatters) + WS chatters:* (channel_feature_for)✅ / ✅
feature:automationautomations.rsautomations.rs✅ / ✅
feature:obs_remoteobs.rsobs_remote.rs, obs_integration.rs✅ / ✅
feature:copyright_detectioncopyright.rscopyright.rs✅ / ✅
feature:streamelementsse_tokens.rsse_tokens.rs✅ / ✅
feature:musicspotify.rsspotify.rs✅ / ✅
feature:botsbot_connections.rsbot_connections.rs✅ / ✅
feature:bot_modulesbot_modules.rsbot_modules.rs (umbrella + per-module bot_module:{name})✅ / ✅
feature:bot_commandsbot_commands.rsbot_commands_crud.rs✅ / ✅
feature:connectionsconnections.rsconnections.rs✅ / ✅
feature:rewardsrewards.rs(no REST surface - GraphQL-only)✅ / n/a
feature:overlay_foldersoverlay_folders.rsoverlay_folders.rs✅ / ✅
feature:overlay_accessoverlays.rsoverlays.rs✅ / ✅
feature:overlay_sharingoverlays.rsoverlays.rs✅ / ✅
feature:widgetswidgets.rswidgets.rs✅ / ✅
feature:soundssounds.rssounds.rs✅ / ✅
feature:extensionsextension_store.rs, extension_access.rsextension_*.rs✅ / ✅
feature:extension_developmentdeveloper_*.rs, bot_module_extensions.rsdeveloper_*.rs, bot_module_extensions.rs✅ / ✅
feature:multi_accountaccounts.rs (inline is_enabled)accounts.rs (inline is_enabled)✅ / ✅
feature:advanced_overlays(UI-only)(UI-only)n/a - gates overlay-editor capabilities in the webapp SSR (overlays/{id}/editor), no dedicated resolver
feature:custom_botbot_connections.rs (authorize_bot)bot_connections.rs (authorize + shared complete_bot_connection choke)✅ / ✅
feature:extension_paidextension_store.rs (install_extension, when pricing_type != "free")extension_store.rs (install_extension), extension_purchases.rs (create_purchase)✅ / ✅
feature:apikeystokens.rs, service_keys.rs (create/edit only)tokens.rs, service_keys.rs (create/edit only)✅ / ✅ (create/edit)

feature:advanced_overlays has no dedicated API surface - the base overlay resolvers already gate on feature:overlays/feature:overlay_*, and the flag only toggles editor capabilities in the webapp. feature:custom_bot (a paid unlock - "connect your own bot account", i.e. OAuth a per-account bot_type = "custom" identity) is enforced at the custom-bot connect surface: GraphQL authorize_bot and REST authorize fail fast, and the shared complete_bot_connection choke that every OAuth exchange path funnels through — including the unified callback, which carries no plan context of its own - is the authoritative gate. It gates only custom-bot connects, never global-bot operations (toggle_bot_for_platform / rejoin_bot / delete_bot_connection). feature:extension_paid (a paid unlock layered on top of the feature:extensions umbrella) is enforced at the paid-extension acquisition points: the REST create_purchase route (POST /v1/extension-purchases) gates on it unconditionally (purchase is inherently paid, and there is no GraphQL twin - a deliberate single-protocol gate), while both install_extension paths (GraphQL and REST) gate on it only when the resolved extension pricing_type != "free", so free extensions install unaffected. The Stripe checkout.session.completed webhook auto-install is intentionally not gated - it is a server-side post-payment callback, not a user request, and a gate there would wrongly reject a legitimate completed purchase. Its account default is default_for_accounts = true; the free plan is denied by its explicit plan_features (enabled = false) row (20260517000002_plan_features_extension_platform).

feature:apikeys (a Pro-plan unlock, ZAF-1173) is enforced only on the lifecycle-mutating actions of both API keys (lm_usr_*) and account service keys (lm_svc_*): create_* and update_* gate on it, list_*/delete_* do not. The read/delete carve-out is deliberate and load-bearing - flipping the flag off (a downgrade) must never strip a user of the ability to list and revoke a live credential, so only creating and renaming are locked. The same carve-out holds on both protocols (parity), and the free plan is denied by its explicit plan_features (enabled = false) row plus default_for_accounts = false (20260909000001_apikeys_pro_only). A source tripwire (apps/api/tests/apikeys_pro_plan_gate.rs) locks this split so read/delete cannot silently drift back behind the gate.

On the frontend the API Keys sidebar entry follows feature:apikeys unconditionally (ZAF-1222/ZAF-1242) - a disabled flag hides it, including a plan_locked downgrade. The route stays reachable by direct URL and renders the locked list (revoke enabled, create/edit disabled); the discovery path for a downgraded user is a notice on the Subscription page that appears only when the account still holds live keys. Do not re-add an ungated nav entry - the earlier ZAF-1173 carve-out left a dead sidebar link for every Free/planless account.

Account creation (user-scope)​

Located at apps/api/src/services/account_creation.rs. This is a plain module of free functions - there is no AccountCreationService type.

FunctionDescription
can_user_create_account(db, redis, user_id)Returns anyhow::Result<CreationDecision>, where CreationDecision { allowed: bool, reason: Option<FeatureDisabledReason> }
invalidate_global_flag(redis)Invalidates the global flag cache entry
invalidate_user_override(redis, user_id)Invalidates the per-user cache entry

can_user_create_account is called in both the createAccount GraphQL mutation and the POST /v1/accounts REST handler to enforce the same gate on both protocols.

Cache invalidation​

When the system:account_creation feature flag is toggled via PATCH /v1/admin/feature-flags/{id} or the GraphQL updateFeatureFlag mutation, invalidate_global_flag() is called. When PATCH /v1/admin/users/{id} updates account_creation_override, invalidate_user_override() is called.

GraphQL API​

me { featureStatuses }​

Returns merged account-scope + user-scope statuses for the current user. Available on MeResult in me { ... } queries.

accountFeatures { enabledFeatures featureStatuses }​

Returns account-scope-only statuses (no system:*) for the caller's active account. Works with popout-token auth.

enabledFeatures vs featureStatuses​

The two fields come from different sources:

  • featureStatuses is resolved fresh from the database on every query and carries a disabled reason per flag.
  • enabledFeatures is the list of keys the API guards allow for the account. It walks every flag in the global cache and decides each one with the same resolve_enabled() core that is_enabled() uses — and with it every FeatureGuard, REST require_feature and WebSocket gate. Per-account values come from the cached map lumio:account_features:{account_id} (at-most-1-hour TTL).

A flag seeded after that map was built is missing from the map. Both is_enabled() and enabledFeatures then fall back to default_for_accounts only when the flag is On, so a freshly seeded default-on flag shows up in enabledFeatures as soon as the global cache picks it up (at startup, or within the 30-second refresh) — the same moment the guards allow it. A key left in the cached map after its flag was deleted is dropped (unknown key, fail closed), and a global kill switch hides a flag even if the cached map still says true.

The two fields can still disagree while a cached map is stale for a reason other than a new flag — for example a plan-feature change that skipped invalidate_plan_accounts_cache(). enabledFeatures then matches what the guards enforce, and featureStatuses shows the database state.

UI gating that must match the dashboard's FeatureProvider derives its list from featureStatuses, using enabledFeatureKeys() in apps/web/src/lib/feature-reason.ts. The chat popout (/popout/chat) does this in both auth modes.

adminUpdateUserAccountCreationOverride(userId: UUID!, override: AccountCreationOverride!): AdminUser!​

Admin mutation to set a per-user override; it returns the updated AdminUser, not a boolean. AccountCreationOverride enum: DEFAULT, ALLOW, DENY.

REST API​

EndpointDescription
GET /v1/users/meIncludes feature_statuses[] in the response
GET /v1/accounts/{id}/enabled-featuresEnabled flag keys for an account
GET /v1/accounts/{id}/feature-statusesFull status list with reason for an account
GET /v1/features/enabledGlobal check for a single key (?key=...); reachable without an account
PATCH /v1/admin/users/{id}Set account_creation_override - body value is the string "default", "allow" or "deny"
GET /v1/admin/feature-flags · GET /v1/admin/feature-flags/detailAdmin flag registry
PATCH /v1/admin/feature-flags/{id} · PUT /v1/admin/feature-flags/{id}/detailToggle / edit a flag
PUT /v1/admin/plans/{id}/featuresSet a plan's plan_features rows
GET/PUT /v1/admin/accounts/{id}/features · DELETE /v1/admin/accounts/{id}/features/{feature_id}Per-account overrides (account_features)

See REST API reference for response shapes.

Frontend Primitives (Webapp)​

All primitives live in apps/web/src/.

FeatureProvider​

Context provider (contexts/feature-context.tsx) that supplies feature data to the component tree. Mount it in the app shell layout with the flags loaded from the server. The chat, events and music popouts each mount one with their SSR-resolved statuses; with an empty list (fetch failed) useFeature is false and useIsFeatureEnabled fails open, as without a provider.

useFeature(key: string): boolean​

Returns true if the given feature key is enabled for the current account. Returns false while statuses are not loaded.

useFeatureStatus(key: string)​

Returns { enabled: boolean; reason: FeatureDisabledReason | null; unlockAt: string | null; isLoaded: boolean }. Use this when you need a reason-aware disabled state (e.g. an upgrade prompt for plan_locked). An unknown key on a loaded context yields { enabled: false, reason: "global_off", isLoaded: true }.

Other hooks​

HookReturns
useFeatures()string[] of enabled keys
useFeatureStatuses()The raw Map<string, FeatureStatus>
useIsFeatureEnabled()A (key) => boolean predicate usable inside loops/filters without breaking the rules of hooks. Fails open while statuses are unloaded, so a nav bar does not empty itself during an API hiccup

<FeatureGate flag={string} mode={...}>​

Declarative gate component (components/feature-gate.tsx). The prop is flag, not key or featureKey.

mode propBehaviour when the feature is disabled
page (default)Renders <FeatureDisabledPage reason={reason ?? "global_off"} />
hideRenders nothing
alertRenders the children (the section handles its own messaging)

While the context is not loaded - API unreachable or SSR failed - every mode renders the children, so an outage never masquerades as "disabled".

<FeatureGate flag="feature:music" mode="page">
<MusicDashboard />
</FeatureGate>

<FeatureDisabledPage reason={...}>​

Full-page disabled state (components/feature-disabled-page.tsx). Renders a different message per reason:

  • plan_locked - upgrade CTA
  • global_off - "Feature unavailable" message (admin-controlled)
  • not_released - "Not released yet" message, with the scheduled date when unlockAt is available
  • account_override - "Feature disabled for your account"
  • No reason / fallback - generic disabled page

Server-side route gate (<FeatureRouteGate>)​

Hiding a sidebar entry does not close a route - a direct link still renders the page. Close the whole segment from its layout.tsx, so every nested route (/dashboard/widgets/{id}/designer as well as /dashboard/widgets) is covered by one gate:

// apps/web/src/app/(main)/(app)/dashboard/widgets/layout.tsx
import type { ReactNode } from "react";
import { FeatureRouteGate } from "@/components/feature-route-gate";

export default function WidgetsLayout({ children }: { children: ReactNode }) {
return <FeatureRouteGate feature="feature:widgets">{children}</FeatureRouteGate>;
}

FeatureRouteGate (components/feature-route-gate.tsx) calls checkFeatureSSR (lib/feature-status.ts), which reads enabledFeatures and featureStatuses from the shared DashboardMe load (lib/dashboard-me.ts). The loader reads the JWT from cookies and is cached for one server rendering, so the dashboard layout, segment gates and pages share one GraphQL me request. Never use shared/api REST functions in SSR pages. A nested page may add a stricter check of its own without another me request: /dashboard/overlays gates on feature:overlays in the layout, while overlays/{id}/editor/page.tsx additionally requires feature:advanced_overlays.

The gate fails open when the API is unreachable or a partial GraphQL response lacks usable feature lists (api_unreachable): an outage must not look like "your administrator disabled this". The API enforces every flag independently via FeatureGuard / require_feature, so the route gate is a UX layer, not the security boundary.

apps/web/__tests__/feature-route-gates.test.ts asserts that every nav item declaring a feature is covered by a layout gate, so a new route cannot ship without one.

Adding a New Feature Flag - Checklist​

  1. Register the flag. Two seed paths exist and both are in use:

    • a tuple (category, key, label) in NEW_FEATURE_FLAGS (apps/api/src/db/admin.rs), applied at startup by seed_default_feature_flags(). PRE_R15_FEATURE_FLAGS is frozen; never append to it; or
    • an INSERT INTO feature_flags in a migration under apps/api/migrations/ (this is where description and default_for_accounts get set).

    Leave state out. A new flag in a user-facing category (feature, widget, integration, bot_module) then starts in Overrides only: hidden from every account, testable through an account override, until an operator moves it to Rollout or On in the admin app. The feature_flags_initial_state trigger sets the state when an insert omits it: enabled = false gives Off, a user-facing category gives Overrides only, every other category (system, platform, …) gives On. An explicit state is kept.

  2. Add a migration that inserts plan_features rows for all existing plans. A flag with no plan_features row reads as not-included on the pricing card (COALESCE(pf.enabled, false)).

  3. Add admin translation keys in apps/admin/messages/{en,de}.json under featureFlags.keys."{key}".

  4. If gating an ordinary webapp page: add feature: "{key}" to its nav item in apps/web/src/app/(main)/(app)/shell.tsx and a <FeatureRouteGate feature="{key}"> in the route segment's layout.tsx. The nav flag only hides the link; the layout gate closes direct links. If gating a settings area, put the feature on its entry in apps/web/src/lib/settings/sections.ts: sectionGate controls the window list and the GET /api/settings/sections/[id] load route. The API remains the security boundary on both protocols.

  5. Enforce it on the API. The route gate is UX, not the security boundary: add FeatureGuard::new("{key}") to the GraphQL resolvers and the matching require_feature check to the REST handlers, so both protocols reject identically.

  6. If the flag has cache implications: ensure both the GraphQL mutation and REST twin queue the same cache invalidation job. Do not loop through every account in the admin request.

Key Files​

FilePurpose
apps/api/src/services/feature_service.rsFeatureService - account-scope flag resolution
apps/api/src/services/account_creation.rscan_user_create_account() - user-scope system:account_creation resolution
apps/api/src/db/admin.rsseed_default_feature_flags() - flag seed data
apps/api/src/graphql/accounts.rsaccountFeatures GraphQL query
apps/api/src/graphql/auth.rsMe.featureStatuses, FeatureStatusGql, FeatureDisabledReasonGql
apps/api/src/graphql/admin.rsadminUpdateUserAccountCreationOverride, AccountCreationOverride enum
apps/api/src/routes/accounts.rsREST GET /accounts/{id}/enabled-features + /feature-statuses
apps/api/src/routes/users.rsREST GET /users/me with feature_statuses field
apps/api/src/routes/admin.rsREST PATCH /admin/users/{id} with account_creation_override
apps/web/src/contexts/feature-context.tsxFeatureProvider, useFeature, useFeatureStatus, normalizeFeatureReason
apps/web/src/components/feature-gate.tsx<FeatureGate> with mode prop
apps/web/src/components/feature-route-gate.tsx<FeatureRouteGate> - segment layout.tsx route gate
apps/web/src/lib/dashboard-me.tsloadDashboardMe, getDashboardMe - one cached me load per dashboard server rendering
apps/web/src/lib/feature-status.tscheckFeatureSSR, resolveReason - reads feature lists from that load
apps/web/src/components/feature-disabled-page.tsxReason-aware disabled page