868a305539
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.
50 lines
1.1 KiB
TypeScript
50 lines
1.1 KiB
TypeScript
import {
|
|
ArrayUnique,
|
|
IsArray,
|
|
IsEnum,
|
|
IsInt,
|
|
IsOptional,
|
|
IsString,
|
|
Matches,
|
|
Max,
|
|
MaxLength,
|
|
Min,
|
|
ValidateNested,
|
|
} from 'class-validator'
|
|
import { Type } from 'class-transformer'
|
|
|
|
class FlagScopeDto {
|
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
|
plans?: string[]
|
|
|
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
|
tenantSlugs?: string[]
|
|
|
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
|
partnerSlugs?: string[]
|
|
|
|
@IsOptional() @IsArray() @ArrayUnique()
|
|
@IsEnum(['prod', 'staging', 'dev'], { each: true })
|
|
environments?: ('prod' | 'staging' | 'dev')[]
|
|
}
|
|
|
|
export class CreateFlagDto {
|
|
@IsString()
|
|
@Matches(/^[a-z][a-z0-9_]{1,62}[a-z0-9]$/, {
|
|
message: 'key must be lowercase snake_case, 3-64 chars, starts with a letter',
|
|
})
|
|
key!: string
|
|
|
|
@IsOptional() @IsString() @MaxLength(280)
|
|
description?: string
|
|
|
|
@IsOptional() @IsEnum(['off', 'targeted', 'rollout', 'on'])
|
|
state?: 'off' | 'targeted' | 'rollout' | 'on'
|
|
|
|
@IsOptional() @IsInt() @Min(0) @Max(100)
|
|
pct?: number
|
|
|
|
@IsOptional() @ValidateNested() @Type(() => FlagScopeDto)
|
|
scope?: FlagScopeDto
|
|
}
|