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.
| Category | Use for | Examples |
|---|---|---|
feature:* | Dashboard pages and major feature areas | feature: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-switch | platform:twitch, platform:youtube |
platform:{x}:{type} | Platform sub-flag for connection type | platform:twitch:login, platform:kick:channel |
system:* | User-scope system controls; resolved by AccountCreationService, not FeatureService | system:account_creation |
bot_module:* | Individual bot moderation modules | bot_module:link_protection |
Key rules
feature:*flags gate pages and dashboard features — never usewidget:*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 resolvesystem:*flags throughget_feature_statuses(it filters them out of the global snapshot); usecan_user_create_account().
Resolution Chain
Account-scope flags (feature:*, widget:*, integration:*, platform:*, bot_module:*)
Resolution stops at the first layer that returns enabled = false:
GLOBAL_OFF— the flag is disabled globally (admin kill-switch in thefeature_flagstable, served fromFeatureService's in-processglobal_cache).PLAN_LOCKED— the account's plan does not include this feature (plan_featurestable).ACCOUNT_OVERRIDE— an explicit per-account override has disabled this feature (account_featurestable).- Enabled — none of the above applied;
reasonisnull.
Fail-closed rules to keep in mind:
- A key that is not in the global cache resolves to
falseinis_enabled()(resolve_enabledreturnsfalseforNone), so a flag referenced in code but never seeded is off. - The pricing-card query (
PRICING_FEATURES_SQLinapps/api/src/db/plans.rs) usesCOALESCE(pf.enabled, false)— a flag with noplan_featuresrow shows as not-included for that plan. get_feature_statuses/build_resolved_features, by contrast, fall back to the flag'sdefault_for_accountscolumn when neither a plan row nor an account row exists. Seed the pairedplan_featuresrows 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:
USER_OVERRIDE— theusers.account_creation_overridecolumn is explicitlytrue(allow) orfalse(deny). Adenyoverride here blocks the user regardless of global state; anallowoverride grants access regardless of global state.GLOBAL_OFF— if no per-user override is set and the globalsystem:account_creationflag is disabled.- Enabled — no override, global flag is on.
Cache keys (both TTL 3600 s, TTL_SECS):
| Key | Holds |
|---|---|
lumio:system:account_creation_enabled | The global flag value |
lumio:user:{user_id}:account_creation_override | The 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:
| Protocol | Type | Wire values |
|---|---|---|
| GraphQL | FeatureDisabledReasonGql | GLOBAL_OFF, PLAN_LOCKED, ACCOUNT_OVERRIDE, USER_OVERRIDE |
| REST | FeatureDisabledReasonRest | global_off, plan_locked, account_override, user_override |
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:
| Method | Description |
|---|---|
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_platforms | Platform-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 key | GraphQL resolver | REST route | Enforced (GraphQL / REST) |
|---|---|---|---|
feature:multichat | chat.rs, youtube_memberships.rs | chat.rs | ✅ / ✅ |
feature:automation | automations.rs | automations.rs | ✅ / ✅ |
feature:obs_remote | obs.rs | obs_remote.rs, obs_integration.rs | ✅ / ✅ |
feature:copyright_detection | copyright.rs | copyright.rs | ✅ / ✅ |
feature:streamelements | se_tokens.rs | se_tokens.rs | ✅ / ✅ |
feature:music | spotify.rs | spotify.rs | ✅ / ✅ |
feature:bots | bot_connections.rs | bot_connections.rs | ✅ / ✅ |
feature:bot_modules | bot_modules.rs | bot_modules.rs (umbrella + per-module bot_module:{name}) | ✅ / ✅ |
feature:bot_commands | bot_commands.rs | bot_commands_crud.rs | ✅ / ✅ |
feature:connections | connections.rs | connections.rs | ✅ / ✅ |
feature:rewards | rewards.rs | (no REST surface — GraphQL-only) | ✅ / n/a |
feature:overlay_folders | overlay_folders.rs | overlay_folders.rs | ✅ / ✅ |
feature:overlay_access | overlays.rs | overlays.rs | ✅ / ✅ |
feature:overlay_sharing | overlays.rs | overlays.rs | ✅ / ✅ |
feature:widgets | widgets.rs | widgets.rs | ✅ / ✅ |
feature:sounds | sounds.rs | sounds.rs | ✅ / ✅ |
feature:extensions | extension_store.rs, extension_access.rs | extension_*.rs | ✅ / ✅ |
feature:extension_development | developer_*.rs, bot_module_extensions.rs | developer_*.rs, bot_module_extensions.rs | ✅ / ✅ |
feature:multi_account | accounts.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_bot | bot_connections.rs (authorize_bot) | bot_connections.rs (authorize + shared complete_bot_connection choke) | ✅ / ✅ |
feature:extension_paid | extension_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.
| Function | Description |
|---|---|
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
| Endpoint | Description |
|---|---|
GET /v1/users/me | Includes feature_statuses[] in the response |
GET /v1/accounts/{id}/enabled-features | Enabled flag keys for an account |
GET /v1/accounts/{id}/feature-statuses | Full status list with reason for an account |
GET /v1/features/enabled | Global 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/detail | Admin flag registry |
PATCH /v1/admin/feature-flags/{id} · PUT /v1/admin/feature-flags/{id}/detail | Toggle / edit a flag |
PUT /v1/admin/plans/{id}/features | Set 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
| Hook | Returns |
|---|---|
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 prop | Behaviour when the feature is disabled |
|---|---|
page (default) | Renders <FeatureDisabledPage reason={reason ?? "global_off"} /> |
hide | Renders nothing |
alert | Renders 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 CTAglobal_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
- Register the flag. Two seed paths exist and both are in use:
- a tuple
(category, key, label)inseed_default_feature_flags()(apps/api/src/db/admin.rs), applied at startup; or - an
INSERT INTO feature_flagsin a migration underapps/api/migrations/(this is wheredescriptionanddefault_for_accountsget set).
- a tuple
- Add a migration that inserts
plan_featuresrows for all existing plans. A flag with noplan_featuresrow reads as not-included on the pricing card (COALESCE(pf.enabled, false)). - Add admin translation keys in
apps/admin/messages/{en,de}.jsonunderfeatureFlags.keys."{key}". - If gating a webapp page: add
feature: "{key}"to the nav item inapps/web/src/app/(main)/(app)/shell.tsxand a<FeatureRouteGate feature="{key}">in the route segment'slayout.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.tsfails if you do only the first half. - Enforce it on the API. The route gate is UX, not the security boundary: add
FeatureGuard::new("{key}")to the GraphQL resolvers and the matchingrequire_featurecheck to the REST handlers, so both protocols reject identically. - If the flag has cache implications: add an invalidation call in both the
updateFeatureFlagmutation and thePATCH /v1/admin/feature-flags/{id}REST handler.
Key Files
| File | Purpose |
|---|---|
apps/api/src/services/feature_service.rs | FeatureService — account-scope flag resolution |
apps/api/src/services/account_creation.rs | can_user_create_account() — user-scope system:account_creation resolution |
apps/api/src/db/admin.rs | seed_default_feature_flags() — flag seed data |
apps/api/src/graphql/accounts.rs | accountFeatures GraphQL query |
apps/api/src/graphql/auth.rs | Me.featureStatuses, FeatureStatusGql, FeatureDisabledReasonGql |
apps/api/src/graphql/admin.rs | adminUpdateUserAccountCreationOverride, AccountCreationOverride enum |
apps/api/src/routes/accounts.rs | REST GET /accounts/{id}/enabled-features + /feature-statuses |
apps/api/src/routes/users.rs | REST GET /users/me with feature_statuses field |
apps/api/src/routes/admin.rs | REST PATCH /admin/users/{id} with account_creation_override |
apps/web/src/contexts/feature-context.tsx | FeatureProvider, 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.ts | checkFeatureSSR, resolveReason — request-memoised SSR check |
apps/web/src/components/feature-disabled-page.tsx | Reason-aware disabled page |