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 call FeatureService for system:* flags; use AccountCreationService.

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 feature_flags table).
  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_feature_overrides table).
  4. Enabled — none of the above applied; reason is null.

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

  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 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:

MethodDescription
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):

FunctionDescription
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

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
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 propBehavior when feature is disabled
hide (default)Children are not rendered at all
pageRenders a <FeatureDisabledPage> with context-appropriate messaging
alertRenders 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 CTA
  • global_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

  1. Add a seed entry to seed_default_feature_flags() in apps/api/src/db/admin.rs (tuple (category, key, label)).
  2. Add a migration in apps/api/migrations/ that inserts plan_features rows for all existing plans (default enabled=true unless pro-gated).
  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/(app)/shell.tsx and a server-side gate in the page's page.tsx.
  5. If the flag has cache implications: add an invalidation call in both the updateFeatureFlag mutation and the PATCH /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.rsAccountCreationService — 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
apps/web/src/components/feature-gate.tsx<FeatureGate> with mode prop
apps/web/src/components/feature-disabled-page.tsxReason-aware disabled page