Commit Graph

4 Commits

Author SHA1 Message Date
Ronni Baslund 868a305539 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.
2026-05-24 19:21:15 +02:00
Ronni Baslund fbbb43e3e2 feat(operator): partner management with attach/detach (O.6)
- Partners list with name/domain/status/customers/margin + Create modal
- Partner detail: contract card, contact card, customers table, attach modal,
  terminate (soft-delete) danger card
- Operator proxies for /partners + /partners/:slug/tenants
- platform-api: add partnerId Prop to Tenant schema. The field was being
  silently dropped by Mongoose because the schema didn't declare it.
- tenants.service: rewrite update() to build $set/$unset explicitly and cast
  partnerId via new Types.ObjectId(). Handles detach via $unset so the field
  vanishes from the doc cleanly.
2026-05-24 08:02:00 +02:00
Ronni Baslund 2db41fec5e feat(platform-api): multi-audience JWT + Partner CRUD + tenant lifecycle (O.2)
JwtAuthGuard now accepts a comma-separated AUTHENTIK_AUDIENCE
('dezky-portal,dezky-operator'). jose.jwtVerify takes an array and succeeds
on any match — both customer-portal and operator-portal tokens validate
against this service. Per-endpoint guards restrict further.

New OperatorGuard enforces operator-only mutations:
  1. JWT audience claim includes 'dezky-operator' (proof from the token
     alone that this is a privileged session)
  2. ActorService-resolved User has platformAdmin=true (DB check so
     revocation works without waiting for the token to expire)
Both required; either alone is insufficient.

Partner module:
  - Partner schema: slug, name, domain, status, marginPct, contactInfo,
    billingInfo. marginPct is one number per partner (decided in grilling)
  - CRUD endpoints under @UseGuards(JwtAuthGuard, OperatorGuard) — every
    partner mutation requires operator scope
  - GET /partners returns each row with a computed customers count from
    aggregating Tenant.partnerId. MRR aggregation deferred until
    Subscription gains a price column
  - GET /partners/:slug/tenants for the partner detail view
  - DELETE soft-terminates (status='terminated') — never hard-delete
    because tenants may still reference the partner

Tenant changes:
  - partnerId?: Types.ObjectId (ref Partner, indexed sparse) added to
    Tenant schema
  - UpdateTenantDto accepts partnerId so PATCH can attach/detach
  - POST /tenants/:slug/suspend and /resume — operator-only via
    OperatorGuard. PATCH already covers plan/domains/partnerId changes

Smoke test: customer-portal session sends POST /api/partners through the
portal proxy → 403 "This endpoint requires an operator-scoped token". The
positive test (operator-token → 200) waits for O.3 when there's an
operator app to mint the right token.

apps/portal/server/api/partners/index.post.ts is a temporary verification
proxy — delete once the operator portal exists.
2026-05-24 07:08:59 +02:00
Ronni Baslund 22b2583f0b chore(services): rename services/provisioning -> services/platform-api
O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner
management and operator-specific endpoints. The service now owns more than
just provisioning orchestration (it'll soon own partners, tenant lifecycle
actions, multi-audience JWT validation), so the name 'platform-api' reflects
its scope better.

What changed:
- Directory: services/provisioning/ -> services/platform-api/
- Package: @dezky/provisioning -> @dezky/platform-api
- Docker: container_name dezky-provisioning -> dezky-platform-api;
  compose service key 'provisioning' -> 'platform-api'; volume
  provisioning_node_modules -> platform_api_node_modules
- Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL,
  default URL http://provisioning:3001 -> http://platform-api:3001 in all
  three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/
  reconcile.post.ts), plus NUXT_API_BASE updated
- Health endpoint service identifier and main.ts log lines updated to
  'dezky-platform-api'
- Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md,
  NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml

What deliberately stays:
- Internal module names ProvisioningService / ProvisioningModule (those
  describe an orchestration sub-concern, not the service's purpose)
- Tenant.provisioningStatus / provisioningErrors field names (state
  per integration, not service name)
- File services/platform-api/src/tenants/provisioning.service.ts
- 'Hetzner provisioning' references in production-prep docs (infrastructure
  provisioning, unrelated)

Verified end-to-end after rename: /api/me returns 200 with profile + 2
tenants + subscription, /api/tenants/dezky/reconcile returns 200 with
Authentik integration still ok.

OPERATOR-PLAN.md O.0 checkboxes ticked.
2026-05-24 00:35:01 +02:00