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:*)​

Resolution stops at the first layer that returns enabled = false:

  1. GLOBAL_OFF - the flag is disabled globally (admin kill-switch in the feature_flags table, served from FeatureService's in-process global_cache).
  2. PLAN_LOCKED - the account's plan does not include this feature (plan_features table).
  3. ACCOUNT_OVERRIDE - an explicit per-account override has disabled this feature (account_features table).
  4. Enabled - none of the above applied; reason is null.

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 enum FeatureDisabledReason {
GlobalOff,
PlanLocked,
AccountOverride,
UserOverride,
}

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

ProtocolTypeWire values
GraphQLFeatureDisabledReasonGqlGLOBAL_OFF, PLAN_LOCKED, ACCOUNT_OVERRIDE, USER_OVERRIDE
RESTFeatureDisabledReasonRestglobal_off, 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 yields 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 5 minutes
invalidate_account_cache(account_id) / invalidate_plan_accounts_cache(plan_id)Drop the resolved-feature Redis entries after an override or plan change

The resolved per-account map is cached in Redis under lumio:account_features:{account_id} with a 1-hour TTL; precedence is global defaults < plan defaults < account overrides (merge_resolved_features).

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} (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 the flag's default_for_accounts, 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 5-minute 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.

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; 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)
  • 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 the JWT from cookies and queries me { enabledFeatures featureStatuses } over GraphQL - never use shared/api REST functions in SSR pages. The fetch is request-memoised, so a nested page may add a stricter check of its own for free: /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 (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 seed_default_feature_flags() (apps/api/src/db/admin.rs), applied at startup; or
    • an INSERT INTO feature_flags in a migration under apps/api/migrations/ (this is where description and default_for_accounts get set).
  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: add an invalidation call in both the updateFeatureFlag mutation and the PATCH /v1/admin/feature-flags/{id} REST handler.

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/feature-status.tscheckFeatureSSR, resolveReason - request-memoised SSR check
apps/web/src/components/feature-disabled-page.tsxReason-aware disabled page