From 868a305539ef0c446dc8304dbf8763f5424f9c42 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 19:21:15 +0200 Subject: [PATCH] feat(flags): real feature-flag system with bulk eval + operator UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real backend for the flags page (was pure mock). Built so it's ready for the first risky rollout (likely the Stalwart JMAP client or the Stripe billing engine). services/platform-api: - Flag schema (key, description, state, pct, scope.{plans, tenantSlugs, partnerSlugs, environments}, embedded history capped at 20) - FlagsService with CRUD + evaluateAll(tenantSlug) → { key: bool } Eval algorithm: off → false; on → true targeted → require non-empty scope (empty allowlist means "nobody"), then match every non-empty axis rollout → match scope, then sha256(`${tenantId}:${key}`) % 100 < pct Hash-based rollout is deterministic: bumping pct only flips the new slice. Pure helpers (matchesScope, hasAnyScope, inRolloutBucket) are exported for future unit tests. - FlagsController exposes GET /flags, GET /flags/:key, POST /flags/evaluate (JwtAuthGuard); POST/PATCH/DELETE require OperatorGuard. History entries capture the actor's email. - SeedService idempotently creates 10 flag keys mapping to real Dezky concerns (jmap_native_v2, gdpr_export_v2, new_billing_engine, etc.). $setOnInsert so operator edits survive restarts. apps/operator: - 6 proxies: /api/flags index get/post, [key] get/patch/delete, evaluate post - types/flag.ts with the shape that mirrors the backend - pages/flags.vue: useFetch real list, row click opens FlagDetail, "New flag" opens NewFlagModal, scope summary column shows targeting at a glance - FlagDetail.vue: side panel with segmented state, rollout slider with live "~N of M tenants" preview from /api/tenants, plan/tenant/env chip pickers, dirty-tracked Save, instant Kill-switch (PATCH state=off+pct=0), embedded change history - NewFlagModal.vue: minimal create form (key + description). Everything else is configured in the detail panel afterward. - CommandPalette: feature-flag rows now come from /api/flags instead of the dropped fixture, so newly-created flags are searchable immediately - data/fixtures.ts: drop FLAGS / FeatureFlag exports (replaced by the real backend) Smoke-tested end-to-end: list renders 10 seed flags, opening gdpr_export_v2 and flipping to rollout 25% then saving persists + adds a history entry, kill-switch sets state=off in one click, /api/flags/evaluate returns the correct booleans for the seeded tenant, same tenant gets the same answer on consecutive evals (determinism), and creating + deleting a flag through the UI roundtrips correctly. --- apps/operator/components/CommandPalette.vue | 11 +- apps/operator/components/FlagDetail.vue | 432 ++++++++++++++++++ apps/operator/components/NewFlagModal.vue | 176 +++++++ apps/operator/data/fixtures.ts | 24 +- apps/operator/pages/flags.vue | 109 ++++- .../server/api/flags/[key]/index.delete.ts | 7 + .../server/api/flags/[key]/index.get.ts | 6 + .../server/api/flags/[key]/index.patch.ts | 7 + .../server/api/flags/evaluate.post.ts | 6 + apps/operator/server/api/flags/index.get.ts | 3 + apps/operator/server/api/flags/index.post.ts | 6 + apps/operator/types/flag.ts | 29 ++ services/platform-api/src/app.module.ts | 2 + .../src/flags/dto/create-flag.dto.ts | 49 ++ .../src/flags/dto/evaluate.dto.ts | 9 + .../src/flags/dto/update-flag.dto.ts | 48 ++ .../src/flags/flags.controller.ts | 66 +++ .../platform-api/src/flags/flags.module.ts | 21 + .../platform-api/src/flags/flags.service.ts | 214 +++++++++ .../platform-api/src/schemas/flag.schema.ts | 76 +++ services/platform-api/src/seed/seed.module.ts | 2 + .../platform-api/src/seed/seed.service.ts | 71 +++ 22 files changed, 1334 insertions(+), 40 deletions(-) create mode 100644 apps/operator/components/FlagDetail.vue create mode 100644 apps/operator/components/NewFlagModal.vue create mode 100644 apps/operator/server/api/flags/[key]/index.delete.ts create mode 100644 apps/operator/server/api/flags/[key]/index.get.ts create mode 100644 apps/operator/server/api/flags/[key]/index.patch.ts create mode 100644 apps/operator/server/api/flags/evaluate.post.ts create mode 100644 apps/operator/server/api/flags/index.get.ts create mode 100644 apps/operator/server/api/flags/index.post.ts create mode 100644 apps/operator/types/flag.ts create mode 100644 services/platform-api/src/flags/dto/create-flag.dto.ts create mode 100644 services/platform-api/src/flags/dto/evaluate.dto.ts create mode 100644 services/platform-api/src/flags/dto/update-flag.dto.ts create mode 100644 services/platform-api/src/flags/flags.controller.ts create mode 100644 services/platform-api/src/flags/flags.module.ts create mode 100644 services/platform-api/src/flags/flags.service.ts create mode 100644 services/platform-api/src/schemas/flag.schema.ts diff --git a/apps/operator/components/CommandPalette.vue b/apps/operator/components/CommandPalette.vue index c777043..79ffe39 100644 --- a/apps/operator/components/CommandPalette.vue +++ b/apps/operator/components/CommandPalette.vue @@ -2,7 +2,8 @@ import type { IconName } from './UiIcon.vue' import type { Tenant } from '~/types/tenant' import type { Partner } from '~/types/partner' -import { FLAGS, INCIDENT } from '~/data/fixtures' +import type { Flag } from '~/types/flag' +import { INCIDENT } from '~/data/fixtures' // Each row in the palette. `action` decides what happens on Enter / click. // We try `to` first (a navigateTo) since most rows are navigation; `run` is @@ -31,6 +32,7 @@ const inputRef = ref(null) // palette is client-only). const { data: tenants, refresh: rT } = useLazyFetch('/api/tenants', { default: () => [], server: false }) const { data: partners, refresh: rP } = useLazyFetch('/api/partners', { default: () => [], server: false }) +const { data: flags, refresh: rF } = useLazyFetch('/api/flags', { default: () => [], server: false }) const NAV: { id: string; label: string; icon: IconName; to: string }[] = [ { id: 'n-overview', label: 'Overview', icon: 'home', to: '/' }, @@ -72,12 +74,13 @@ function partnerRows(): Row[] { } function flagRows(): Row[] { - return FLAGS.slice(0, 6).map((f) => ({ + return (flags.value ?? []).slice(0, 8).map((f) => ({ id: `f-${f.key}`, groupLabel: 'Feature flags', title: f.key, - subtitle: f.state === 'rollout' ? `rollout · ${f.pct}% · ${f.scope}` : `${f.state} · ${f.scope}`, + subtitle: f.state === 'rollout' ? `rollout · ${f.pct}%` : f.state, icon: 'plug', + badge: f.state, to: '/flags', })) } @@ -154,7 +157,7 @@ watch(isOpen, async (v) => { if (!v) return query.value = '' cursor.value = 0 - await Promise.all([rT(), rP()]) + await Promise.all([rT(), rP(), rF()]) await nextTick() inputRef.value?.focus() }) diff --git a/apps/operator/components/FlagDetail.vue b/apps/operator/components/FlagDetail.vue new file mode 100644 index 0000000..a9878a2 --- /dev/null +++ b/apps/operator/components/FlagDetail.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/apps/operator/components/NewFlagModal.vue b/apps/operator/components/NewFlagModal.vue new file mode 100644 index 0000000..4e44da1 --- /dev/null +++ b/apps/operator/components/NewFlagModal.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/apps/operator/data/fixtures.ts b/apps/operator/data/fixtures.ts index 18baace..6c242bf 100644 --- a/apps/operator/data/fixtures.ts +++ b/apps/operator/data/fixtures.ts @@ -59,27 +59,9 @@ export const INCIDENT: ActiveIncident = { ], } -export type FlagState = 'on' | 'off' | 'rollout' | 'targeted' -export interface FeatureFlag { - key: string - state: FlagState - pct: number - scope: string - modified: string -} - -export const FLAGS: FeatureFlag[] = [ - { key: 'jmap_native_v2', state: 'rollout', pct: 50, scope: 'Business+ · 38 tenants', modified: 'Anne · 2 d ago' }, - { key: 'oci_versioning', state: 'on', pct: 100, scope: 'all tenants', modified: 'Anne · 14 d ago' }, - { key: 'jitsi_recording_e2ee', state: 'targeted', pct: 0, scope: 'allowlist · 3 tenants', modified: 'Mikkel · 5 d ago' }, - { key: 'new_billing_engine', state: 'rollout', pct: 25, scope: '12 tenants', modified: 'Anne · today' }, - { key: 'gdpr_export_v2', state: 'off', pct: 0, scope: 'kill-switch', modified: 'Sofie · 21 d ago' }, - { key: 'whitelabel_cssprops', state: 'on', pct: 100, scope: 'partners', modified: 'Anne · 1 mo ago' }, - { key: 'audit_log_streaming', state: 'on', pct: 100, scope: 'Enterprise', modified: 'Mikkel · 8 d ago' }, - { key: 'zulip_topic_threading', state: 'rollout', pct: 75, scope: '63 tenants', modified: 'Sofie · 3 d ago' }, - { key: 'tos_2026_acceptance', state: 'on', pct: 100, scope: 'all tenants', modified: 'Anne · 6 d ago' }, - { key: 'beta_ai_summaries', state: 'off', pct: 0, scope: 'killed', modified: 'Anne · 1 mo ago' }, -] +// Feature flags moved to a real backend at /api/flags + see types/flag.ts. +// The seed in services/platform-api/src/seed/seed.service.ts creates the +// same 10 flags this fixture used to contain. export type AuditTone = 'info' | 'warn' | 'bad' export interface AuditEntry { diff --git a/apps/operator/pages/flags.vue b/apps/operator/pages/flags.vue index e201954..81109ca 100644 --- a/apps/operator/pages/flags.vue +++ b/apps/operator/pages/flags.vue @@ -1,18 +1,63 @@