Claude-skill-registry feature-flags-implementation
Database-backed feature flag systems in the Orient. Use when implementing feature flags, debugging flag-related issues, or understanding the flag hierarchy. Covers schema design, API patterns, frontend hooks, ID format conversion, and ProtectedRoute integration.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/feature-flags-implementation" ~/.claude/skills/majiayu000-claude-skill-registry-feature-flags-implementation && rm -rf "$T"
skills/data/feature-flags-implementation/SKILL.mdFeature Flags Implementation
Quick Reference
# Check feature flags in database sqlite3 "$SQLITE_DB_PATH" "SELECT id, name, enabled FROM feature_flags ORDER BY sort_order;" # Test feature flag API curl -X GET http://localhost:4098/api/feature-flags \ -H "Authorization: Bearer $(cat ~/.dashboard_token)"
Architecture Overview
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │ Database │────▶│ Dashboard API │────▶│ Frontend │ │ (SQLite) │ │ (Express) │ │ (React) │ │ │ │ │ │ │ │ feature_flags table │ │ /api/feature-flags │ │ useFeatureFlags() │ │ snake_case IDs │ │ JWT Authentication │ │ camelCase IDs │ │ dot.notation │ │ │ │ underscore_notation │ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
Database Schema
Feature Flags Table
Located in
packages/database/src/schema/index.ts:
export const featureFlags = sqliteTable('feature_flags', { id: text('id').primaryKey(), // e.g., 'mini_apps', 'operations.billing' name: text('name').notNull(), // Display name description: text('description'), enabled: integer('enabled', { mode: 'boolean' }).default(true), category: text('category').default('ui'), sortOrder: integer('sort_order').default(0), createdAt: integer('created_at', { mode: 'timestamp' }), updatedAt: integer('updated_at', { mode: 'timestamp' }), });
ID Naming Convention
Database uses snake_case with dot notation for hierarchy:
mini_apps # Root flag mini_apps.create # Child of mini_apps mini_apps.edit_with_ai # Child with multi-word name operations # Root flag operations.billing # Child of operations operations.monitoring # Child of operations operations.monitoring.server_health # Nested child (grandchild)
API Endpoints
Located in
packages/dashboard/src/server/routes/featureFlags.routes.ts:
| Method | Endpoint | Description |
|---|---|---|
| GET | | Get all flags with effective values |
| GET | | Get detailed flag list |
| GET | | Get flat map of flag IDs to booleans |
| PUT | | Update a flag |
| PUT | | Set user override |
| DELETE | | Remove user override |
API Response Format
// GET /api/feature-flags returns: { flags: [ { id: 'mini_apps', name: 'Mini-Apps', description: 'Enable mini-apps feature', enabled: false, category: 'ui', sortOrder: 0, createdAt: '2024-01-15T10:00:00.000Z', updatedAt: '2024-01-15T10:00:00.000Z', userOverride: null, effectiveValue: false, }, // ... more flags ]; }
Frontend Implementation
ID Format Conversion
Database → Frontend:
snake_case.dot → camelCase_underscore
// In useFeatureFlags.ts // API returns: { id: 'mini_apps.edit_with_ai', ... } // Frontend expects: 'miniApps_editWithAi' const camelId = flag.id.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); // 'mini_apps.edit_with_ai' → 'miniApps.editWithAi' const uiId = camelId.replace(/\./g, '_'); // 'miniApps.editWithAi' → 'miniApps_editWithAi'
Frontend → Database:
camelCase_underscore → snake_case.dot
// In FeatureFlagsPage.tsx const toDbId = (uiId: string): string => { const parts = uiId.split('_'); const dbParts = parts.map((part) => part .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, '') ); return dbParts.join('.'); }; // 'miniApps_editWithAi' → 'mini_apps.edit_with_ai'
useFeatureFlags Hook
Located in
packages/dashboard-frontend/src/hooks/useFeatureFlags.ts:
import { getFeatureFlags } from '../api'; // MUST use authenticated API export function useFeatureFlags(): UseFeatureFlagsReturn { const [flags, setFlags] = useState<Record<string, FeatureFlagDefinition>>(PRE_LAUNCH_DEFAULTS); const loadFlags = useCallback(async () => { try { // Use authenticated API function, NOT raw fetch const data = await getFeatureFlags(); // Transform API format to UI format const flagsFromApi: Record<string, FeatureFlagDefinition> = {}; for (const flag of data.flags) { const uiId = convertToUiId(flag.id); flagsFromApi[uiId] = { enabled: flag.effectiveValue ?? flag.enabled ?? false, uiStrategy: 'hide', parentFlag: extractParentFlag(flag.id), }; } setFlags({ ...PRE_LAUNCH_DEFAULTS, ...flagsFromApi }); } catch (err) { // Fallback to defaults on error setFlags(PRE_LAUNCH_DEFAULTS); } }, []); // ... isEnabled, shouldHide, shouldNotify helpers }
PRE_LAUNCH_DEFAULTS
Safe defaults when API is unavailable:
const PRE_LAUNCH_DEFAULTS: Record<string, FeatureFlagDefinition> = { // Disabled by default (new/experimental features) miniApps: { enabled: false, uiStrategy: 'hide', route: '/apps' }, miniApps_create: { enabled: false, parentFlag: 'miniApps', uiStrategy: 'hide' }, // Enabled by default (core features) agentRegistry: { enabled: true, uiStrategy: 'hide', route: '/agents' }, operations: { enabled: true, uiStrategy: 'hide', route: '/operations' }, operations_billing: { enabled: true, parentFlag: 'operations', uiStrategy: 'hide' }, };
FeatureFlagsPage Component
Located in
packages/dashboard-frontend/src/components/Settings/FeatureFlagsPage.tsx:
import { setFeatureFlagOverride } from '../../api'; // MUST use authenticated API const updateFlag = async (flagId: string, updates: Partial<FeatureFlagDefinition>) => { try { const dbId = toDbId(flagId); // Convert UI ID to database ID await setFeatureFlagOverride(dbId, updates.enabled ?? false); await refresh(); // Reload flags } catch (error) { alert('Failed to update feature flag'); } };
ProtectedRoute Integration
Located in
packages/dashboard-frontend/src/components/ProtectedRoute.tsx:
function ProtectedRoute({ flagId, children }: Props) { const { isEnabled, shouldHide, shouldNotify } = useFeatureFlags(); if (shouldHide(flagId)) { return <Navigate to="/" replace />; } if (shouldNotify(flagId)) { return <FeatureDisabledOverlay />; } return children; } // Usage in routes <Route path="/apps" element={ <ProtectedRoute flagId="miniApps"> <AppsPage /> </ProtectedRoute> } />
Database Migrations
Adding a New Feature Flag
Create migration in
data/migrations/:
-- XXXX_add_my_feature_flag.sql INSERT INTO feature_flags (id, name, description, enabled, category, sort_order) VALUES ('my_feature', 'My Feature', 'Description of feature', false, 'ui', 100), ('my_feature.sub_feature', 'Sub Feature', 'A sub-feature', false, 'ui', 101) ON CONFLICT (id) DO NOTHING;
Adding Child Flags
-- Add child flags under existing parent INSERT INTO feature_flags (id, name, description, enabled, category, sort_order) VALUES ('operations.new_child', 'New Child', 'Under operations', true, 'ui', 50) ON CONFLICT (id) DO NOTHING;
Common Issues
Issue: 401 Unauthorized on Flag Updates
Cause: Using raw
fetch() instead of authenticated API functions.
Fix: Import and use functions from
api.ts:
// BAD const response = await fetch(`/api/feature-flags/${id}`, { method: 'PUT', credentials: 'include', body: JSON.stringify({ enabled }), }); // GOOD import { setFeatureFlagOverride } from '../api'; await setFeatureFlagOverride(id, enabled);
Issue: Flag Shows in UI but Not in Database
Cause: Flag is defined in PRE_LAUNCH_DEFAULTS but missing from database.
Fix: Add migration to create the flag in database, or remove from defaults if not needed.
Issue: Child Flag Accessible When Parent Disabled
Cause:
isEnabled doesn't check parent chain, or parent flag ID is wrong.
Fix: Ensure
parentFlag is set correctly in both database parent extraction and defaults:
// The isEnabled function checks parent chain const isEnabled = (flagId: string): boolean => { const flag = flags[flagId]; if (!flag?.enabled) return false; if (flag.parentFlag) { return isEnabled(flag.parentFlag); // Recursive check } return true; };
Issue: ID Conversion Mismatch
Symptoms: Flag exists in DB but not showing in UI, or updates go to wrong flag.
Debug:
console.log('UI ID:', uiId); console.log('DB ID:', toDbId(uiId)); // Verify they match expected values
Checklist for New Feature Flags
- Add flag to database migration
- Run migration:
(auto-runs migrations)./run.sh dev - Add to PRE_LAUNCH_DEFAULTS in
(if needs default)useFeatureFlags.ts - Export from
if queried server-side@orientbot/database - Add ProtectedRoute wrapper if flag controls route access
- Update navigation to conditionally show/hide based on flag
- Test toggle in UI persists to database
- Test parent/child relationships work correctly
Testing Feature Flags
Manual Testing
- Navigate to Settings → Feature Flags
- Toggle a flag
- Verify database updated:
sqlite3 "$SQLITE_DB_PATH" "SELECT id, enabled FROM feature_flags WHERE id = 'mini_apps';" - Verify UI reflects change (navigation item appears/disappears)
Automated Testing
// In a test file import { describe, it, expect, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import { useFeatureFlags } from '../hooks/useFeatureFlags'; describe('useFeatureFlags', () => { it('should return defaults when API fails', async () => { vi.mock('../api', () => ({ getFeatureFlags: vi.fn().mockRejectedValue(new Error('Network error')), })); const { result } = renderHook(() => useFeatureFlags()); // Should use PRE_LAUNCH_DEFAULTS expect(result.current.isEnabled('miniApps')).toBe(false); expect(result.current.isEnabled('agentRegistry')).toBe(true); }); });