Feature flags — developer reference
How the Owlat feature flag system works: single source of truth, dependency resolution, docker profile mapping, and how to add a new flag.
Feature flags — developer reference
For the operator-facing overview see Feature flags in the product guide. This page is for developers adding, removing, or extending flags.
Single source of truth
Every toggleable surface is declared in packages/shared/src/featureFlags.ts. The setup CLI, the admin UI, the Convex backend, and Nuxt middleware all import from that one file — there is no second registry to keep in sync.
export const FEATURE_FLAGS: Record<FeatureFlagKey, FeatureFlagDefinition> = {
postbox: {
key: 'postbox',
category: 'receiving',
label: 'Personal mail (Postbox)',
description: 'Per-user mailboxes with webmail UI, IMAP/SMTP for native clients, and MX-based delivery (Gmail-equivalent).',
default: false,
dockerProfiles: ['personal-mail'],
},
// … 30 more flags (31 total in FEATURE_FLAGS)
};
Each definition can declare:
| Field | Purpose |
|---|---|
category | Groups the flag in the admin UI (sending / receiving / ai / integrations / security / deliverability / hosted) |
default | Initial value for fresh installs |
requires | Other flag keys that must be on for this flag to be on |
cascadesOff | Flags forced off when this one is turned off (also auto-derived from requires) |
requiredEnvVars | Env vars the wizard/UI must collect before activation |
dockerProfiles | Compose profiles activated when this flag is on |
hostedOnly | Hidden from the self-host wizard (control-plane flags) |
Runtime helpers
import {
FEATURE_FLAGS,
resolveFlags,
isFlagEnabled,
applyToggle,
getActiveProfiles,
getRequiredEnvVars,
getFlagsByCategory,
FEATURE_PACKS,
applyPackToggle,
isPackEnabled,
} from '@owlat/shared/featureFlags';
| Helper | Use when |
|---|---|
resolveFlags(stored) | Reading current state — applies requires cascades and returns the effective state |
isFlagEnabled(stored, key) | Convenience around resolveFlags(...)[key] |
applyToggle(stored, key, value) | User clicked a switch — returns { next, cascaded } so the UI can show what else changed |
getActiveProfiles(stored) | Compute the Docker Compose profiles to activate |
getRequiredEnvVars(stored) | What env vars to prompt for given the active flag set |
getFlagsByCategory() | Render the admin UI grouped by category |
applyPackToggle(stored, packKey, value) | Toggle every flag in a pack at once (cascades still apply) |
Resolution is iterative to a fixed point with a 10-iteration safety cap — long dependency chains (inbox.codeTasks → ai.agent → ai + inbox) resolve in a single call.
Storage & enforcement
┌─────────────────────────────────────────────────────┐
│ packages/shared/src/featureFlags.ts (registry) │
│ resolveFlags, applyToggle, getActiveProfiles, … │
└────┬───────────────┬───────────────┬────────────────┘
│ │ │
┌───────────▼────────┐ ┌──▼─────────────────┐ ┌─▼─────────────────────┐
│ apps/setup-cli │ │ apps/api │ │ apps/web │
│ commands/feature │ │ organizations/ │ │ middleware/ │
│ commands/pack │ │ featureFlags.ts │ │ feature.global.ts │
│ lib/override │ │ lib/featureFlags.ts│ │ pages w/ requires- │
│ lib/flagState │ │ + queries │ │ Feature: '<flag>' │
└───────┬────────────┘ └────────┬───────────┘ └───┬───────────────────┘
│ │ │
writes docker-compose │ route gate →
.override.yml + .owlat- │ /dashboard?disabled=<flag>
flags.json │
source of truth:
Convex table
instanceSettings.featureFlags
- Convex is the source of truth at runtime.
instanceSettings.featureFlagsis a single document. - Setup CLI mirrors state into
.owlat-flags.jsonnext to the compose file so the operator can change flags before the stack is up. On boot, Convex reconciles the file with the DB. - Docker Compose profiles are activated indirectly:
owlat-setupregeneratesdocker-compose.override.ymlfromgetActiveProfiles(...). The base compose file declaresprofiles: [<name>]on each gated service. - Page guards: pages declare
requiresFeature(a single key or array — all must be on) and optionallyrequiresAnyFeature(an OR-group, at least one on) indefinePageMeta. The dedicated global middlewareapps/web/app/middleware/feature.global.tsreads both and redirects to/dashboard?disabled=<flag>when a required flag is off.requiresAnyFeatureexists for surfaces reachable via more than one feature — e.g. the Postbox UI under hostedpostboxormail.external. - Server-side guards: Convex public functions in gated modules call
await assertFeatureEnabled(ctx, '<flag>')(apps/api/convex/lib/featureFlags.ts) early, so a stale client can't bypass UI gates. The helper readsinstanceSettings.featureFlags, resolves dependencies via the sharedresolveFlags, and throws aforbiddenerror when off.
Feature packs
Packs are pure UI sugar — they don't introduce new state, they read and write the same FeatureFlagState via applyPackToggle, which calls applyToggle per flag so cascades behave correctly.
export const FEATURE_PACKS: Record<FeaturePackKey, FeaturePack> = {
marketing: { flags: ['campaigns', 'automations', 'transactional'], /* … */ },
emailClient: { flags: ['inbox', 'chat', 'postbox'], /* … */ },
ai: { flags: ['ai', 'ai.agent', 'ai.autonomy', 'ai.knowledge', 'ai.assistant', 'ai.visualizations'] },
};
Adding a new flag
- Declare it in
packages/shared/src/featureFlags.ts— add to theFeatureFlagKeyunion and theFEATURE_FLAGSmap. - Decide dependencies. Use
requiresfor hard prerequisites; the resolver and the cascade-off helper handle the rest automatically. - (Optional) Declare a Docker profile if the flag needs an extra service. Add
profiles: [<name>]to the corresponding service ininfra/templates/docker-compose.vps.ymland the rootdocker-compose.yml. - (Optional) Declare env vars in
requiredEnvVars. The wizard will prompt for them; the admin UI will block toggling on until they're set. - Gate the UI. Add
requiresFeature: '<key>'to thedefinePageMetaof every page that belongs to the feature, and conditionally render sidebar entries. - Gate the server. At the top of every Convex public function behind the flag (after the auth check), call
await assertFeatureEnabled(ctx, '<key>')fromapps/api/convex/lib/featureFlags.ts— it reads the stored flags, resolves dependencies, and throws aforbiddenerror on its own. - Update docs: add the flag to the table on Feature flags in the product guide and (if user-visible) reference it from the README feature table.
If you introduce a new category, also add the category to the FeatureCategory union and a label in the admin UI's category-sorter.
Removing a flag
- Set its
default: falsefor one release to give operators time to migrate. - Drop the page-meta gates and conditional renders, leaving the underlying code path either always-on (if you're keeping it) or fully deleted (if you're retiring the feature).
- Remove the entry from
FEATURE_FLAGSand theFeatureFlagKeyunion. The resolver gracefully ignores unknown stored keys, so removal is safe — old.owlat-flags.jsonfiles won't break. - Drop the docker profile from the compose files.
Hosted-only flags
billing.stripe, multiTenancy, and tier.autoProvision are marked hostedOnly: true. getDefaultFlags() and getFlagsByCategory() skip them directly unless { hosted: true } is passed, so the self-host setup CLI and admin UI never see them. resolveFlags() doesn't filter them itself — it just merges over the (filtered) default baseline, so a hostedOnly key explicitly present in stored would still resolve through. They're set externally by the hosted control plane (in a separate private repo) and read by apps/api for billing UI visibility.
Testing
Unit tests for the resolver live in packages/shared/src/__tests__/featureFlags.test.ts. When adding a flag with non-trivial dependencies, add a case there exercising the cascade. The setup CLI's own tests live in apps/setup-cli/src/lib/__tests__/ (passwordHash.test.ts, convexDeploy.test.ts).