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 callFeatureServiceforsystem:*flags; useAccountCreationService.
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 infeature_flagstable).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_feature_overridestable).- Enabled — none of the above applied;
reasonisnull.
The FeatureService::get_feature_statuses(account_id, plan_id) method returns a Vec<FeatureStatus> covering all flags in these categories.
User-scope flags (system:account_creation)
Resolved by AccountCreationService (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 key: lumio:account_creation:global (global flag, TTL 300 s) + lumio:account_creation:user:{user_id} (per-user override, TTL 300 s). Both entries are invalidated when the flag or override changes.
FeatureStatus struct
pub struct FeatureStatus {
pub key: String,
pub enabled: bool,
pub reason: Option<FeatureDisabledReason>,
}
pub enum FeatureDisabledReason {
GlobalOff,
PlanLocked,
AccountOverride,
UserOverride,
}
In GraphQL this is FeatureStatus / FeatureDisabledReason (PascalCase enum values). In REST the reason is serialized as a snake_case string: "global_off", "plan_locked", "account_override", "user_override".
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 |
|---|---|
get_enabled_features(account_id) | Returns Vec<String> of enabled flag keys for an account |
get_feature_statuses(account_id, plan_id) | Returns Vec<FeatureStatus> with reason for every flag |
compute_me_feature_statuses(user_id, account_id) | Merges account-scope statuses with user-scope system:account_creation status |
is_feature_enabled(account_id, key) | Boolean check for a single flag key |
AccountCreationService (user-scope)
Located at apps/api/src/services/account_creation.rs.
Key functions (free functions, not methods):
| Function | Description |
|---|---|
can_user_create_account(db, redis, user_id) | Returns AccountCreationDecision { allowed, reason } |
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!): Boolean!
Admin mutation to set a per-user override. 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 |
PATCH /v1/admin/users/{id} | Set account_creation_override (body field) |
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. Used for simple boolean checks.
useFeatureStatus(key: string): FeatureStatus | undefined
Returns the full FeatureStatus object (key, enabled, reason) for a flag key. Use this when you need to show a reason-aware disabled state (e.g., upgrade prompts for plan_locked).
<FeatureGate key={string} mode={...}>
Declarative gate component (components/feature-gate.tsx). Renders children only when the feature is enabled.
mode prop | Behavior when feature is disabled |
|---|---|
hide (default) | Children are not rendered at all |
page | Renders a <FeatureDisabledPage> with context-appropriate messaging |
alert | Renders an inline alert explaining the feature is disabled |
<FeatureGate featureKey="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 SSR page gate
In page.tsx, gate the page server-side to avoid rendering on the client:
// apps/web/src/app/(app)/dashboard/bots/page.tsx
import { serverGqlSSR } from "@/lib/server-gql";
import { FeatureDisabledPage } from "@/components/feature-disabled-page";
export default async function BotsPage() {
const data = await serverGqlSSR(ACCOUNT_FEATURES_QUERY);
const bots = data.accountFeatures.featureStatuses.find(
(s) => s.key === "feature:bots"
);
if (!bots?.enabled) {
return <FeatureDisabledPage reason={bots?.reason} />;
}
return <BotsContent />;
}
serverGqlSSR reads the JWT from cookies and calls GraphQL directly — never use shared/api REST functions in SSR pages.
Adding a New Feature Flag — Checklist
- Add a seed entry to
seed_default_feature_flags()inapps/api/src/db/admin.rs(tuple(category, key, label)). - Add a migration in
apps/api/migrations/that insertsplan_featuresrows for all existing plans (defaultenabled=trueunless pro-gated). - 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/(app)/shell.tsxand a server-side gate in the page'spage.tsx. - If the flag has cache implications: add an invalidation call in both the
updateFeatureFlagmutation and thePATCH /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 | AccountCreationService — 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 |
apps/web/src/components/feature-gate.tsx | <FeatureGate> with mode prop |
apps/web/src/components/feature-disabled-page.tsx | Reason-aware disabled page |