Feature-Flags — Entwicklerreferenz

Wie das Feature-Flag-System von Owlat funktioniert: Single Source of Truth, Auflösung von Abhängigkeiten, Zuordnung zu Docker-Profilen und wie Sie ein neues Flag hinzufügen.

Feature-Flags — Entwicklerreferenz

Die betreiberseitige Übersicht finden Sie unter Feature-Flags in der Produktanleitung. Diese Seite richtet sich an Entwickler, die Flags hinzufügen, entfernen oder erweitern.

Single Source of Truth

Jede umschaltbare Oberfläche ist in packages/shared/src/featureFlags.ts deklariert. Das Setup-CLI, die Admin-Oberfläche, das Convex-Backend und die Nuxt-Middleware importieren alle aus dieser einen Datei — es gibt keine zweite Registry, die synchron gehalten werden müsste.

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)
};

Jede Definition kann Folgendes deklarieren:

FeldZweck
categoryGruppiert das Flag in der Admin-Oberfläche (sending / receiving / ai / integrations / security / deliverability / hosted)
defaultAnfangswert für frische Installationen
requiresAndere Flag-Schlüssel, die aktiviert sein müssen, damit dieses Flag aktiv sein kann
cascadesOffFlags, die zwangsweise deaktiviert werden, wenn dieses deaktiviert wird (wird außerdem automatisch aus requires abgeleitet)
requiredEnvVarsUmgebungsvariablen, die Assistent/Oberfläche vor der Aktivierung erfassen müssen
dockerProfilesCompose-Profile, die aktiviert werden, wenn dieses Flag aktiv ist
hostedOnlyIm Self-Host-Assistenten ausgeblendet (Control-Plane-Flags)

Laufzeit-Helfer

import {
  FEATURE_FLAGS,
  resolveFlags,
  isFlagEnabled,
  applyToggle,
  getActiveProfiles,
  getRequiredEnvVars,
  getFlagsByCategory,
  FEATURE_PACKS,
  applyPackToggle,
  isPackEnabled,
} from '@owlat/shared/featureFlags';
HelferEinsatz
resolveFlags(stored)Aktuellen Zustand lesen — wendet requires-Kaskaden an und liefert den effektiven Zustand
isFlagEnabled(stored, key)Bequemer Zugriff auf resolveFlags(...)[key]
applyToggle(stored, key, value)Benutzer hat einen Schalter betätigt — liefert { next, cascaded }, damit die Oberfläche zeigen kann, was sich sonst noch geändert hat
getActiveProfiles(stored)Die zu aktivierenden Docker-Compose-Profile berechnen
getRequiredEnvVars(stored)Welche Umgebungsvariablen bei der aktiven Flag-Menge abgefragt werden müssen
getFlagsByCategory()Die Admin-Oberfläche nach Kategorien gruppiert rendern
applyPackToggle(stored, packKey, value)Alle Flags eines Packs auf einmal umschalten (Kaskaden greifen weiterhin)

Die Auflösung erfolgt iterativ bis zum Fixpunkt mit einer Sicherheitsgrenze von 10 Iterationen — lange Abhängigkeitsketten (inbox.codeTasksai.agentai + inbox) werden in einem einzigen Aufruf aufgelöst.

Speicherung & Durchsetzung

                     ┌─────────────────────────────────────────────────────┐
                     │  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 ist zur Laufzeit die Source of Truth. instanceSettings.featureFlags ist ein einzelnes Dokument.
  • Das Setup-CLI spiegelt den Zustand in .owlat-flags.json neben der Compose-Datei, damit der Betreiber Flags ändern kann, bevor der Stack läuft. Beim Start gleicht Convex die Datei mit der Datenbank ab.
  • Docker-Compose-Profile werden indirekt aktiviert: owlat-setup generiert docker-compose.override.yml aus getActiveProfiles(...) neu. Die Basis-Compose-Datei deklariert profiles: [<name>] an jedem gegateten Service.
  • Page Guards: Seiten deklarieren in definePageMeta ein requiresFeature (ein einzelner Schlüssel oder ein Array — alle müssen aktiv sein) und optional ein requiresAnyFeature (eine ODER-Gruppe, mindestens eines aktiv). Die dedizierte globale Middleware apps/web/app/middleware/feature.global.ts liest beides und leitet auf /dashboard?disabled=<flag> um, wenn ein erforderliches Flag aus ist. requiresAnyFeature existiert für Oberflächen, die über mehr als eine Funktion erreichbar sind — z. B. die Postbox-Oberfläche unter dem gehosteten postbox oder mail.external.
  • Serverseitige Guards: Öffentliche Convex-Funktionen in gegateten Modulen rufen früh await assertFeatureEnabled(ctx, '<flag>') auf (apps/api/convex/lib/featureFlags.ts), damit ein veralteter Client die Gates der Oberfläche nicht umgehen kann. Der Helfer liest instanceSettings.featureFlags, löst Abhängigkeiten über das gemeinsame resolveFlags auf und wirft im deaktivierten Fall einen forbidden-Fehler.

Feature-Packs

Packs sind reiner Oberflächen-Zucker — sie führen keinen neuen Zustand ein, sondern lesen und schreiben denselben FeatureFlagState über applyPackToggle, das pro Flag applyToggle aufruft, damit sich Kaskaden korrekt verhalten.

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'] },
};

Ein neues Flag hinzufügen

  1. Deklarieren Sie es in packages/shared/src/featureFlags.ts — ergänzen Sie die Union FeatureFlagKey und die Map FEATURE_FLAGS.
  2. Entscheiden Sie über Abhängigkeiten. Verwenden Sie requires für harte Voraussetzungen; der Resolver und der Cascade-Off-Helfer erledigen den Rest automatisch.
  3. (Optional) Deklarieren Sie ein Docker-Profil, falls das Flag einen zusätzlichen Service benötigt. Fügen Sie profiles: [<name>] beim entsprechenden Service in infra/templates/docker-compose.vps.yml und in der docker-compose.yml im Wurzelverzeichnis hinzu.
  4. (Optional) Deklarieren Sie Umgebungsvariablen in requiredEnvVars. Der Assistent fragt sie ab; die Admin-Oberfläche verhindert das Einschalten, bis sie gesetzt sind.
  5. Gaten Sie die Oberfläche. Fügen Sie requiresFeature: '<key>' zum definePageMeta jeder Seite hinzu, die zur Funktion gehört, und rendern Sie Sidebar-Einträge bedingt.
  6. Gaten Sie den Server. Rufen Sie am Anfang jeder öffentlichen Convex-Funktion hinter dem Flag (nach der Auth-Prüfung) await assertFeatureEnabled(ctx, '<key>') aus apps/api/convex/lib/featureFlags.ts auf — die Funktion liest die gespeicherten Flags, löst Abhängigkeiten auf und wirft selbstständig einen forbidden-Fehler.
  7. Aktualisieren Sie die Dokumentation: Nehmen Sie das Flag in die Tabelle unter Feature-Flags in der Produktanleitung auf und referenzieren Sie es (falls für Benutzer sichtbar) aus der Feature-Tabelle in der README.

Wenn Sie eine neue Kategorie einführen, ergänzen Sie sie außerdem in der Union FeatureCategory sowie mit einem Label im Kategorie-Sortierer der Admin-Oberfläche.

Ein Flag entfernen

  1. Setzen Sie es für ein Release auf default: false, um Betreibern Zeit für die Migration zu geben.
  2. Entfernen Sie die Page-Meta-Gates und bedingten Renderings und lassen Sie den zugrunde liegenden Codepfad entweder dauerhaft aktiv (wenn Sie ihn behalten) oder löschen Sie ihn vollständig (wenn Sie die Funktion ausmustern).
  3. Entfernen Sie den Eintrag aus FEATURE_FLAGS und der Union FeatureFlagKey. Der Resolver ignoriert unbekannte gespeicherte Schlüssel anstandslos, das Entfernen ist also gefahrlos — alte .owlat-flags.json-Dateien gehen dabei nicht kaputt.
  4. Entfernen Sie das Docker-Profil aus den Compose-Dateien.

Nur gehostete Flags

billing.stripe, multiTenancy und tier.autoProvision sind mit hostedOnly: true markiert. getDefaultFlags() und getFlagsByCategory() überspringen sie direkt, sofern nicht { hosted: true } übergeben wird, sodass das Self-Host-Setup-CLI und die Admin-Oberfläche sie nie zu Gesicht bekommen. resolveFlags() filtert sie nicht selbst — es merged lediglich über die (gefilterte) Standard-Baseline, sodass ein hostedOnly-Schlüssel, der explizit in stored vorhanden ist, dennoch durchaufgelöst würde. Sie werden extern von der gehosteten Control Plane (in einem separaten privaten Repository) gesetzt und von apps/api für die Sichtbarkeit der Abrechnungsoberfläche gelesen.

Tests

Unit-Tests für den Resolver liegen in packages/shared/src/__tests__/featureFlags.test.ts. Wenn Sie ein Flag mit nicht trivialen Abhängigkeiten hinzufügen, ergänzen Sie dort einen Fall, der die Kaskade abdeckt. Die eigenen Tests des Setup-CLI liegen in apps/setup-cli/src/lib/__tests__/ (passwordHash.test.ts, convexDeploy.test.ts).