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)Vec<String> of enabled flag keys for an account
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: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: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 deliberately unauthenticated unified callback — 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).

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.

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 a webapp page: add feature: "{key}" to the 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 is what closes the route against direct links. apps/web/__tests__/feature-route-gates.test.ts fails if you do only the first half.
  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