Files
descrybe/docs/plan-permissions/04-backend-model.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

3.7 KiB

04 — Backend model (agent 4/10)

Implements storage + billing service APIs for plan dashboard permissions and global section/feature master switches, aligned with 03-permission-contract.md.

BREAKING: none (additive schema + fields + methods only).


Schema (apps/api/sql/schema/026_plan_features.sql)

Artifact Purpose
plans.features JSONB NOT NULL DEFAULT '{}' — sparse feature_key → bool overrides
platform_feature_gates Global masters: gate_key, kind (section|feature), enabled, updated_at, updated_by

Missing gate rows ⇒ enabled (migration-safe). Goose Up/Down included.


Go package (apps/api/internal/billing/)

File Role
feature_catalog.go Registry keys, sections, Free/Starter deny lists
plan_features.go Resolve + admin CRUD (Get/SetFeatureGates, Get/SetPlanFeatures, CapabilitiesForCompany, …)
features_api.go Task-named surface: ListFeatures, IsAllowed, SetPlanFeature, SetGlobalFeature, EnableAllForPlan, ApplyDefaultMatrix, AssertFeature, ResolveFeatures
default_plan_features_seed.go EnsureDefaultFeatureSeeds (idempotent section rows; never clobber non-empty plan overrides)
service.go Plan.Features / ResolvedFeatures; ListPlans / UpsertPlan read/write JSON; CreditsOverview embeds effective features
client_errors.go Maps feature gate errors for HTTP clients
features_api_test.go Pure resolve/default/validate tests

EnsureDefaultPlans still updates meters only — does not overwrite features.


Resolution (normative)

effective(key) = plan_allows(key)
  AND global_section_enabled(section(key))
  AND global_feature_enabled(key)
  • plan_allows: override in plans.features if present; else is_custom ⇒ true; else DefaultPlanFeatures(name, false).
  • Globals: missing row ⇒ true.

How to use the service

svc := &billing.Service{Pool: pool}

// Catalog
defs, _ := svc.ListFeatures(ctx)

// Effective check (company's active plan ∧ globals)
ok, err := svc.IsAllowed(ctx, companyID, "marketing.campaigns.generate_ai")
if err := svc.AssertFeature(ctx, companyID, "marketing.campaigns.generate_ai"); err != nil {
    // errors.Is(err, billing.ErrFeatureDisabled) → HTTP 402 plan_gate
}

// Admin: per-plan override merge / replace / all-on / reset defaults
_ = svc.SetPlanFeature(ctx, planID, "settings.api_keys", true)
_, _ = svc.SetPlanFeatures(ctx, planID, map[string]bool{"settings.api_keys": true})
_ = svc.EnableAllForPlan(ctx, planID)
_ = svc.ApplyDefaultMatrix(ctx, planID) // sparse Free/Starter offs; custom → {}

// Admin: global masters (section or feature key)
_ = svc.SetGlobalFeature(ctx, "marketing", false, &adminUserID)
_ = svc.SetGlobalFeature(ctx, "capability.byok", false, &adminUserID)
gates, _ := svc.GetFeatureGates(ctx)

// Tenant payload (also on CreditsOverview /auth/me)
caps, _ := svc.CapabilitiesForCompany(ctx, companyID)
// caps.Features, caps.DisabledFeatures, caps.FeatureETag, caps.Entitlements

Pure helpers (no DB)

  • DefaultPlanFeatures(name, isCustom)
  • PlanAllowsFeature(name, isCustom, overrides, key)
  • ResolveFeatures / ResolveEffectiveFeatures
  • SparseDefaultOverrides(name, isCustom)
  • IsKnownFeatureKey / IsKnownFeatureSection / SectionOfFeature

Seed / bootstrap

Call EnsureDefaultFeatureSeeds after migrations (alongside EnsureDefaultPlans if desired). Seeds section gates ON; leaves empty {} plan overrides so runtime defaults apply.


Out of scope (other agents)

  • HTTP routes under /api/admin/feature-gates and capabilities endpoints
  • Frontend / admin UI toggles
  • Wiring AssertFeature into every handler (processing, SEO, campaigns, …)