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:

FieldPurpose
categoryGroups the flag in the admin UI (sending / receiving / ai / integrations / security / deliverability / hosted)
defaultInitial value for fresh installs
requiresOther flag keys that must be on for this flag to be on
cascadesOffFlags forced off when this one is turned off (also auto-derived from requires)
requiredEnvVarsEnv vars the wizard/UI must collect before activation
dockerProfilesCompose profiles activated when this flag is on
hostedOnlyHidden 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';
HelperUse 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.codeTasksai.agentai + 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.featureFlags is a single document.
  • Setup CLI mirrors state into .owlat-flags.json next 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-setup regenerates docker-compose.override.yml from getActiveProfiles(...). The base compose file declares profiles: [<name>] on each gated service.
  • Page guards: pages declare requiresFeature (a single key or array — all must be on) and optionally requiresAnyFeature (an OR-group, at least one on) in definePageMeta. The dedicated global middleware apps/web/app/middleware/feature.global.ts reads both and redirects to /dashboard?disabled=<flag> when a required flag is off. requiresAnyFeature exists for surfaces reachable via more than one feature — e.g. the Postbox UI under hosted postbox or mail.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 reads instanceSettings.featureFlags, resolves dependencies via the shared resolveFlags, and throws a forbidden error 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

  1. Declare it in packages/shared/src/featureFlags.ts — add to the FeatureFlagKey union and the FEATURE_FLAGS map.
  2. Decide dependencies. Use requires for hard prerequisites; the resolver and the cascade-off helper handle the rest automatically.
  3. (Optional) Declare a Docker profile if the flag needs an extra service. Add profiles: [<name>] to the corresponding service in infra/templates/docker-compose.vps.yml and the root docker-compose.yml.
  4. (Optional) Declare env vars in requiredEnvVars. The wizard will prompt for them; the admin UI will block toggling on until they're set.
  5. Gate the UI. Add requiresFeature: '<key>' to the definePageMeta of every page that belongs to the feature, and conditionally render sidebar entries.
  6. Gate the server. At the top of every Convex public function behind the flag (after the auth check), call await assertFeatureEnabled(ctx, '<key>') from apps/api/convex/lib/featureFlags.ts — it reads the stored flags, resolves dependencies, and throws a forbidden error on its own.
  7. 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

  1. Set its default: false for one release to give operators time to migrate.
  2. 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).
  3. Remove the entry from FEATURE_FLAGS and the FeatureFlagKey union. The resolver gracefully ignores unknown stored keys, so removal is safe — old .owlat-flags.json files won't break.
  4. 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).