feat(flags): real feature-flag system with bulk eval + operator UI
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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
@@ -11,6 +12,7 @@ import { SeedService } from './seed.service.js'
|
||||
{ name: Tenant.name, schema: TenantSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: Flag.name, schema: FlagSchema },
|
||||
]),
|
||||
],
|
||||
providers: [SeedService],
|
||||
|
||||
@@ -2,10 +2,41 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js'
|
||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
|
||||
interface SeedFlag {
|
||||
key: string
|
||||
description: string
|
||||
state: FlagState
|
||||
pct?: number
|
||||
scope?: {
|
||||
plans?: string[]
|
||||
tenantSlugs?: string[]
|
||||
partnerSlugs?: string[]
|
||||
environments?: ('prod' | 'staging' | 'dev')[]
|
||||
}
|
||||
}
|
||||
|
||||
// Initial set of flags. Keys + states map to real Dezky concerns documented
|
||||
// in the operator design — flags here become real switches the operator
|
||||
// can flip once the gated features land. Seeded with $setOnInsert so an
|
||||
// operator editing them later doesn't get clobbered on restart.
|
||||
const FLAG_SEEDS: SeedFlag[] = [
|
||||
{ key: 'jmap_native_v2', state: 'off', description: 'Native JMAP client (replacing the HTTP-stub bridge)', scope: { plans: ['pro', 'enterprise'] } },
|
||||
{ key: 'oci_versioning', state: 'on', description: 'OCIS file versioning enabled' },
|
||||
{ key: 'jitsi_recording_e2ee', state: 'targeted', description: 'E2EE recording in Jitsi (pilot)' },
|
||||
{ key: 'new_billing_engine', state: 'off', description: 'Stripe-backed billing engine — replacing manual invoicing' },
|
||||
{ key: 'gdpr_export_v2', state: 'off', description: 'Self-serve GDPR data export — kept off until reviewed' },
|
||||
{ key: 'whitelabel_cssprops', state: 'on', description: 'Per-partner whitelabel CSS variables' },
|
||||
{ key: 'audit_log_streaming', state: 'off', description: 'Real-time audit log streaming to the operator UI', scope: { plans: ['enterprise'] } },
|
||||
{ key: 'zulip_topic_threading', state: 'off', description: 'Zulip topic threading UI (post deploy)' },
|
||||
{ key: 'tos_2026_acceptance', state: 'on', description: 'Force users to accept the v2026 Terms of Service' },
|
||||
{ key: 'beta_ai_summaries', state: 'off', description: 'AI summaries for inbox + files — killed pending review' },
|
||||
]
|
||||
|
||||
// Idempotent seed: a default 'dezky' tenant + the akadmin user, so the portal can
|
||||
// query non-empty results immediately. Real user records are bootstrapped via
|
||||
// UsersController.me() on each user's first authenticated request.
|
||||
@@ -17,6 +48,7 @@ export class SeedService implements OnApplicationBootstrap {
|
||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
||||
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -60,5 +92,44 @@ export class SeedService implements OnApplicationBootstrap {
|
||||
this.logger.log(`Subscription ready for ${tenant.slug}`)
|
||||
|
||||
// No user seeded here — UsersController.me() upserts akadmin on first call.
|
||||
|
||||
// Feature flags. Seeded via $setOnInsert so an operator who later edits a
|
||||
// flag's state through the UI doesn't get their change reverted on the
|
||||
// next bootstrap.
|
||||
let createdFlags = 0
|
||||
for (const seed of FLAG_SEEDS) {
|
||||
const res = await this.flagModel
|
||||
.findOneAndUpdate(
|
||||
{ key: seed.key },
|
||||
{
|
||||
$setOnInsert: {
|
||||
key: seed.key,
|
||||
description: seed.description,
|
||||
state: seed.state,
|
||||
pct: seed.pct ?? 0,
|
||||
scope: {
|
||||
plans: seed.scope?.plans ?? [],
|
||||
tenantSlugs: seed.scope?.tenantSlugs ?? [],
|
||||
partnerSlugs: seed.scope?.partnerSlugs ?? [],
|
||||
environments: seed.scope?.environments ?? [],
|
||||
},
|
||||
history: [{ at: new Date(), action: `seeded · state=${seed.state}` }],
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true, rawResult: true },
|
||||
)
|
||||
.exec()
|
||||
// Mongoose returns the raw mongo result; lastErrorObject.updatedExisting
|
||||
// is false when it just inserted.
|
||||
if (
|
||||
(res as unknown as { lastErrorObject?: { updatedExisting?: boolean } })
|
||||
.lastErrorObject?.updatedExisting === false
|
||||
) {
|
||||
createdFlags++
|
||||
}
|
||||
}
|
||||
if (createdFlags > 0) {
|
||||
this.logger.log(`Seeded ${createdFlags} new flag(s) (of ${FLAG_SEEDS.length})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user