From 73b6b548f042190d3ace2d38fb6ff6c387681ca8 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 23:34:34 +0100 Subject: [PATCH] docs: implementation plan for spec 7, metered licensing Ten tasks, each ending in something independently verifiable. The plan stops at the Paddle boundary on purpose: spec 7 lands before plan 5, so there is no client, no webhook and no checkout here, and the configurator is built as a component mounted for staff first rather than as a customer screen with nothing behind it. Corrects two things the spec got wrong about the control plane. Feature gating is already built and mounted, so Free tenants have already lost the console and no customer email is owed. The only real gap is that HandleOIDCCallback lacks the check HandleOIDCStart already has, which is the half that completes a sign-in. Adds the Free self-hosted lifecycle the spec called for and the first draft of the plan missed: linking issues nothing today, and renewInstance hardcodes a monthly term that would hand a self-hosted install a one-month licence. Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-26-metered-licensing.md | 4061 +++++++++++++++++ .../2026-07-26-metered-licensing-design.md | 42 +- 2 files changed, 4083 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-26-metered-licensing.md diff --git a/docs/superpowers/plans/2026-07-26-metered-licensing.md b/docs/superpowers/plans/2026-07-26-metered-licensing.md new file mode 100644 index 0000000..9fda067 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-metered-licensing.md @@ -0,0 +1,4061 @@ +# Metered Licensing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** make a licence express what one customer configured rather than what a fixed tier grants — two deployments times three tiers, a metered server count, and per-customer feature toggles — with the control plane enforcing every column of the pricing table. + +**Architecture:** `plans` is re-keyed on `(deployment, tier)` and holds base allowances only. A new `catalogue` collection holds one row per priceable component and is the only place a Paddle price ID lives. A new `entitlements` collection holds one row per instance with `desired` beside `granted`; `licensing.Issue` snapshots `granted` and nothing else. One new package, `admin/internal/catalogue`, owns both folds — entitlement to licence limits, and entitlement to Paddle line items — so the arithmetic exists once. + +**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, Tailwind 3. + +Spec: [`docs/superpowers/specs/2026-07-26-metered-licensing-design.md`](../specs/2026-07-26-metered-licensing-design.md). + +## Global Constraints + +- **No automated Go tests.** This repo has no Go test suite (one exception: `agent/internal/config/config_test.go`). Verification is by compiler, `grep`, `curl` and running built images against scratch databases. Every "confirm" step below is a command with expected output. **Do not add `*_test.go` files.** +- **Never run `go` or `npm` on the host.** Everything runs in a container. Create the wrappers if missing: + ```sh + # /tmp/gorun.sh + DIR="$1"; shift + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ + -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \ + golang:1.26 "$@" + ``` + ```sh + # /tmp/npmrun.sh + DIR="$1"; shift + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \ + -w "/src/$DIR" node:26-alpine "$@" + ``` +- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise. +- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry". +- **This plan adds no Paddle dependency, no webhook and no checkout.** See "The seam with plan 5" below. If you find yourself importing a Paddle SDK, stop. +- **No price ID, product ID or Paddle URL is ever hard-coded** in Go or TypeScript. Price IDs live in `catalogue.price_ids`, keyed by environment then term, and are edited through the staff UI. +- **`licensing.Issue` stays the only signer.** Nothing else touches `licenses` or signs anything. +- **Admin's control-plane writes stay confined to `inject` (three licence fields) and `cloudprov` (instances and users).** This plan adds no third write path. +- **Every licence snapshots; nothing rewrites history.** Editing a plan, a catalogue row or an entitlement must never change an issued licence. +- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`. +- **Nothing shortens or revokes a licence.** Reductions take effect at renewal. If you find yourself writing an earlier `expires_at`, stop. +- **`web/` is dark-only and carries no hex values;** `adminsite/` is light-default and carries no hex values. Tokens only, in both. + +## The seam with plan 5 + +Spec 7 lands **before** [plan 5](2026-07-26-paddle-billing.md), whose preamble it revises. Plan 5 is unstarted: `admin/internal/paddle` and `admin/internal/billing` do not exist. That fixes this plan's boundary precisely. + +**In this plan:** the data model, both resolution folds, issuance from an entitlement, control-plane enforcement, and staff-facing screens to edit plans, the catalogue and an instance's entitlement. + +**Left to plan 5:** the Paddle client, webhooks, `GET /api/checkout/options`, the outbound subscription update, and the *customer's* purchase configurator. + +The customer-facing configurator is deliberately not built here. It cannot function without a checkout, and a dead screen is worse than no screen. Instead, task 8 builds the configurator as a **component** driven entirely by the catalogue, mounted first on the staff instance screen. Plan 5 then mounts the same component in the customer purchase flow and hands it a checkout. This mirrors how spec 3 made staff issuance work through the API before any UI existed for it. + +**The consequence to accept:** at the end of this plan a customer cannot buy anything new. Staff can configure any instance's entitlement and issue against it, which is exactly the position spec 3 left licensing in, and it is what makes plan 5 a wiring job rather than a rewrite. + +## Context this plan inherits + +Shipped and load-bearing here: + +- `licensing.Issue(ctx, IssueInput)` signs, records, supersedes the previous licence, sets `tier`/`status: active`/`current_license` on the instance, and resets `relink_count` when `Reason == ReasonRenewal`. `IssueInput.ExpiresAt` overrides `Term`. It does **not** deliver. +- `models.GracePeriod` is 3 days and is added by `Issue` when it derives an expiry from `Term`. **When you pass `ExpiresAt` you must add the grace yourself.** +- `models.SeedPlans` uses `$setOnInsert` only, so staff edits survive a redeploy. +- `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. Admin has no migrations collection; this is where schema moves go. +- `db.EnsureIndexes` declares `plans.tier` **unique**. Re-keying means dropping that index by name, which Mongo will not do implicitly. +- **Feature gating already exists and is already mounted.** `api.RequireFeature` answers 403 `feature_unavailable`, and `handlers.go` wraps `/api/console/connect`, `/api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. `auth.HandleOIDCStart` checks `Feature("oidc")` itself. **Only `auth.HandleOIDCCallback` is ungated.** +- `services.GetLicenseState` caches per instance for 60s; `stateFromResult` is the single decode site where a payload becomes a `LicenseState`. +- `services.StartLogSweeper` is the pattern for a retention sweep: an immediate pass, then an hourly ticker. +- `api.limitStatus` maps a `*services.LimitError` to a 403 with `limit`/`current`/`max`. + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `admin/internal/models/catalogue.go` | the `CatalogueRow` document, its kind constants, its price lookup, and its seed | +| `admin/internal/models/entitlements.go` | the `Entitlement` and `Config` documents, and the per-instance read/upsert helpers | +| `admin/internal/catalogue/catalogue.go` | row loading, and the fold to `license.Limits` + features | +| `admin/internal/catalogue/items.go` | the fold to Paddle line items, and the reverse fold from an item list | +| `admin/internal/api/entitlement.go` | staff and customer entitlement endpoints | +| `server/internal/services/audit_retention.go` | the audit-log retention sweep | +| `adminsite/components/PlanConfigurator.tsx` | deployment/tier/term/servers/features, catalogue-driven, priced | +| `adminsite/app/(staff)/staff/catalogue/page.tsx` | the price-ID editor, one table per environment | + +**Modified:** + +| Path | Change | +|---|---| +| `shared/license/license.go` | `Limits` gains two fields; `License` gains `SupportLevel`; `FillUnset`; support constants | +| `shared/license/plans.go` | six plans keyed on `(deployment, tier)`; `PlanFor` re-signed; `NormaliseTier` | +| `shared/cmd/lkctl/main.go` | the new `PlanFor` signature and a `--deployment` flag | +| `admin/internal/models/models.go` | `Plan` re-shaped; `ReasonEntitlementChange` | +| `admin/internal/models/plans.go` | `SeedPlans` writes six rows; `GetPlan` takes a deployment | +| `admin/internal/models/backfill.go` | re-key plans, re-tier self-hosted instances, backfill entitlements | +| `admin/internal/db/db.go` | drop `plans.tier` unique; add three indexes | +| `admin/internal/licensing/issue.go` | issue from the entitlement; Free limit per deployment | +| `admin/internal/api/customer.go` | the Free pre-check gains a deployment; `claimFree`; `renewInstance` handles both deployments | +| `admin/internal/lifecycle/lifecycle.go` | deletion warnings and reaping stay cloud-only | +| `web/lib/api.ts` | `LicenseInfo` gains two limits, one usage count and the support level | +| `admin/internal/api/staff.go` | `staffUpdatePlan` re-keyed; catalogue handlers | +| `admin/internal/api/routes.go` | the re-keyed plan route and five new routes | +| `admin/cmd/main.go` | seed the catalogue at boot | +| `server/internal/services/licence.go` | fill unset limits from the plan base at the decode site | +| `server/internal/services/licence_limits.go` | `CheckMonitorLimit`; monitors in `LicenseUsage` | +| `server/internal/api/licence.go` | monitors in the usage response | +| `server/internal/api/monitors.go` | enforce the monitor cap on create | +| `server/internal/auth/oidc.go` | gate the callback | +| `server/cmd/main.go` | start the audit sweeper | +| `web/app/(app)/settings/license/page.tsx` | show monitors, audit retention and support level | +| `adminsite/lib/api.ts` | the re-shaped `Plan`, `CatalogueRow`, `Entitlement`, and their calls | +| `adminsite/app/(staff)/staff/plans/page.tsx` | six plans, allowances and support level; prices move out | +| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the entitlement configurator | +| `adminsite/components/AppBar.tsx` | a staff nav entry for Catalogue | +| `CLAUDE.md` | the collection list, the admin route table, the licensing summary | +| `docs/superpowers/specs/README.md` | spec 7 status | +| `docs/superpowers/plans/2026-07-26-paddle-billing.md` | the preamble, narrowed to what remains | + +--- + +### Task 1: The licence payload, six plans, and the legacy tier + +**Files:** +- Modify: `shared/license/license.go`, `shared/license/plans.go`, `shared/cmd/lkctl/main.go` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: + - `license.Limits.MaxMonitors int`, `license.Limits.AuditRetentionDays int` + - `func (l Limits) FillUnset(base Limits) Limits` + - `license.License.SupportLevel string` + - `license.TierEnterprise` + - `license.SupportCommunity`, `SupportEmail24x5`, `SupportEmailCall24x7` + - `func PlanFor(deployment, tier string) (Plan, bool)` + - `func NormaliseTier(deployment, tier string) (string, string)` + - `Plan.SupportLevel string` + +- [ ] **Step 1: Add the tier, the support levels, and the two limit fields** + +In `shared/license/license.go`, add `TierEnterprise` to the tier block and a support-level block after the feature constants: + +```go +const ( + TierFree = "free" + TierProfessional = "professional" + TierEnterprise = "enterprise" + + // TierSelfHosted is LEGACY and no new licence carries it. + // + // It was a tier when self-hosting was a tier rather than a deployment. Blobs + // already signed with it exist and cannot be rewritten, so it stays a + // recognised value that NormaliseTier maps forward. Never put it in a plan + // row and never offer it in a UI. + TierSelfHosted = "self_hosted" + + DeploymentCloud = "cloud" + DeploymentSelfHosted = "self_hosted" + + FeatureConsole = "console" // browser SSH/RDP/VNC + FeatureOIDC = "oidc" // per-instance single sign-on +) + +// Support levels. Carried for display and enforced by nothing — there is no code +// path anywhere that branches on these, and there must not be one. They are here +// so an air-gapped install can tell its operator who to call without reaching +// Vantage HQ. +const ( + SupportCommunity = "community" + SupportEmail24x5 = "email_24_5" + SupportEmailCall24x7 = "email_call_24_7" +) +``` + +Replace `Limits`: + +```go +// Limits are the countable caps a licence grants. +// +// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays +// is the odd one out: it bounds a duration rather than a count, and Unlimited +// there means "never trim" rather than "no cap". +type Limits struct { + MaxServers int `json:"max_servers"` + MaxMonitors int `json:"max_monitors"` + MaxSecretGroups int `json:"max_secret_groups"` + MaxChannels int `json:"max_channels"` + AuditRetentionDays int `json:"audit_retention_days"` +} + +// FillUnset replaces any zero field with the same field from base. +// +// This exists for one reason: a licence signed before a field existed decodes it +// as 0, and 0 would read as the most restrictive possible value — no monitors, +// and an audit log trimmed to nothing. A blob we cannot re-sign must not be +// allowed to mean that. +// +// The cost is that 0 stops being expressible as a real allowance. No plan grants +// zero of anything, so nothing is lost today; a plan that genuinely means zero +// must use a negative-free sentinel of its own rather than reintroducing 0 here. +func (l Limits) FillUnset(base Limits) Limits { + if l.MaxServers == 0 { + l.MaxServers = base.MaxServers + } + if l.MaxMonitors == 0 { + l.MaxMonitors = base.MaxMonitors + } + if l.MaxSecretGroups == 0 { + l.MaxSecretGroups = base.MaxSecretGroups + } + if l.MaxChannels == 0 { + l.MaxChannels = base.MaxChannels + } + if l.AuditRetentionDays == 0 { + l.AuditRetentionDays = base.AuditRetentionDays + } + return l +} +``` + +Add `SupportLevel` to `License`, immediately after `Deployment`: + +```go + Deployment string `json:"deployment"` + SupportLevel string `json:"support_level,omitempty"` // display only +``` + +- [ ] **Step 2: Replace the seed plan table with six plans** + +Replace the whole of `shared/license/plans.go`: + +```go +package license + +// Plan is the contents of one (deployment, tier) pair at issue time. +// +// This table is the seed. The admin service owns the authoritative copy in its +// `plans` collection, and every issued licence snapshots the plan it was cut +// from — so editing a plan never rewrites an existing licence, the same rule as +// workflow_runs.steps_snapshot. +// +// Limits here are the BASE allowance: what the tier grants before anything is +// bought. A metered dimension adds to it, which is why max_servers is a real +// number at Professional and Enterprise rather than Unlimited. +type Plan struct { + Tier string + Name string + Deployment string + SupportLevel string + Limits Limits + Features []string +} + +// planKey is the composite the table is keyed on. +// +// Keying on tier alone was what made Free cloud-only by construction. There is +// now a self-hosted Free plan, so that guarantee is gone and the Free limit is +// enforced per account AND deployment instead. See licensing.checkFreeLimit. +type planKey struct { + Deployment string + Tier string +} + +// baseFree, baseProfessional and baseEnterprise are shared by both deployments. +// +// The allowances are deliberately identical across cloud and self-hosted: what +// differs between the two is the term on offer, not what you get. Duplicating +// them per deployment would be four places to forget. +var ( + baseFree = Limits{ + MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1, + MaxChannels: 1, AuditRetentionDays: 30, + } + baseProfessional = Limits{ + MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited, + MaxChannels: Unlimited, AuditRetentionDays: 365, + } + baseEnterprise = Limits{ + MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited, + MaxChannels: Unlimited, AuditRetentionDays: Unlimited, + } +) + +var plans = map[planKey]Plan{ + {DeploymentCloud, TierFree}: { + Tier: TierFree, Name: "Free", Deployment: DeploymentCloud, + SupportLevel: SupportCommunity, Limits: baseFree, + // Empty rather than nil: nil marshals as JSON null, and this table is + // the seed every plan and licence is cut from. + Features: []string{}, + }, + {DeploymentCloud, TierProfessional}: { + Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud, + SupportLevel: SupportEmail24x5, Limits: baseProfessional, + // Console and SSO are opt-in per customer, so no tier bundles them. The + // field stays because a future tier might. + Features: []string{}, + }, + {DeploymentCloud, TierEnterprise}: { + Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud, + SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise, + Features: []string{}, + }, + {DeploymentSelfHosted, TierFree}: { + Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted, + SupportLevel: SupportCommunity, Limits: baseFree, + Features: []string{}, + }, + {DeploymentSelfHosted, TierProfessional}: { + Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted, + SupportLevel: SupportEmail24x5, Limits: baseProfessional, + Features: []string{}, + }, + {DeploymentSelfHosted, TierEnterprise}: { + Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted, + SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise, + Features: []string{}, + }, +} + +// PlanFor returns the seed plan for one deployment and tier. +// +// It normalises first, so a legacy self_hosted licence resolves to the plan that +// replaced it rather than to nothing. +func PlanFor(deployment, tier string) (Plan, bool) { + deployment, tier = NormaliseTier(deployment, tier) + p, ok := plans[planKey{deployment, tier}] + return p, ok +} + +// NormaliseTier maps a legacy tier forward. +// +// tier "self_hosted" predates deployments being separate from tiers. Such a +// licence granted what Professional now grants, on a self-hosted install, so it +// maps to exactly that. Called by PlanFor and by anything reading a tier off an +// already-signed payload. +func NormaliseTier(deployment, tier string) (string, string) { + if tier == TierSelfHosted { + return DeploymentSelfHosted, TierProfessional + } + return deployment, tier +} + +// Tiers is the offer order, for any UI that lists them. +func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} } + +// Deployments is the offer order. +func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} } + +// TermsFor reports which billing terms a deployment sells. +// +// Self-hosted is annual only, and the reason is in this package's doc comment: an +// offline licence cannot be revoked, so the term length IS the revocation +// window. A self-hosted monthly licence would renew that window twelve times a +// year for no commercial gain. +func TermsFor(deployment string) []string { + if deployment == DeploymentSelfHosted { + return []string{"annual"} + } + return []string{"monthly", "annual"} +} +``` + +- [ ] **Step 3: Update lkctl for the new signature** + +In `shared/cmd/lkctl/main.go`'s `issue`, add a deployment flag beside the tier flag: + +```go + tier := fs.String("tier", "", "free | professional | enterprise (required)") + deployment := fs.String("deployment", "cloud", "cloud | self_hosted") +``` + +Replace the `PlanFor` call and the self-hosted term guard: + +```go + plan, ok := license.PlanFor(*deployment, *tier) + if !ok { + fatal("no plan for deployment %q tier %q", *deployment, *tier) + } +``` + +Then find the existing guard that reads: + +```go + if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" { +``` + +and replace its condition with the deployment, since self-hosted is no longer a tier: + +```go + if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" { +``` + +Finally, carry the support level into the payload. Find where the `license.License` literal is built in `issue` and add: + +```go + SupportLevel: plan.SupportLevel, +``` + +- [ ] **Step 4: Confirm every module still builds** + +`shared` is imported by `server`, `admin` and `sitesvc`, so all four must be checked. `admin/internal/models/plans.go` calls the old `PlanFor` signature and **will fail here** — that is expected and task 2 fixes it. + +```bash +GOWORK=off /tmp/gorun.sh shared go build ./... +``` +Expected: no output. + +```bash +GOWORK=off /tmp/gorun.sh server go build ./... +``` +Expected: no output. The server never calls `PlanFor` yet. + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: **failure**, `not enough arguments in call to license.PlanFor` in `internal/models/plans.go`. Confirm the error names only that one file; anything else means a caller was missed. + +Confirm the legacy mapping is reachable and the table is complete: + +```bash +GOWORK=off /tmp/gorun.sh shared go vet ./... +``` +Expected: no output. + +```bash +grep -c "planKey{" shared/license/plans.go +``` +Expected: `7` — one in the type-keyed map literal per plan (6) plus the lookup in `PlanFor`. + +- [ ] **Step 5: Commit** + +```bash +git add shared/license shared/cmd/lkctl/main.go +git commit -m "feat(license): two deployments times three tiers, and two new limits + +Plans are keyed on (deployment, tier) rather than tier alone, which is what +ends Free being cloud-only by construction — there is a self-hosted Free +plan now, so the one-per-account rule has to be enforced per deployment +instead of falling out of the plan table. + +tier self_hosted becomes a legacy value no new licence carries. +NormaliseTier maps it to self-hosted Professional, which is what it always +granted, so blobs we cannot re-sign keep working. + +Limits.FillUnset exists because a licence signed before a field existed +decodes it as 0, and 0 would read as no monitors and an audit log trimmed +to nothing. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Plans re-keyed, and the catalogue and entitlement documents + +**Files:** +- Create: `admin/internal/models/catalogue.go`, `admin/internal/models/entitlements.go` +- Modify: `admin/internal/models/models.go`, `admin/internal/models/plans.go`, `admin/internal/models/backfill.go`, `admin/internal/db/db.go`, `admin/cmd/main.go` + +**Interfaces:** +- Consumes: `license.PlanFor(deployment, tier)`, `license.Tiers()`, `license.Deployments()`, `license.NormaliseTier`, `license.Limits.FillUnset`. +- Produces: + - `models.Plan` with `Deployment`, `Tier`, `BaseLimits`, `BaseFeatures`, `SupportLevel`, `Active` + - `models.GetPlan(ctx, deployment, tier) (*Plan, error)` + - `models.SeedPlans(ctx) error` — six rows + - `models.KindBase`, `KindLimit`, `KindFeature` + - `models.LimitKeyServers` + - `models.CatalogueRow` with `PriceID(env, term string) string` + - `models.SeedCatalogue(ctx) error` + - `models.CatalogueFor(ctx, deployment, tier) ([]CatalogueRow, error)` + - `models.Config` with `Servers int`, `Features Features` + - `models.Entitlement` + - `models.GetEntitlement(ctx, instanceID) (*Entitlement, error)` + - `models.ErrNoEntitlement` + - `models.ReasonEntitlementChange` + +- [ ] **Step 1: Re-shape the Plan struct and add the new reason** + +In `admin/internal/models/models.go`, add to the issuance-reason block: + +```go + // ReasonEntitlementChange is a mid-term change to what an instance is + // allowed — servers added, a feature toggled — at the same expiry. + // + // It is deliberately NOT ReasonRenewal: a renewal resets relink_count + // because a new term has begun, and adding a server does not begin one. + ReasonEntitlementChange = "entitlement_change" +``` + +Replace the `Plan` struct: + +```go +// Plan is the authoritative definition of one (deployment, tier) pair, seeded +// from shared/license. +// +// It lives in the database so tier contents change without a deploy. Every +// issued licence snapshots it, so editing a plan never rewrites an existing +// licence — the same rule as workflow_runs.steps_snapshot. +// +// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a +// metered plan is priced by several components and a single map on this row +// cannot express that. +type Plan struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier" json:"tier"` + Name string `bson:"name" json:"name"` + // BaseLimits is the allowance before anything is bought. The field is named + // `base_` rather than `limits` because that is a different claim from the one + // the old field made, and a reader must not assume it is the total. + BaseLimits license.Limits `bson:"base_limits" json:"base_limits"` + BaseFeatures Features `bson:"base_features" json:"base_features"` + SupportLevel string `bson:"support_level" json:"support_level"` + Active bool `bson:"active" json:"active"` +} +``` + +- [ ] **Step 2: Seed six plans and re-sign GetPlan** + +Replace `SeedPlans` and `GetPlan` in `admin/internal/models/plans.go`: + +```go +// SeedPlans inserts the six (deployment, tier) rows from shared/license on first +// boot. +// +// It uses $setOnInsert only: once a plan exists, staff edits to allowances, +// features and support level are authoritative and a redeploy must not stamp +// over them. +func SeedPlans(ctx context.Context) error { + for _, deployment := range license.Deployments() { + for _, tier := range license.Tiers() { + p, ok := license.PlanFor(deployment, tier) + if !ok { + continue + } + _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"deployment": deployment, "tier": tier}, + bson.M{"$setOnInsert": bson.M{ + "deployment": p.Deployment, + "tier": p.Tier, + "name": p.Name, + "base_limits": p.Limits, + "base_features": Features(p.Features).OrEmpty(), + "support_level": p.SupportLevel, + "active": true, + }}, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return err + } + } + } + return nil +} + +// GetPlan reads one pair's authoritative definition. +// +// It normalises the tier first, so a legacy self_hosted licence being reissued +// resolves to the plan that replaced it. +func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) { + deployment, tier = license.NormaliseTier(deployment, tier) + var p Plan + if err := db.Admin("plans").FindOne(ctx, + bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil { + return nil, err + } + return &p, nil +} +``` + +- [ ] **Step 3: Create the catalogue document and its seed** + +Create `admin/internal/models/catalogue.go`: + +```go +package models + +import ( + "context" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// Component kinds. +const ( + // KindBase is the plan's own fee, always quantity 1. + KindBase = "base" + // KindLimit raises a named limit by one per unit of quantity. + KindLimit = "limit" + // KindFeature is an on/off feature key. + KindFeature = "feature" +) + +// LimitKeyServers is the only metered limit today. +// +// A limit_key is a field name in license.Limits, which is what lets a second +// metered dimension be a catalogue row rather than a code change. There is +// deliberately no block size: with secret-group blocks dropped from the spec it +// would be 1 in every row that will ever exist. +const LimitKeyServers = "max_servers" + +// CatalogueRow is one priceable component of one plan. +// +// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty +// PriceIDs means the component is free — a feature with no price is a toggle a +// customer may take at no charge, and giving it a price later is a staff edit +// rather than a migration or a deploy. +type CatalogueRow struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + Kind string `bson:"kind" json:"kind"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier" json:"tier"` + LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"` + FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"` + // PriceIDs is environment -> term -> Paddle price ID, e.g. + // {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}. + // + // Nested by environment rather than kept in two collections, because + // promoting sandbox to production must be a configuration change and not a + // data migration. The running PADDLE_ENV picks the inner map. + PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"` +} + +// PriceID returns the price for one environment and term, or "". +func (r CatalogueRow) PriceID(env, term string) string { + if r.PriceIDs == nil { + return "" + } + return r.PriceIDs[env][term] +} + +// Priced reports whether this component costs anything in an environment. +func (r CatalogueRow) Priced(env string) bool { + for _, term := range []string{"monthly", "annual"} { + if r.PriceID(env, term) != "" { + return true + } + } + return false +} + +// SeedCatalogue inserts the sixteen rows the four PAID plans need. +// +// The two Free plans get no rows at all, and that absence is what keeps Free +// outside Paddle: with nothing to price, no checkout can be built for it. Do not +// "fix" this by adding zero-priced Free rows. +// +// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted +// in by staff and a redeploy must not blank them. +func SeedCatalogue(ctx context.Context) error { + paid := []string{license.TierProfessional, license.TierEnterprise} + for _, deployment := range license.Deployments() { + for _, tier := range paid { + rows := []CatalogueRow{ + {Kind: KindBase, Deployment: deployment, Tier: tier}, + {Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers}, + {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole}, + {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC}, + } + for _, r := range rows { + filter := bson.M{ + "kind": r.Kind, + "deployment": r.Deployment, + "tier": r.Tier, + "limit_key": r.LimitKey, + "feature_key": r.FeatureKey, + } + if _, err := db.Admin("catalogue").UpdateOne(ctx, filter, + bson.M{"$setOnInsert": bson.M{ + "kind": r.Kind, + "deployment": r.Deployment, + "tier": r.Tier, + "limit_key": r.LimitKey, + "feature_key": r.FeatureKey, + "price_ids": map[string]map[string]string{}, + }}, + options.UpdateOne().SetUpsert(true)); err != nil { + return err + } + } + } + } + return nil +} + +// CatalogueFor returns every component of one plan. +func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) { + deployment, tier = license.NormaliseTier(deployment, tier) + cur, err := db.Admin("catalogue").Find(ctx, + bson.M{"deployment": deployment, "tier": tier}) + if err != nil { + return nil, err + } + rows := []CatalogueRow{} + if err := cur.All(ctx, &rows); err != nil { + return nil, err + } + return rows, nil +} + +// AllCatalogue returns every row, for the staff editor. +func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) { + cur, err := db.Admin("catalogue").Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + rows := []CatalogueRow{} + if err := cur.All(ctx, &rows); err != nil { + return nil, err + } + return rows, nil +} +``` + +- [ ] **Step 4: Create the entitlement document** + +Create `admin/internal/models/entitlements.go`: + +```go +package models + +import ( + "context" + "errors" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// ErrNoEntitlement means the instance has no configuration row. +// +// Callers fall back to the plan's base rather than failing: staff manual +// issuance and any instance predating the backfill legitimately have none. +var ErrNoEntitlement = errors.New("instance has no entitlement") + +// Config is one side of an entitlement — a complete statement of what an +// instance is allowed. +// +// Servers is the TOTAL the customer sees, not the number of units billed. The +// billed quantity is Servers minus the plan's base allowance, and it is computed +// where the line items are built rather than stored, so the two can never +// disagree about which of them included the base. +type Config struct { + Servers int `bson:"servers" json:"servers"` + Features Features `bson:"features" json:"features"` +} + +// Entitlement is what one instance's customer configured. +// +// Both the subscription and the licence are derived from it; it is derived from +// nothing. Desired is what they last asked for; Granted is what a payment +// confirmed. A licence is only ever signed from Granted, so an abandoned +// checkout leaves a Desired that reached nothing. +type Entitlement struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + AccountID string `bson:"account_id" json:"account_id"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier" json:"tier"` + Term string `bson:"term" json:"term"` + + Desired Config `bson:"desired" json:"desired"` + Granted Config `bson:"granted" json:"granted"` + + // ResolvedLimits is BaseLimits with Granted folded in. It is stored rather + // than derived on read so the fold lives in exactly one place — deriving it + // at every read would put the arithmetic in the issuer, the portal and the + // staff console. + ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"` + + // ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only + // when Desired grants less than Granted, and it is what lets the portal say + // "drops to 5 on 12 August" instead of guessing. + ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"` + + GrantedAt time.Time `bson:"granted_at" json:"granted_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +// Pending reports whether Desired and Granted disagree. +func (e Entitlement) Pending() bool { + if e.Desired.Servers != e.Granted.Servers { + return true + } + if len(e.Desired.Features) != len(e.Granted.Features) { + return true + } + have := map[string]bool{} + for _, f := range e.Granted.Features { + have[f] = true + } + for _, f := range e.Desired.Features { + if !have[f] { + return true + } + } + return false +} + +// GetEntitlement reads one instance's configuration. +func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) { + var e Entitlement + err := db.Admin("entitlements").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&e) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNoEntitlement + } + if err != nil { + return nil, err + } + return &e, nil +} + +// UpsertEntitlement writes an entitlement, creating it if absent. +// +// GrantedAt is only touched when Granted actually changes, which is what makes +// it answer "since when has this instance been allowed this" rather than "when +// was this row last written". +func UpsertEntitlement(ctx context.Context, e Entitlement) error { + now := time.Now().UTC() + set := bson.M{ + "account_id": e.AccountID, + "deployment": e.Deployment, + "tier": e.Tier, + "term": e.Term, + "desired": e.Desired, + "granted": e.Granted, + "resolved_limits": e.ResolvedLimits, + "updated_at": now, + } + if e.ScheduledChangeAt != nil { + set["scheduled_change_at"] = *e.ScheduledChangeAt + } + if !e.GrantedAt.IsZero() { + set["granted_at"] = e.GrantedAt + } else { + set["granted_at"] = now + } + update := bson.M{"$set": set} + if e.ScheduledChangeAt == nil { + update["$unset"] = bson.M{"scheduled_change_at": ""} + } + _, err := db.Admin("entitlements").UpdateOne(ctx, + bson.M{"instance_id": e.InstanceID}, + mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}), + options.UpdateOne().SetUpsert(true)) + return err +} + +// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one. +func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M { + update["$setOnInsert"] = onInsert + return update +} +``` + +- [ ] **Step 5: Move the indexes** + +In `admin/internal/db/db.go`, remove `{"plans", "tier"}` from the `unique` slice. Then add, immediately after the `subscriptions` sparse index block: + +```go + // plans was unique on tier alone until spec 7. Mongo will not replace an + // index implicitly, and the old one would refuse the second row of every + // tier, so it is dropped by name here. Dropping a missing index is not an + // error worth failing boot over — a fresh database has never had it. + if _, err := Admin("plans").Indexes().DropOne(ctx, "tier_unique"); err != nil { + log.Printf("index plans.tier_unique: not dropped (%v); expected on a fresh database", err) + } + + for _, u := range []struct { + coll string + keys bson.D + name string + }{ + {"plans", bson.D{{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}}, "deployment_tier_unique"}, + {"catalogue", bson.D{ + {Key: "deployment", Value: 1}, {Key: "tier", Value: 1}, {Key: "kind", Value: 1}, + {Key: "limit_key", Value: 1}, {Key: "feature_key", Value: 1}, + }, "component_unique"}, + {"entitlements", bson.D{{Key: "instance_id", Value: 1}}, "instance_id_unique"}, + } { + if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: u.keys, + Options: options.Index().SetUnique(true).SetName(u.name), + }); err != nil { + return fmt.Errorf("index %s.%s: %w", u.coll, u.name, err) + } + } +``` + +Add `"log"` to that file's imports. + +- [ ] **Step 6: Extend the backfill** + +In `admin/internal/models/backfill.go`, append three passes to `Backfill` before its final `return nil`, and add `"github.com/mrhid6/vantage/shared/license"` if not already imported (it is): + +```go + // Pass 3: plans were keyed on tier alone. Give the two cloud tiers their + // deployment, and turn the self_hosted TIER row into the self-hosted + // Professional row it always was. Field names move too: limits/features + // become base_limits/base_features, because "base" is a different claim. + if _, err := db.Admin("plans").UpdateMany(ctx, + bson.M{"deployment": bson.M{"$exists": false}, + "tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}}, + bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil { + return err + } + if _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"tier": license.TierSelfHosted}, + bson.M{"$set": bson.M{ + "deployment": license.DeploymentSelfHosted, + "tier": license.TierProfessional, + "name": "Professional", + }}); err != nil { + return err + } + if _, err := db.Admin("plans").UpdateMany(ctx, + bson.M{"limits": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil { + return err + } + // Support level is new, so nothing has one. Fill from the seed table rather + // than guessing: a plan row a human edited keeps every other field. + for _, deployment := range license.Deployments() { + for _, tier := range license.Tiers() { + p, ok := license.PlanFor(deployment, tier) + if !ok { + continue + } + if _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"deployment": deployment, "tier": tier, + "support_level": bson.M{"$in": bson.A{nil, ""}}}, + bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil { + return err + } + } + } + + // Pass 4: instances carrying the self_hosted TIER move to Professional. + // Their deployment already says self_hosted, so only the tier is wrong. + res, err = db.Admin("admin_instances").UpdateMany(ctx, + bson.M{"tier": license.TierSelfHosted}, + bson.M{"$set": bson.M{"tier": license.TierProfessional}}) + if err != nil { + return err + } + if res.ModifiedCount > 0 { + log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount) + } + + // Pass 5: give every instance an entitlement, reconstructed from its current + // licence. Filtering on the absence of a row is what makes this idempotent, + // and it means an entitlement a customer has since edited is never + // overwritten by a stale licence. + if err := backfillEntitlements(ctx); err != nil { + return err + } +``` + +Then append the helper to the same file: + +```go +// backfillEntitlements reconstructs an entitlement per instance from its licence. +// +// An Unlimited max_servers maps back to the plan's BASE allowance rather than to +// a huge number: an unlimited licence bought no server units, so the honest +// reconstruction of "how many did they pay for" is none. This makes a +// pre-metering Professional instance read as 3 servers, which is a REDUCTION in +// what it is allowed. That is deliberate and it is why this is a plan step and +// not a silent fix — see the task's confirmation step. +func backfillEntitlements(ctx context.Context) error { + cur, err := db.Admin("admin_instances").Find(ctx, + bson.M{"status": bson.M{"$ne": StatusDeleted}}) + if err != nil { + return err + } + var instances []Instance + if err := cur.All(ctx, &instances); err != nil { + return err + } + + created := 0 + for _, inst := range instances { + n, err := db.Admin("entitlements").CountDocuments(ctx, + bson.M{"instance_id": inst.InstanceID}) + if err != nil { + return err + } + if n > 0 { + continue + } + + deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier) + if tier == "" { + // An instance awaiting its first licence has no tier. It gets an + // entitlement when one is issued, not before. + continue + } + plan, err := GetPlan(ctx, deployment, tier) + if err != nil { + log.Printf("backfill: instance %s names unknown plan %s/%s; skipped", + inst.InstanceID, deployment, tier) + continue + } + + cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}} + if inst.CurrentLicense != "" { + var lic License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil { + if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 { + cfg.Servers = lic.Limits.MaxServers + } + cfg.Features = lic.Features.OrEmpty() + } + } + + limits := plan.BaseLimits + limits.MaxServers = cfg.Servers + if err := UpsertEntitlement(ctx, Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: deployment, + Tier: tier, + Term: defaultTerm(deployment), + Desired: cfg, + Granted: cfg, + ResolvedLimits: limits, + }); err != nil { + return err + } + created++ + } + if created > 0 { + log.Printf("backfill: created %d entitlements from current licences", created) + } + return nil +} + +// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted +// sells annual only, so there is nothing to guess there. +func defaultTerm(deployment string) string { + if deployment == license.DeploymentSelfHosted { + return "annual" + } + return "monthly" +} +``` + +- [ ] **Step 7: Seed the catalogue at boot** + +In `admin/cmd/main.go`, find the existing `models.SeedPlans` call and add the catalogue seed directly after it, sharing its error handling style: + +```go + if err := models.SeedCatalogue(ctx); err != nil { + log.Fatalf("seed catalogue: %v", err) + } +``` + +**Order matters:** `SeedPlans` before `SeedCatalogue` before `Backfill`. The backfill's pass 5 calls `GetPlan`, which needs the six rows to exist and to have been re-keyed by pass 3 — and pass 3 runs inside `Backfill`, after both seeds. Confirm the existing call order in `main.go` is seeds first, then `Backfill`; if it is not, move `Backfill` after them. + +- [ ] **Step 8: Confirm it builds and the shapes are right** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. Task 1's deliberate failure is now resolved. + +```bash +GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +Confirm nothing still reads the deleted Paddle fields on `Plan`: + +```bash +grep -rn "PaddlePriceIDs\|PaddleProductID" admin/ adminsite/ +``` +Expected: hits **only** in `admin/internal/api/staff.go` (task 6 removes them) and `adminsite/` (task 7). Anything in `models/` means the struct edit was incomplete. + +- [ ] **Step 9: Verify the migration against a copy of live data** + +This pass rewrites plan and instance rows, so do it against a **restored copy**, never live. + +```bash +mongorestore --uri "$SCRATCH_ADMIN_URI" --drop --archive=/path/to/admin-dump.archive +``` + +Run admin against the scratch database (the compose/scratch procedure from plan 3), then: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.plans.find({},{deployment:1,tier:1,support_level:1,"base_limits.max_servers":1,_id:0}).toArray()' +``` +Expected: **exactly six** rows; `(cloud,free)`, `(cloud,professional)`, `(cloud,enterprise)`, `(self_hosted,free)`, `(self_hosted,professional)`, `(self_hosted,enterprise)`; every one with a non-empty `support_level`; **no row with tier `self_hosted`**; `base_limits.max_servers` of 3, 3, 10, 3, 3, 10. + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.plans.find({limits:{$exists:true}}).count()' +``` +Expected: `0` — the rename left nothing behind. + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.catalogue.countDocuments({})' +``` +Expected: `16`. + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.catalogue.countDocuments({tier:"free"})' +``` +Expected: `0` — Free is priced by nothing, on purpose. + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.entitlements.find({},{instance_id:1,tier:1,"granted.servers":1,"granted.features":1,_id:0}).toArray()' +``` +Expected: one row per non-deleted instance that has a tier. **Read the `granted.servers` values.** Every pre-metering Professional or self-hosted instance will show the plan base (3), because its licence said Unlimited and an unlimited licence bought no units. + +**This is the sharp edge, and it needs a human decision before production.** Those instances are allowed fewer servers than they run the moment they are reissued. Existing licences are not reissued by this plan, so nothing breaks today, but the first renewal would cap them. Before deploying to production, list them: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.licenses.find({"limits.max_servers":-1,superseded_by:{$exists:false}},{instance_id:1,tier:1,_id:0}).toArray()' +``` + +For each, count the servers actually registered in the control plane and set `granted.servers` to at least that number by hand through the staff entitlement endpoint (task 6). Record what you set and why. **Do not automate this** — the right number is a commercial question about what each customer is entitled to, not a maximum to be inferred. + +Confirm the index swap took: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.plans.getIndexes().map(i => i.name)' +``` +Expected: includes `deployment_tier_unique`, excludes `tier_unique`. + +Restart admin once more and confirm idempotency: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'print(db.plans.countDocuments({}), db.catalogue.countDocuments({}), db.entitlements.countDocuments({}))' +``` +Expected: identical numbers to the first run. + +- [ ] **Step 10: Commit** + +```bash +git add admin/internal/models admin/internal/db/db.go admin/cmd/main.go +git commit -m "feat(admin): plans re-keyed on (deployment, tier), plus catalogue and entitlements + +plans holds base allowances and no Paddle identifiers at all — a metered +plan is priced by several components and one map on the plan row cannot +express that, so every price ID moves to the catalogue collection. + +entitlements holds desired beside granted, one row per instance. A licence +is only ever signed from granted, so an abandoned checkout leaves a desired +that reached nothing. + +The two Free plans get no catalogue rows. That absence is what keeps Free +outside Paddle: with nothing to price, no checkout can be built for it. + +plans.tier_unique is dropped by name; Mongo will not replace an index +implicitly and the old one refuses the second row of every tier. + +The entitlement backfill maps an Unlimited max_servers back to the plan +base, because an unlimited licence bought no server units. That under- +states what pre-metering customers run, deliberately and visibly, so the +number is set by a human before production rather than inferred. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: The catalogue package — both folds + +**Files:** +- Create: `admin/internal/catalogue/catalogue.go`, `admin/internal/catalogue/items.go` + +**Interfaces:** +- Consumes: `models.CatalogueFor`, `models.CatalogueRow`, `models.Plan`, `models.Config`, `models.KindBase/KindLimit/KindFeature`, `license.Limits`, `license.TermsFor`. +- Produces: + - `catalogue.Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error)` + - `catalogue.Item` with `PriceID string`, `Quantity int` + - `catalogue.LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error)` + - `catalogue.Match` with `Deployment`, `Tier`, `Term string`, `Servers int`, `Features []string` + - `catalogue.ResolveItems(ctx context.Context, env string, items []Item) (Match, error)` + - `catalogue.ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced` + +- [ ] **Step 1: Write the fold to a licence** + +Create `admin/internal/catalogue/catalogue.go`: + +```go +// Package catalogue turns an entitlement into the two things derived from it: +// the limits and features a licence grants, and the Paddle line items a +// subscription is made of. +// +// Both folds live here so the arithmetic exists once. The temptation is to +// compute limits in the issuer and quantities in the checkout, and then the two +// disagree about whether the base allowance is included in the number — which is +// a bug that bills a customer for three servers they were given. +package catalogue + +import ( + "context" + "errors" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" +) + +var ( + // ErrUnknownPrice means an item named a price ID no catalogue row claims. + // + // This is always a configuration error and never a customer error: someone + // bought something at a price we cannot map to a plan. It must fail loudly + // rather than guess a tier — a guessed tier is a wrong licence with no + // record of why. + ErrUnknownPrice = errors.New("no catalogue row claims that price ID") + + // ErrNoBaseItem means no item matched a base row, so the subscription names + // no plan. Quantities are meaningless without one. + ErrNoBaseItem = errors.New("no item matches a base price; the subscription names no plan") + + // ErrTermNotSold means a price resolved to a term its deployment does not + // sell — in practice a self-hosted monthly price. + ErrTermNotSold = errors.New("that deployment does not sell that term") + + // ErrUnpriced means a component needed for this configuration has no price + // ID in this environment. Refusing is correct: a checkout that silently + // drops a paid line item gives away the thing it was meant to charge for. + ErrUnpriced = errors.New("component has no price in this environment") +) + +// Resolve folds a configuration into the limits and features a licence grants. +// +// Limits start at the plan's base and each metered component adds its configured +// amount. Features are the plan's base features plus the configured ones, +// deduplicated and filtered to keys the catalogue actually offers — a stale +// feature key in a stored entitlement must not survive into a signed payload. +func Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error) { + rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier) + if err != nil { + return license.Limits{}, nil, err + } + + limits := plan.BaseLimits + offered := map[string]bool{} + for _, r := range rows { + switch r.Kind { + case models.KindLimit: + if err := addLimit(&limits, r.LimitKey, configured(cfg, r.LimitKey), plan.BaseLimits); err != nil { + return license.Limits{}, nil, err + } + case models.KindFeature: + offered[r.FeatureKey] = true + } + } + + seen := map[string]bool{} + features := []string{} + for _, f := range plan.BaseFeatures { + if !seen[f] { + seen[f] = true + features = append(features, f) + } + } + for _, f := range cfg.Features { + if seen[f] || !offered[f] { + continue + } + seen[f] = true + features = append(features, f) + } + return limits, features, nil +} + +// configured reads the configured total for one metered limit key. +// +// A switch rather than reflection, so every metered dimension is greppable and +// adding one is a visible edit here as well as a catalogue row. +func configured(cfg models.Config, limitKey string) int { + switch limitKey { + case models.LimitKeyServers: + return cfg.Servers + default: + return 0 + } +} + +// addLimit sets a metered limit to its configured total. +// +// The configured value is a TOTAL, not an increment, so this assigns rather than +// adds. A base of Unlimited is left alone: nothing can be added to no cap, and a +// plan that meters an already-unlimited dimension is a configuration mistake +// rather than something to compute around. +func addLimit(l *license.Limits, limitKey string, total int, base license.Limits) error { + switch limitKey { + case models.LimitKeyServers: + if base.MaxServers == license.Unlimited { + return nil + } + if total > base.MaxServers { + l.MaxServers = total + } + return nil + case "": + return fmt.Errorf("catalogue limit row has no limit_key") + default: + return fmt.Errorf("%w: limit_key %q", ErrUnknownPrice, limitKey) + } +} +``` + +- [ ] **Step 2: Write the line-item folds, forward and reverse** + +Create `admin/internal/catalogue/items.go`: + +```go +package catalogue + +import ( + "context" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" +) + +// Item is one Paddle line item: a price and how many of it. +type Item struct { + PriceID string `json:"price_id"` + Quantity int `json:"quantity"` +} + +// LineItems builds the subscription items for a configuration. +// +// The base row is always quantity 1. A metered row's quantity is the configured +// TOTAL minus the plan's base allowance, so a Professional customer at exactly +// three servers has a single-item subscription rather than one with a zero +// quantity Paddle would reject. A feature with no price in this environment +// produces no item and is granted free. +func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) { + if !sells(plan.Deployment, term) { + return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term) + } + rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fmt.Errorf("%w: %s/%s is priced by nothing", + ErrUnpriced, plan.Deployment, plan.Tier) + } + + wanted := map[string]bool{} + for _, f := range cfg.Features { + wanted[f] = true + } + + items := []Item{} + for _, r := range rows { + switch r.Kind { + case models.KindBase: + id := r.PriceID(env, term) + if id == "" { + return nil, fmt.Errorf("%w: base price for %s/%s in %s", + ErrUnpriced, plan.Deployment, plan.Tier, env) + } + items = append(items, Item{PriceID: id, Quantity: 1}) + + case models.KindLimit: + qty := billable(cfg, r.LimitKey, plan.BaseLimits) + if qty <= 0 { + continue + } + id := r.PriceID(env, term) + if id == "" { + return nil, fmt.Errorf("%w: %s price for %s/%s in %s", + ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env) + } + items = append(items, Item{PriceID: id, Quantity: qty}) + + case models.KindFeature: + if !wanted[r.FeatureKey] { + continue + } + id := r.PriceID(env, term) + if id == "" { + // Free to toggle. Resolve() still grants it. + continue + } + items = append(items, Item{PriceID: id, Quantity: 1}) + } + } + return items, nil +} + +// billable is how many UNITS to charge for a metered dimension. +// +// The configured value is the total the customer sees, which includes the base +// allowance they were given. Charging for that base is the single most likely +// bug in this file, so the subtraction lives here and nowhere else. +func billable(cfg models.Config, limitKey string, base license.Limits) int { + switch limitKey { + case models.LimitKeyServers: + if base.MaxServers == license.Unlimited { + return 0 + } + return cfg.Servers - base.MaxServers + default: + return 0 + } +} + +// Match is what an item list says about itself. +type Match struct { + Deployment string + Tier string + Term string + Servers int + Features []string +} + +// ResolveItems maps a full item list back to a plan and a configuration. +// +// This replaces a price-ID-to-tier lookup, which cannot work once a subscription +// has several prices. The base item identifies the plan and the term; everything +// else is read relative to it. An item matching nothing fails the whole list. +// +// Only the running environment's IDs are consulted, so a production process +// cannot be talked into resolving a sandbox price by a forged or misrouted +// event. +// +// It is a function of the COMPLETE list, which is what keeps out-of-order +// delivery correct by construction: Paddle sends every item on every +// subscription event, so reading all of them is reading current state rather +// than a transition. +func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) { + all, err := models.AllCatalogue(ctx) + if err != nil { + return Match{}, err + } + + // Pass 1: find the base item. Until we know the plan, no other item means + // anything — a quantity of 7 is 7 of what? + var m Match + found := false + for _, it := range items { + for _, r := range all { + if r.Kind != models.KindBase { + continue + } + for _, term := range []string{"monthly", "annual"} { + if r.PriceID(env, term) != it.PriceID || it.PriceID == "" { + continue + } + if found { + return Match{}, fmt.Errorf( + "item list names two plans: %s/%s and %s/%s", + m.Deployment, m.Tier, r.Deployment, r.Tier) + } + m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term + found = true + } + } + } + if !found { + return Match{}, ErrNoBaseItem + } + if !sells(m.Deployment, m.Term) { + return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue", + ErrTermNotSold, m.Deployment, m.Term) + } + + plan, err := models.GetPlan(ctx, m.Deployment, m.Tier) + if err != nil { + return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w", + m.Deployment, m.Tier, err) + } + m.Servers = plan.BaseLimits.MaxServers + m.Features = []string{} + + // Pass 2: everything else, relative to that plan. An item matching no row of + // this plan is a configuration error even if it matches some other plan's + // row — mixing two plans in one subscription is not a thing we sell. + rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier) + if err != nil { + return Match{}, err + } + for _, it := range items { + matched := false + for _, r := range rows { + if r.PriceID(env, m.Term) != it.PriceID { + continue + } + matched = true + switch r.Kind { + case models.KindBase: + // Already handled. + case models.KindLimit: + if r.LimitKey == models.LimitKeyServers { + m.Servers = plan.BaseLimits.MaxServers + it.Quantity + } + case models.KindFeature: + m.Features = append(m.Features, r.FeatureKey) + } + } + if !matched { + return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)", + ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier) + } + } + return m, nil +} + +// sells reports whether a deployment offers a term. +func sells(deployment, term string) bool { + for _, t := range license.TermsFor(deployment) { + if t == term { + return true + } + } + return false +} +``` + +- [ ] **Step 3: Confirm it compiles and the folds agree** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +```bash +GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +The base allowance must be included in exactly one of the two folds. Confirm the subtraction exists once: + +```bash +grep -rn "BaseLimits.MaxServers" admin/internal/catalogue/ +``` +Expected: four hits — `addLimit`'s Unlimited guard and comparison in `catalogue.go`, `billable`'s guard and subtraction in `items.go`, plus the two reconstruction lines in `ResolveItems`. There must be **no** subtraction anywhere outside `billable`. + +```bash +grep -rn "Servers - \|Servers-" admin/ --include=*.go +``` +Expected: exactly one hit, in `billable`. + +- [ ] **Step 4: Commit** + +```bash +git add admin/internal/catalogue +git commit -m "feat(admin): the catalogue folds, entitlement to licence and to line items + +Both folds live in one package because the base allowance has to be +included in exactly one of them. Computing limits in the issuer and +quantities in the checkout is how a customer gets billed for the three +servers their plan gave them; the subtraction now exists once, in billable. + +ResolveItems replaces a price-to-tier lookup, which cannot work once a +subscription has several prices. The base item names the plan and the term +and everything else is read relative to it. It reads the complete item +list, which is what keeps out-of-order webhook delivery correct by +construction rather than by special case. + +An item matching no row fails the whole list. A guessed tier is a wrong +licence with no record of why. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Issue from the entitlement + +**Files:** +- Modify: `admin/internal/licensing/issue.go`, `admin/internal/api/customer.go` + +**Interfaces:** +- Consumes: `models.GetEntitlement`, `models.ErrNoEntitlement`, `models.GetPlan(ctx, deployment, tier)`, `catalogue.Resolve`, `license.NormaliseTier`. +- Produces: + - `licensing.Issue` snapshotting the entitlement + - `licensing.ErrFreeLimit` scoped per deployment + +- [ ] **Step 1: Resolve the plan by deployment and issue from the entitlement** + +In `admin/internal/licensing/issue.go`, replace the plan lookup and the payload/record construction. Find: + +```go + plan, err := models.GetPlan(ctx, in.Tier) + if err != nil { + return nil, ErrUnknownTier + } +``` + +and replace with: + +```go + // The plan is looked up by the INSTANCE's deployment, not by a caller's + // guess. That is what makes the deployment comparison below a consistency + // check rather than the thing that decides which plan applies. + plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier) + if err != nil { + return nil, ErrUnknownTier + } +``` + +Then, after the `checkFreeLimit` block and before `now := time.Now().UTC()`, insert the entitlement resolution: + +```go + // What this licence grants comes from the instance's entitlement, not from + // the plan. The plan is only the base. + // + // An instance with no entitlement gets the plan's base, which covers staff + // manual issuance and anything predating the backfill. Falling back is + // deliberate: refusing here would make a missing row an outage rather than a + // default. + limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty()) + ent, entErr := models.GetEntitlement(ctx, inst.InstanceID) + switch { + case entErr == nil: + // Granted, never Desired. A configuration nobody has paid for must not + // reach a signed payload. + limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted) + if err != nil { + return nil, fmt.Errorf("resolve entitlement: %w", err) + } + case errors.Is(entErr, models.ErrNoEntitlement): + log.Printf("licensing: instance %s has no entitlement; issuing plan base", + inst.InstanceID) + default: + return nil, fmt.Errorf("read entitlement: %w", entErr) + } +``` + +Replace the two places that read `plan.Limits` and `plan.Features` — the `license.License` payload literal and the `models.License` record literal — so both read the resolved values: + +```go + // Snapshotted, not referenced: editing a plan or an entitlement tomorrow + // must not change what this licence grants. + Limits: limits, + Features: features, + SupportLevel: plan.SupportLevel, +``` + +and in the `models.License` literal: + +```go + Limits: limits, + Features: models.Features(features).OrEmpty(), +``` + +Add `"log"` and `"github.com/mrhid6/vantage/admin/internal/catalogue"` to the imports. + +- [ ] **Step 2: Scope the Free limit per deployment** + +Still in `issue.go`, replace `checkFreeLimit` and its call. The call becomes: + +```go + if plan.Tier == license.TierFree { + if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil { + return nil, err + } + } +``` + +And the function: + +```go +// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT. +// +// It used to be one per account, which was sufficient while Free existed only on +// cloud. With a self-hosted Free plan, an account-wide count would refuse a +// self-hosted Free instance to anyone holding a cloud one, and tell them about a +// limit they have not reached. +// +// Cancelled instances do not count: a customer who cancelled their Free instance +// is allowed another one. +func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error { + n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ + "account_id": accountID, + "deployment": deployment, + "tier": license.TierFree, + "status": bson.M{"$ne": models.StatusCancelled}, + "instance_id": bson.M{"$ne": exceptInstanceID}, + }) + if err != nil { + return err + } + if n > 0 { + return ErrFreeLimit + } + return nil +} +``` + +And make the error name the deployment, since one account can now legitimately hold two Free instances: + +```go + ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type") +``` + +- [ ] **Step 3: Scope the friendly pre-check the same way** + +In `admin/internal/api/customer.go`'s `createInstance`, the pre-check must agree with the issuer or it will refuse things the issuer would allow. Add the deployment to its filter: + +```go + // Pre-check the Free rule so we never create an instance we then cannot + // licence. licensing.Issue enforces it too; this is the friendly refusal. + // + // Scoped to cloud because that is what this endpoint creates. It MUST match + // checkFreeLimit's scoping — a pre-check stricter than the issuer refuses + // something that would have worked. + n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ + "account_id": s.AccountID, + "deployment": license.DeploymentCloud, + "tier": license.TierFree, + "status": bson.M{"$ne": models.StatusCancelled}, + }) +``` + +And its message: + +```go + if n > 0 { + c.JSON(http.StatusConflict, gin.H{ + "error": "this account already has a Free cloud instance"}) + return + } +``` + +- [ ] **Step 4: Confirm the snapshot actually comes from the entitlement** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +Confirm nothing reads the plan's limits into a licence any more: + +```bash +grep -n "plan.Limits\|plan.Features" admin/internal/licensing/issue.go +``` +Expected: no output. Both were replaced; `plan.BaseLimits` and `plan.BaseFeatures` appear only in the fallback assignment. + +Confirm `Desired` is never read by the issuer: + +```bash +grep -rn "\.Desired" admin/internal/licensing/ +``` +Expected: no output. Issuing from `Desired` would grant something nobody paid for, and this grep is the guard. + +Against a scratch database with admin running, set an entitlement by hand and issue: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval ' + db.entitlements.updateOne({instance_id:""}, + {$set:{"granted.servers":9,"granted.features":["console"], + "desired.servers":9,"desired.features":["console"]}})' +``` + +Then issue through the staff endpoint and read the licence back: + +```bash +curl -s -b /tmp/staffcj -X POST \ + https://vantage-hq.hostxtra.co.uk/api/staff/instances//issue \ + -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly"}' + +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval ' + db.licenses.find({instance_id:""},{"limits.max_servers":1,features:1,tier:1,_id:0}) + .sort({issued_at:-1}).limit(1).toArray()' +``` +Expected: `limits.max_servers: 9`, `features: ["console"]`. If it reports `3`, the fallback is being taken — check the entitlement's `instance_id` matches. + +Now confirm the Free rule per deployment. With an account holding a cloud Free instance, issue a Free licence against a self-hosted instance of the same account: +Expected: success. Before this task it would have been refused with `ErrFreeLimit`. + +Then attempt a second cloud Free instance for that account: +Expected: refused, `409`, "already has a Free cloud instance". + +- [ ] **Step 5: Commit** + +```bash +git add admin/internal/licensing/issue.go admin/internal/api/customer.go +git commit -m "feat(admin): issue from the entitlement, and Free per deployment + +A licence now snapshots the instance's entitlement rather than its plan. +The plan is only the base; the entitlement is what the customer configured +and paid for. An instance without one gets the base, so a missing row is a +default rather than an outage. + +Granted, never Desired. Issuing from Desired would sign a configuration +nobody has paid for, and a grep for .Desired under licensing/ is the guard. + +The Free limit is now per account AND deployment. Account-wide was +sufficient only while Free was cloud-only; with a self-hosted Free plan it +would refuse a self-hosted instance to anyone holding a cloud one and cite +a limit they had not reached. The friendly pre-check in createInstance is +scoped to match, because a pre-check stricter than the issuer refuses what +would have worked. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: The control plane enforces every column + +**Files:** +- Create: `server/internal/services/audit_retention.go` +- Modify: `server/internal/services/licence.go`, `server/internal/services/licence_limits.go`, `server/internal/api/licence.go`, `server/internal/api/monitors.go`, `server/internal/auth/oidc.go`, `server/cmd/main.go` + +**Interfaces:** +- Consumes: `license.PlanFor`, `license.NormaliseTier`, `license.Limits.FillUnset`, `services.GetLicenseState`, `services.DeploymentMode`. +- Produces: + - `services.CheckMonitorLimit(instanceID string) error` + - `services.LicenseUsage` returning a fourth count + - `services.StartAuditSweeper()` + +- [ ] **Step 1: Fill unset limits at the single decode site** + +In `server/internal/services/licence.go`, `stateFromResult` is the one place a payload becomes a `LicenseState`. Replace it: + +```go +func stateFromResult(res license.Result, source string) LicenseState { + feats := map[string]bool{} + for _, f := range res.License.Features { + feats[f] = true + } + + // A licence signed before max_monitors and audit_retention_days existed + // decodes them as 0, which would read as "no monitors" and "trim the audit + // log to nothing". Fill from the seed plan for the tier the licence names. + // + // This is the only decode site, which is why the fill belongs here rather + // than at each of the places that reads a limit. + limits := res.License.Limits + deployment, tier := license.NormaliseTier(res.License.Deployment, res.License.Tier) + if base, ok := license.PlanFor(deployment, tier); ok { + limits = limits.FillUnset(base.Limits) + } + + s := LicenseState{ + Status: res.State, + Reason: res.Reason, + Tier: res.License.Tier, + SupportLevel: res.License.SupportLevel, + Limits: limits, + Features: feats, + Source: source, + } + if !res.License.ExpiresAt.IsZero() { + exp := res.License.ExpiresAt + s.ExpiresAt = &exp + } + return s +} +``` + +Add `SupportLevel` to the `LicenseState` struct, after `Tier`: + +```go + Tier string `json:"tier,omitempty"` + SupportLevel string `json:"support_level,omitempty"` +``` + +- [ ] **Step 2: Add the monitor cap** + +In `server/internal/services/licence_limits.go`, add after `CheckChannelLimit`: + +```go +// CheckMonitorLimit refuses a new monitor when the instance is at its cap. +// +// Counts live rows only, like every other check here. An instance already over +// its cap keeps every monitor it has and they keep executing — the licence +// expiry story is that monitoring never stops, so truncating here would +// contradict it. +func CheckMonitorLimit(instanceID string) error { + st := GetLicenseState(instanceID) + ctx, cancel := limitCtx() + defer cancel() + + n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if !license.WithinLimit(int(n), st.Limits.MaxMonitors) { + return &LimitError{Limit: "max_monitors", Current: int(n), Max: st.Limits.MaxMonitors} + } + return nil +} +``` + +Replace `LicenseUsage` so it reports monitors too: + +```go +// LicenseUsage reports current counts, so the UI can say "12 of 3 servers" +// honestly when an instance is over its cap rather than pretending. +func LicenseUsage(instanceID string) (servers, monitors, secretGroups, channels int) { + ctx, cancel := limitCtx() + defer cancel() + + if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil { + servers = int(n) + } + if n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil { + monitors = int(n) + } + var groups []string + if err := db.Col("secrets").Distinct(ctx, "group", + bson.M{"instance_id": instanceID}).Decode(&groups); err == nil { + secretGroups = len(groups) + } + if n, err := db.Col("notification_channels").CountDocuments(ctx, + bson.M{"instance_id": instanceID}); err == nil { + channels = int(n) + } + return +} +``` + +- [ ] **Step 3: Enforce it on create, and report it** + +In `server/internal/api/monitors.go`'s `createMonitor`, add the check after validation and before the service call: + +```go + if err := services.CheckMonitorLimit(auth.InstanceID(c)); err != nil { + if limitStatus(c, err) { + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } +``` + +In `server/internal/api/licence.go`, add monitors to the usage response and the two new fields to the licence response: + +```go +type licenceUsageResponse struct { + Servers int `json:"servers"` + Monitors int `json:"monitors"` + SecretGroups int `json:"secret_groups"` + Channels int `json:"channels"` +} +``` + +Add to `licenceResponse`, after `Tier`: + +```go + SupportLevel string `json:"support_level,omitempty"` +``` + +Then in `getLicence`, update the destructuring and both literals: + +```go + servers, monitors, groups, channels := services.LicenseUsage(instanceID) +``` + +and wherever `licenceUsageResponse` is constructed, add `Monitors: monitors,`; wherever `licenceResponse` is constructed, add `SupportLevel: st.SupportLevel,`. + +- [ ] **Step 4: Gate the OIDC callback** + +In `server/internal/auth/oidc.go`'s `HandleOIDCCallback`, add the check immediately after `ConsumeStateInstance` and before `providerForInstance`: + +```go + // The start handler checks this too, but an ungated callback is the half + // that matters: a start that refuses is a dead end, while a callback that + // completes signs somebody in. A licence that lapsed mid-flow stops the + // exchange here rather than after it. + // + // Resolved from the consumed state rather than from the host, because on + // this route the instance is whatever the state said and nobody is signed + // in yet. + if !services.GetLicenseState(instanceID).Feature("oidc") { + c.Redirect(http.StatusFound, "/login?error=oidc_unavailable") + return + } +``` + +- [ ] **Step 5: Write the audit retention sweep** + +Create `server/internal/services/audit_retention.go`: + +```go +package services + +import ( + "context" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// StartAuditSweeper trims audit logs past their licensed retention. +// +// An immediate pass then daily, following StartLogSweeper's shape. Daily rather +// than hourly because the unit of retention is a day: sweeping twenty-four times +// to delete the same nothing is load without a purpose. +func StartAuditSweeper() { + go func() { + sweepAuditLogs() + t := time.NewTicker(24 * time.Hour) + defer t.Stop() + for range t.C { + sweepAuditLogs() + } + }() +} + +// sweepAuditLogs deletes entries older than each instance's licensed retention. +// +// This is the only part of this subsystem that deletes customer data, so it is +// deliberately conservative in three ways. +// +// It reads the CURRENT licence each run rather than caching a value, so raising +// a customer's retention takes effect on the next sweep instead of whenever a +// process restarts. +// +// It skips an instance whose licence is not valid. A lapsed instance must not +// have its history trimmed on the expired term's allowance — expiry degrades to +// read-only, and deleting more of somebody's audit trail is not read-only. +// +// It skips Unlimited and any non-positive value. A licence that decodes as zero +// has already been filled from the plan base at the decode site, so a zero here +// means something is wrong and doing nothing is the right response to that. +func sweepAuditLogs() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + var ids []string + if err := db.Col("instances").Distinct(ctx, "instance_id", bson.M{}).Decode(&ids); err != nil { + log.Printf("audit sweep: list instances: %v", err) + return + } + + for _, id := range ids { + st := GetLicenseState(id) + if !st.Active() { + continue + } + days := st.Limits.AuditRetentionDays + if days == license.Unlimited || days <= 0 { + continue + } + cutoff := time.Now().UTC().AddDate(0, 0, -days) + res, err := db.Col("audit_logs").DeleteMany(ctx, bson.M{ + "instance_id": id, + "created_at": bson.M{"$lt": cutoff}, + }) + if err != nil { + log.Printf("audit sweep: instance %s: %v", id, err) + continue + } + if res.DeletedCount > 0 { + log.Printf("audit sweep: instance %s: removed %d entries older than %d days", + id, res.DeletedCount, days) + } + } +} +``` + +**Confirm the timestamp field name before running this.** The sweep filters on `created_at`; check the audit model actually uses it: + +```bash +grep -n "bson:\"" server/internal/models/*.go | grep -i audit +``` +If the field is named differently, use that name — a filter on a field that does not exist deletes **every** row, which is why this check is a step rather than an assumption. + +- [ ] **Step 6: Start the sweeper** + +In `server/cmd/main.go`, beside the existing `services.StartLogSweeper()` call: + +```go + services.StartAuditSweeper() +``` + +- [ ] **Step 7: Confirm enforcement** + +```bash +GOWORK=off /tmp/gorun.sh server go build ./... +``` +Expected: no output. If `LicenseUsage` callers were missed the compiler names them — it gained a return value specifically so they cannot be missed silently. + +```bash +GOWORK=off /tmp/gorun.sh server go vet ./... +``` +Expected: no output. + +Against a running server with a Free licence (3 monitors), create four monitors: + +```bash +for i in 1 2 3 4; do + curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/cj -X POST localhost:8080/api/monitors \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"m$i\",\"type\":\"http\",\"target\":\"https://example.com\"}" +done +``` +Expected: `201 201 201 403`. + +```bash +curl -s -b /tmp/cj -X POST localhost:8080/api/monitors \ + -H 'Content-Type: application/json' \ + -d '{"name":"m5","type":"http","target":"https://example.com"}' | python -m json.tool +``` +Expected: `{"error": "limit_exceeded", "limit": "max_monitors", "current": 3, "max": 3}`. + +Confirm the usage report: + +```bash +curl -s -b /tmp/cj localhost:8080/api/license | python -m json.tool +``` +Expected: `usage.monitors: 3`, `limits.max_monitors: 3`, `limits.audit_retention_days: 30`, and a `support_level` of `community`. + +Confirm the fill works on an old blob. Take a licence signed before this change (any existing one) and store it: +Expected: `limits.max_monitors` reports `3` for Free rather than `0`, and `audit_retention_days` reports `30` rather than `0`. **A `0` here means the fill is not running** — check `NormaliseTier` is being applied to the licence's own deployment and tier. + +Confirm the OIDC callback refuses. On an instance whose licence lacks `oidc`: + +```bash +curl -si "localhost:8080/auth/oidc/callback?state=anything&code=anything" | head -3 +``` +Expected: `400` "invalid state" — the state check runs first, which is correct. To exercise the gate itself, start a real SSO flow on an instance that HAS the feature, then remove the feature from its licence before completing the callback: +Expected: `302` to `/login?error=oidc_unavailable`, and **no session cookie set**. + +Confirm the sweep. Insert an old audit row and run the server: + +```bash +mongosh "$SCRATCH_CONTROL_URI" --quiet --eval ' + db.audit_logs.insertOne({instance_id:"", action:"test.old", + created_at: new Date(Date.now() - 1000*60*60*24*400)})' +``` +With a Free licence (30 days), restart the server and check the log line, then: + +```bash +mongosh "$SCRATCH_CONTROL_URI" --quiet --eval \ + 'db.audit_logs.countDocuments({instance_id:"", action:"test.old"})' +``` +Expected: `0`, and a log line naming the count. + +Now the conservatism. Let the licence expire (or store an expired one), insert another old row, restart: +Expected: the row **survives**, and no sweep line for that instance. A lapsed instance is not swept. + +- [ ] **Step 8: Commit** + +```bash +git add server/internal/services server/internal/api server/internal/auth/oidc.go server/cmd/main.go +git commit -m "feat(server): enforce the monitor cap, audit retention, and the OIDC callback + +Three of the pricing table's columns were enforced by nothing. + +CheckMonitorLimit joins the three existing checks with the same shape: +refuse a new one at the cap, never truncate what exists. LicenseUsage +gained a return value so no caller could miss it silently. + +The audit sweep is the only thing here that deletes customer data, so it +reads the current licence each run, skips Unlimited, and skips any instance +whose licence is not valid — expiry degrades to read-only, and trimming +more of somebody's history is not read-only. + +HandleOIDCStart already checked the feature; HandleOIDCCallback did not, +and the unguarded half is the one that completes a sign-in. + +Unset limits are filled from the seed plan at stateFromResult, the single +decode site. A licence signed before max_monitors existed decodes it as 0, +and 0 would mean no monitors and an audit log trimmed to nothing. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: Admin endpoints for plans, the catalogue and entitlements + +**Files:** +- Create: `admin/internal/api/entitlement.go` +- Modify: `admin/internal/api/staff.go`, `admin/internal/api/routes.go` + +**Interfaces:** +- Consumes: `models.AllCatalogue`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `catalogue.Resolve`, `ownedInstance`, `auth.Current`, `audit.Write`. +- Produces: + - `PUT /api/staff/plans/:deployment/:tier` + - `GET /api/staff/catalogue` + - `PUT /api/staff/catalogue` + - `GET /api/staff/instances/:id/entitlement` + - `PUT /api/staff/instances/:id/entitlement` + - `GET /api/instances/:id/entitlement` + +- [ ] **Step 1: Re-key the plan update and drop the Paddle fields** + +In `admin/internal/api/staff.go`, replace `staffUpdatePlan`: + +```go +// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON. +// Existing licences snapshotted their plan at issue time and are unaffected — +// the same rule as workflow_runs.steps_snapshot. +// +// It writes no Paddle identifiers: those live in the catalogue, because a +// metered plan is priced by several components. +func staffUpdatePlan(c *gin.Context) { + var body models.Plan + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"}) + return + } + deployment, tier := c.Param("deployment"), c.Param("tier") + set := bson.M{ + "name": body.Name, + "base_limits": body.BaseLimits, + "base_features": body.BaseFeatures.OrEmpty(), + "support_level": body.SupportLevel, + "active": body.Active, + } + res, err := db.Admin("plans").UpdateOne(c.Request.Context(), + bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if res.MatchedCount == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"}) + return + } + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: staffActor(c), + Action: "plan.updated", + Target: deployment + "/" + tier, + Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t", + body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors, + body.SupportLevel, body.Active), + }) + c.JSON(http.StatusOK, gin.H{"updated": true}) +} +``` + +Use whatever the file's existing staff-actor helper is called for `staffActor(c)` — check the neighbouring handlers and match. Add `"fmt"` and the `audit` import if absent. + +- [ ] **Step 2: Add the catalogue handlers** + +Append to `admin/internal/api/staff.go`: + +```go +func staffListCatalogue(c *gin.Context) { + rows, err := models.AllCatalogue(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rows) +} + +// staffUpdateCatalogue sets the price IDs on one component. +// +// The component is addressed by its natural key rather than by an ObjectID, so +// the staff UI never has to hold a Mongo identifier and a seeded row can be +// updated the moment it exists. Only price IDs are writable: a row's kind, plan +// and key are seeded by SeedCatalogue, and letting a form invent a limit_key +// would let it invent a limit nothing enforces. +func staffUpdateCatalogue(c *gin.Context) { + var body struct { + Kind string `json:"kind"` + Deployment string `json:"deployment"` + Tier string `json:"tier"` + LimitKey string `json:"limit_key"` + FeatureKey string `json:"feature_key"` + PriceIDs map[string]map[string]string `json:"price_ids"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"}) + return + } + + // Refuse a price on a term the deployment does not sell. Storing one would + // mean a resolved self-hosted monthly price later, which the resolver treats + // as a configuration error — better to refuse it at the point somebody + // pastes it, while they are looking at the screen. + for env, byTerm := range body.PriceIDs { + for term, id := range byTerm { + if id == "" { + continue + } + if !termSold(body.Deployment, term) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s does not sell %s (environment %s)", + body.Deployment, term, env)}) + return + } + } + } + + filter := bson.M{ + "kind": body.Kind, + "deployment": body.Deployment, + "tier": body.Tier, + "limit_key": body.LimitKey, + "feature_key": body.FeatureKey, + } + res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter, + bson.M{"$set": bson.M{"price_ids": body.PriceIDs}}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if res.MatchedCount == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no such component"}) + return + } + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: staffActor(c), + Action: "catalogue.updated", + Target: body.Deployment + "/" + body.Tier + "/" + body.Kind, + Detail: body.LimitKey + body.FeatureKey, + }) + c.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func termSold(deployment, term string) bool { + for _, t := range license.TermsFor(deployment) { + if t == term { + return true + } + } + return false +} +``` + +- [ ] **Step 3: Write the entitlement handlers** + +Create `admin/internal/api/entitlement.go`: + +```go +package api + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/catalogue" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// entitlementBody is what a caller may set. +// +// Only Desired is writable. Granted is what a payment confirmed, and letting a +// form set it would let the portal grant itself a licence — which is the one +// thing this whole split exists to prevent. Staff promote Granted explicitly +// through a separate flag, because staff issuing a licence to somebody who has +// not paid is a real operation with a real reason, and it should be one they +// took on purpose and left an audit row for. +type entitlementBody struct { + Tier string `json:"tier"` + Term string `json:"term"` + Servers int `json:"servers"` + Features []string `json:"features"` + // Grant promotes Desired into Granted in the same write. Staff only. + Grant bool `json:"grant"` +} + +// getEntitlement serves the customer's own view of one instance's configuration. +func getEntitlement(c *gin.Context) { + inst, ok := ownedInstance(c) + if !ok { + return + } + ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID) + if errors.Is(err, models.ErrNoEntitlement) { + c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()}) +} + +func staffGetEntitlement(c *gin.Context) { + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(c.Request.Context(), + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"}) + return + } + ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID) + if errors.Is(err, models.ErrNoEntitlement) { + c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()}) +} + +// staffSetEntitlement writes an instance's configuration. +// +// This is the endpoint that makes metering usable before Paddle exists: staff +// configure, then issue. It does NOT issue — recording what an instance is +// allowed and signing a licence for it stay separate, so a bad configuration is +// a row to correct rather than a licence to supersede. +func staffSetEntitlement(c *gin.Context) { + ctx := c.Request.Context() + var body entitlementBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entitlement"}) + return + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"}) + return + } + + tier := body.Tier + if tier == "" { + tier = inst.Tier + } + plan, err := models.GetPlan(ctx, inst.Deployment, tier) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("no plan for %s/%s", inst.Deployment, tier)}) + return + } + if !termSold(inst.Deployment, body.Term) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s does not sell %s", inst.Deployment, body.Term)}) + return + } + if body.Servers < plan.BaseLimits.MaxServers && + plan.BaseLimits.MaxServers != -1 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s includes %d servers; cannot configure fewer", + plan.Name, plan.BaseLimits.MaxServers)}) + return + } + + desired := models.Config{ + Servers: body.Servers, + Features: models.Features(body.Features).OrEmpty(), + } + + // Start from whatever is already granted, so writing a desired change never + // silently alters what the instance is currently allowed. + granted := desired + existing, err := models.GetEntitlement(ctx, inst.InstanceID) + switch { + case err == nil: + if !body.Grant { + granted = existing.Granted + } + case errors.Is(err, models.ErrNoEntitlement): + // First write. There is nothing granted to preserve, so desired becomes + // granted — an instance with an entitlement nobody has granted would + // fall back to the plan base at issue time and confuse everyone. + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Only the limits are stored. Features are NOT snapshotted onto the + // entitlement: they live in Granted.Features, and Issue resolves them again + // against the catalogue at signing time. Storing a second copy here would + // give two answers to "which features does this instance have". + limits, _, err := catalogue.Resolve(ctx, plan, granted) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ent := models.Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: inst.Deployment, + Tier: tier, + Term: body.Term, + Desired: desired, + Granted: granted, + ResolvedLimits: limits, + } + // A reduction is a fact about the future, so it carries a date. There is no + // billing period to read yet — plan 5 sets this from the subscription — so + // staff-set reductions are marked as pending without one. + if desired.Servers < granted.Servers { + now := time.Now().UTC() + ent.ScheduledChangeAt = &now + } + if existing != nil { + ent.GrantedAt = existing.GrantedAt + } + if body.Grant { + ent.GrantedAt = time.Now().UTC() + } + + if err := models.UpsertEntitlement(ctx, ent); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: staffActor(c), + Action: "entitlement.updated", + AccountID: inst.AccountID, + Target: inst.InstanceID, + Detail: fmt.Sprintf("tier=%s term=%s desired_servers=%d granted_servers=%d granted=%t", + tier, body.Term, desired.Servers, granted.Servers, body.Grant), + }) + c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()}) +} +``` + +- [ ] **Step 4: Mount the routes** + +In `admin/internal/api/routes.go`, replace the plan route and add the rest. + +Replace: + +```go + staff.PUT("/plans/:tier", staffUpdatePlan) +``` + +with: + +```go + // Plans are keyed on the pair now, so the path is too. A single :tier + // segment could name three rows. + staff.PUT("/plans/:deployment/:tier", staffUpdatePlan) + + staff.GET("/catalogue", staffListCatalogue) + staff.PUT("/catalogue", staffUpdateCatalogue) + + staff.GET("/instances/:id/entitlement", staffGetEntitlement) + staff.PUT("/instances/:id/entitlement", staffSetEntitlement) +``` + +And in the customer group, beside the other instance reads: + +```go + cust.GET("/instances/:id/entitlement", getEntitlement) +``` + +**Reading is open to any signed-in member**, matching the other instance reads. There is no customer *write* here at all — that is plan 5's `PUT /api/instances/:id/entitlement`, which needs a Paddle call to mean anything. + +- [ ] **Step 5: Confirm the endpoints** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +```bash +GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +With admin running against scratch databases and a staff session in `/tmp/staffcj`: + +```bash +curl -s -b /tmp/staffcj localhost:8083/api/staff/catalogue | python -m json.tool | head -30 +``` +Expected: sixteen rows, every `price_ids` an empty object. + +Paste a sandbox price onto the cloud Professional base: + +```bash +curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/catalogue \ + -H 'Content-Type: application/json' -d '{ + "kind":"base","deployment":"cloud","tier":"professional", + "price_ids":{"sandbox":{"monthly":"pri_test_base_m"}}}' +``` +Expected: `{"updated":true}`. + +Now the refusal that matters — a self-hosted monthly price: + +```bash +curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/catalogue \ + -H 'Content-Type: application/json' -d '{ + "kind":"base","deployment":"self_hosted","tier":"professional", + "price_ids":{"sandbox":{"monthly":"pri_nope"}}}' +``` +Expected: `400`, "self_hosted does not sell monthly". Confirm nothing was written: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.catalogue.findOne({deployment:"self_hosted",tier:"professional",kind:"base"}).price_ids' +``` +Expected: `{}` or no `monthly` key. + +Set an entitlement: + +```bash +curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances//entitlement \ + -H 'Content-Type: application/json' \ + -d '{"tier":"professional","term":"monthly","servers":9,"features":["console"],"grant":true}' \ + | python -m json.tool +``` +Expected: `resolved_limits.max_servers: 9`, `granted.servers: 9`, `pending: false`. + +Now a reduction without granting it: + +```bash +curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances//entitlement \ + -H 'Content-Type: application/json' \ + -d '{"tier":"professional","term":"monthly","servers":5,"features":["console"]}' \ + | python -m json.tool +``` +Expected: `desired.servers: 5`, **`granted.servers: 9`**, `pending: true`, `scheduled_change_at` set, and `resolved_limits.max_servers: 9`. A reduction must not lower what is granted. + +Reissue and confirm the licence still says 9: + +```bash +curl -s -b /tmp/staffcj -X POST localhost:8083/api/staff/instances//issue \ + -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly"}' +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.licenses.find({instance_id:""},{"limits.max_servers":1,_id:0}).sort({issued_at:-1}).limit(1).toArray()' +``` +Expected: `9`. **This is the single most important check in the task** — it proves a pending reduction cannot shorten what a customer currently has. + +Confirm a below-base configuration is refused: + +```bash +curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances//entitlement \ + -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly","servers":1}' +``` +Expected: `400`, "Professional includes 3 servers; cannot configure fewer". + +Confirm the customer read is scoped. As a customer of another account: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/othercj \ + localhost:8083/api/instances//entitlement +``` +Expected: `404`, never `403`. + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/api +git commit -m "feat(admin): staff endpoints for plans, the catalogue and entitlements + +This is what makes metering usable before Paddle exists: staff configure an +entitlement, then issue against it — the position spec 3 left licensing in. + +Only Desired is writable by a body. Granted is what a payment confirmed, +and a form that could set it would be a form that grants itself a licence. +Staff promote it with an explicit flag that leaves an audit row, because +granting somebody something they have not paid for is a real operation with +a real reason. + +A reduction never lowers Granted, so reissuing against a pending reduction +still signs the larger cap. That is verified rather than asserted. + +A self-hosted monthly price is refused where it is pasted, not where it is +later resolved — the resolver treating it as a configuration error is no +help to somebody who has already closed the screen. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 7: The staff plans and catalogue screens + +**Files:** +- Create: `adminsite/app/(staff)/staff/catalogue/page.tsx` +- Modify: `adminsite/lib/api.ts`, `adminsite/app/(staff)/staff/plans/page.tsx`, `adminsite/components/AppBar.tsx` + +**Interfaces:** +- Consumes: `GET/PUT /api/staff/plans/:deployment/:tier`, `GET/PUT /api/staff/catalogue`. +- Produces: + - types `Limits` (five fields), `Plan`, `CatalogueRow`, `Term`, `Deployment` + - `api.staff.plans()`, `api.staff.updatePlan(deployment, tier, plan)` + - `api.staff.catalogue()`, `api.staff.updateCatalogue(row)` + +- [ ] **Step 1: Update the types and calls** + +In `adminsite/lib/api.ts`, replace the `Limits` and `Plan` types and add the rest: + +```ts +export type Term = "monthly" | "annual"; +export type Deployment = "cloud" | "self_hosted"; + +export interface Limits { + max_servers: number; + max_monitors: number; + max_secret_groups: number; + max_channels: number; + audit_retention_days: number; +} + +export interface Plan { + deployment: Deployment; + tier: Tier; + name: string; + /* The allowance BEFORE anything is bought. Not the total — a metered + * dimension adds to it. */ + base_limits: Limits; + base_features: string[]; + support_level: string; + active: boolean; +} + +export interface CatalogueRow { + kind: "base" | "limit" | "feature"; + deployment: Deployment; + tier: Tier; + limit_key?: string; + feature_key?: string; + /* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the + * inner map; both environments are stored so promotion is a config change + * rather than a data migration. */ + price_ids?: Record>>; +} + +export interface EntitlementConfig { + servers: number; + features: string[]; +} + +export interface Entitlement { + instance_id: string; + account_id: string; + deployment: Deployment; + tier: Tier; + term: Term; + desired: EntitlementConfig; + granted: EntitlementConfig; + resolved_limits: Limits; + scheduled_change_at?: string; + granted_at: string; + updated_at: string; +} +``` + +Add `Tier` to include `"enterprise"` wherever it is declared, and **remove `"self_hosted"` from it** — it is a legacy payload value, not something a UI offers: + +```ts +export type Tier = "free" | "professional" | "enterprise"; +``` + +Then the calls, following the file's existing `staff` object shape: + +```ts + plans: () => get("/api/staff/plans"), + updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) => + put<{ updated: boolean }>( + `/api/staff/plans/${deployment}/${tier}`, plan), + catalogue: () => get("/api/staff/catalogue"), + updateCatalogue: (row: CatalogueRow) => + put<{ updated: boolean }>("/api/staff/catalogue", row), + entitlement: (id: string) => + get<{ entitlement: Entitlement; pending: boolean }>( + `/api/staff/instances/${id}/entitlement`), + setEntitlement: ( + id: string, + body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean }, + ) => + put<{ entitlement: Entitlement; pending: boolean }>( + `/api/staff/instances/${id}/entitlement`, body), +``` + +Use the file's own `get`/`put` helpers with their existing names; if it uses a single `req()`, follow that instead. + +- [ ] **Step 2: Rework the plans screen for six rows** + +In `adminsite/app/(staff)/staff/plans/page.tsx`, the screen now lists six plans grouped by deployment, edits allowances and support level, and **no longer touches price IDs at all** — those move to the catalogue screen. + +Remove the existing `PriceEditor` component and the two demo buttons ("Cap servers at 7", "Remove OIDC") entirely. Replace the plan card body with an allowance form: + +```tsx +const SUPPORT_LEVELS = [ + { value: "community", label: "Community" }, + { value: "email_24_5", label: "Email, 24/5" }, + { value: "email_call_24_7", label: "Email + call, 24/7" }, +] as const; + +const LIMIT_FIELDS = [ + { key: "max_servers", label: "Servers" }, + { key: "max_monitors", label: "Monitors" }, + { key: "max_secret_groups", label: "Secret groups" }, + { key: "max_channels", label: "Channels" }, + { key: "audit_retention_days", label: "Audit history (days)" }, +] as const; + +/* + * -1 is Unlimited everywhere in the licence payload, so the form takes it + * literally rather than inventing a checkbox. A staff screen that hides the + * sentinel is a staff screen where nobody can tell whether a plan says + * unlimited or nothing at all. + */ +function AllowanceForm({ + plan, + onSave, + saving, +}: { + plan: Plan; + onSave: (next: Plan) => void; + saving: boolean; +}) { + const [draft, setDraft] = useState(plan); + const dirty = JSON.stringify(draft) !== JSON.stringify(plan); + + return ( +
+
+ {LIMIT_FIELDS.map((f) => ( + + ))} + +
+ + + +

+ Changes apply to licences issued from now on. Existing licences + snapshotted their plan and are unaffected. +

+ + +
+ ); +} +``` + +Group the list by deployment, so six rows read as two families of three rather than a flat list of six: + +```tsx +{(["cloud", "self_hosted"] as const).map((deployment) => ( +
+

+ {deployment === "cloud" ? "Cloud" : "Self-Hosted"} +

+ {plans + .filter((p) => p.deployment === deployment) + .map((p) => ( +
+
+

{p.name}

+ + {p.deployment}/{p.tier} + +
+ save(p.deployment, p.tier, next)} + /> +
+ ))} +
+))} +``` + +Wire `save` through TanStack Query's mutation following the file's existing pattern, invalidating the plans query on success. + +- [ ] **Step 3: Build the catalogue screen** + +Create `adminsite/app/(staff)/staff/catalogue/page.tsx`. Price IDs are the one thing a human pastes in from another system, so both environments are editable side by side — seeing them together is what makes "did I paste the sandbox one" answerable. + +```tsx +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import PageHeader from "@/components/PageHeader"; +import PageFrame from "@/components/PageFrame"; +import { api, type CatalogueRow, type Term } from "@/lib/api"; + +const ENVS = ["sandbox", "production"] as const; + +/* Self-hosted sells annual only, so the monthly cell is not rendered for it + * rather than rendered and rejected. The backend refuses one either way; this is + * so nobody types into a field that cannot be saved. */ +function termsFor(deployment: string): Term[] { + return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"]; +} + +function componentLabel(r: CatalogueRow): string { + if (r.kind === "base") return "Base fee"; + if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`; + return `Feature: ${r.feature_key}`; +} + +function rowKey(r: CatalogueRow): string { + return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/"); +} + +export default function CataloguePage() { + const qc = useQueryClient(); + const { data: rows = [], isLoading } = useQuery({ + queryKey: ["staff", "catalogue"], + queryFn: api.staff.catalogue, + }); + const [drafts, setDrafts] = useState>({}); + + const save = useMutation({ + mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r), + onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }), + }); + + const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`))); + + return ( + + } + rail={ + + } + > + {isLoading ? ( +

Loading…

+ ) : ( +
+ {groups.map((g) => { + const [deployment, tier] = g.split("/"); + const terms = termsFor(deployment); + return ( +
+

+ {deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "} + {tier} +

+
+ + + + + {ENVS.map((env) => + terms.map((t) => ( + + )), + )} + + + + {rows + .filter( + (r) => + r.deployment === deployment && + r.tier === tier, + ) + .map((r) => { + const k = rowKey(r); + const ids = drafts[k] ?? r.price_ids ?? {}; + const dirty = + JSON.stringify(ids) !== + JSON.stringify(r.price_ids ?? {}); + return ( + + + {ENVS.map((env) => + terms.map((t) => ( + + )), + )} + + + ); + })} + +
Component + {env} / {t} + +
+ {componentLabel(r)} + + + setDrafts({ + ...drafts, + [k]: { + ...ids, + [env]: { + ...(ids[ + env + ] ?? {}), + [t]: e + .target + .value, + }, + }, + }) + } + className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink" + /> + + +
+
+
+ ); + })} +
+ )} +
+ ); +} +``` + +Match `PageHeader` and `PageFrame`'s actual prop names — read one existing staff page and follow it exactly rather than trusting the names above. + +- [ ] **Step 4: Add the nav entry** + +In `adminsite/components/AppBar.tsx`, add Catalogue to the staff nav list beside Plans. Active state is derived from `usePathname` — do not hardcode it. + +- [ ] **Step 5: Confirm it builds and carries no hex** + +```bash +sh /tmp/npmrun.sh adminsite npm run build +``` +Expected: a successful build with no type errors. A `Tier` still including `"self_hosted"` somewhere will surface here. + +```bash +grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/app/\(staff\)/staff/catalogue/ adminsite/app/\(staff\)/staff/plans/ +``` +Expected: no output. Tokens only, in every app. + +```bash +grep -rn "pri_" adminsite/ --include=*.tsx --include=*.ts | grep -v "pri_…" +``` +Expected: no output — the only literal is the placeholder text. + +Then in a browser, against the running scratch stack: open **Plans**, confirm six cards in two groups, change Professional cloud's monitors to `50`, save, reload and confirm it stuck. Open **Catalogue**, confirm self-hosted rows show **no monthly column**, paste a sandbox base price on cloud Professional, save, reload and confirm it stuck. + +- [ ] **Step 6: Commit** + +```bash +git add adminsite/lib/api.ts adminsite/app/\(staff\)/staff/plans adminsite/app/\(staff\)/staff/catalogue adminsite/components/AppBar.tsx +git commit -m "feat(adminsite): six plans, and the catalogue as its own screen + +Plans edits allowances and support level; prices moved out entirely, +because a metered plan is priced by several components and a nested map on +a plan card could not show that. + +The catalogue screen puts both environments side by side on purpose: +promoting to production is pasting production IDs, and seeing them together +is what makes \"did I paste the sandbox one\" answerable. + +Self-hosted rows render no monthly cell rather than rendering one the +backend refuses. -1 is shown literally, because a staff screen that hides +the Unlimited sentinel is one where nobody can tell unlimited from unset. + +Tier drops self_hosted: it is a legacy payload value, not something a UI +offers. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 8: The configurator, mounted for staff + +**Files:** +- Create: `adminsite/components/PlanConfigurator.tsx` +- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx` + +**Interfaces:** +- Consumes: `api.staff.plans()`, `api.staff.catalogue()`, `api.staff.entitlement(id)`, `api.staff.setEntitlement(id, body)`, types `Plan`, `CatalogueRow`, `Entitlement`. +- Produces: `` + +**This component is the one plan 5 reuses** for the customer purchase flow. Keep it free of staff-only concerns: it takes a configuration and reports changes, and it neither saves nor knows who is using it. The `grant` flag, the save button and the audit trail belong to the page, not to this. + +- [ ] **Step 1: Write the configurator** + +Create `adminsite/components/PlanConfigurator.tsx`: + +```tsx +"use client"; + +import { useMemo } from "react"; +import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api"; + +export interface PlanChoice { + tier: Tier; + term: Term; + servers: number; + features: string[]; +} + +/* Self-hosted sells annual only. The reason is in shared/license: an offline + * licence cannot be revoked, so the term length IS the revocation window. */ +function termsFor(deployment: Deployment): Term[] { + return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"]; +} + +/* + * PlanConfigurator is the whole of "what is this instance allowed", driven + * entirely by the plans and catalogue it is handed. + * + * A feature appears because a catalogue row offers it, and shows a price because + * that row has one. Nothing here is hardcoded per tier, which is what lets a new + * paid add-on ship as a staff edit rather than a frontend release. + * + * It saves nothing and knows nothing about who is using it. Staff mount it to + * set an entitlement; the customer purchase flow mounts the same component and + * hands it a checkout. + */ +export default function PlanConfigurator({ + deployment, + value, + plans, + catalogue, + onChange, + disabled, +}: { + deployment: Deployment; + value: PlanChoice; + plans: Plan[]; + catalogue: CatalogueRow[]; + onChange: (next: PlanChoice) => void; + disabled?: boolean; +}) { + const available = useMemo( + () => plans.filter((p) => p.deployment === deployment && p.active), + [plans, deployment], + ); + const plan = available.find((p) => p.tier === value.tier); + const rows = useMemo( + () => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier), + [catalogue, deployment, value.tier], + ); + const featureRows = rows.filter((r) => r.kind === "feature"); + const base = plan?.base_limits.max_servers ?? 0; + const extra = Math.max(0, value.servers - base); + + const priceOf = (r: CatalogueRow) => + r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? ""; + + return ( +
+
+ Tier +
+ {available.map((p) => ( + + ))} +
+
+ +
+ Term +
+ {termsFor(deployment).map((t) => ( + + ))} +
+ {deployment === "self_hosted" && ( +

+ Self-hosted is annual only. +

+ )} +
+ + + + {featureRows.length > 0 && ( +
+ Features + {featureRows.map((r) => { + const key = r.feature_key!; + const on = value.features.includes(key); + const priced = priceOf(r) !== ""; + return ( + + ); + })} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Mount it on the staff instance screen** + +In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add an Entitlement section. It needs three queries and one mutation, and it must show `granted` versus `desired` when they differ — a pending reduction is a fact about the account, not only about Paddle. + +```tsx +function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) { + const qc = useQueryClient(); + const { data: plans = [] } = useQuery({ + queryKey: ["staff", "plans"], + queryFn: api.staff.plans, + }); + const { data: catalogue = [] } = useQuery({ + queryKey: ["staff", "catalogue"], + queryFn: api.staff.catalogue, + }); + const { data } = useQuery({ + queryKey: ["staff", "entitlement", instanceId], + queryFn: () => api.staff.entitlement(instanceId), + retry: false, + }); + + const ent = data?.entitlement; + const [draft, setDraft] = useState(null); + const choice: PlanChoice = + draft ?? + (ent + ? { + tier: ent.tier, + term: ent.term, + servers: ent.desired.servers, + features: ent.desired.features ?? [], + } + : { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] }); + + const save = useMutation({ + mutationFn: (grant: boolean) => + api.staff.setEntitlement(instanceId, { ...choice, grant }), + onSuccess: () => { + setDraft(null); + qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] }); + }, + }); + + return ( +
+
+

Entitlement

+

+ What this instance is allowed. A licence is signed from{" "} + granted, never from desired. +

+
+ + {ent && data?.pending && ( +

+ Pending change — currently granted {ent.granted.servers} servers, + configured for {ent.desired.servers} + {ent.scheduled_change_at + ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` + : ""} + . +

+ )} + + + +
+ + +
+

+ Granting takes effect on the next licence issued. It does not issue + one. +

+ {save.error && ( +

+ {String((save.error as Error).message)} +

+ )} +
+ ); +} +``` + +Mount it in the page body beside the existing licence-history section, passing the instance's own deployment. Use the token names the app actually defines — check `adminsite/app/globals.css` for `--warn` and `--expired` and use whatever is there. + +- [ ] **Step 3: Confirm the flow end to end in the browser** + +```bash +sh /tmp/npmrun.sh adminsite npm run build +``` +Expected: a successful build. + +```bash +grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/PlanConfigurator.tsx +``` +Expected: no output. + +Against the running scratch stack, on a cloud Professional instance: + +1. Open the staff instance page. Confirm the Entitlement section shows the configurator with the tier and server count the backfill created. +2. Raise servers to `9`, tick **Browser console**, click **Save and grant**. Confirm the section reloads showing no pending banner. +3. Issue a licence from the same page. Confirm the new licence row reports `max_servers: 9` and features `["console"]`. +4. Lower servers to `5` and click **Save as configured**. Confirm the pending banner appears naming both numbers, and that issuing again **still produces a 9-server licence**. +5. Switch tier to Enterprise. Confirm the server count clamps up to `10` rather than staying at `5`. +6. Open a self-hosted instance. Confirm the Term control offers **annual only** and says so. + +- [ ] **Step 4: Commit** + +```bash +git add adminsite/components/PlanConfigurator.tsx adminsite/app/\(staff\)/staff/instances +git commit -m "feat(adminsite): the plan configurator, mounted for staff first + +One component, catalogue-driven: a feature appears because a row offers it +and shows a price because that row has one, so a new paid add-on ships as a +staff edit rather than a frontend release. + +It saves nothing and knows nothing about who is using it. Plan 5 mounts the +same component in the customer purchase flow and hands it a checkout; this +is deliberately not a customer screen, because a configurator with no +checkout behind it is worse than none. + +The staff page shows granted against desired when they differ. A pending +reduction is a fact about the account, not only about Paddle. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Free self-hosted — claiming it, and renewing it + +**Files:** +- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/lifecycle/lifecycle.go` + +**Interfaces:** +- Consumes: `ownedInstance`, `licensing.Issue`, `licensing.ErrFreeLimit`, `models.GetEntitlement`, `models.UpsertEntitlement`, `license.TermsFor`, `inject.Deliver`, `mail.SendLicense`. +- Produces: + - `POST /api/instances/:id/claim-free` + - `renewInstance` handling both deployments + +Free self-hosted is new in spec 7 and has no path today. `POST /api/instances` creates cloud instances only; `POST /api/instances/link` links a self-hosted UUID but issues nothing. This task closes both ends: claiming Free on a linked install, and renewing it annually. + +**This is the only place in Vantage where an instance is licensed without either a payment or a staff action.** It is bounded by the per-deployment Free limit from task 4 and by nothing else, which is why the guard is checked twice below. + +- [ ] **Step 1: Claim Free on a linked self-hosted instance** + +Append to `admin/internal/api/customer.go`: + +```go +// claimFree issues a Free licence on a linked self-hosted instance. +// +// The link step creates the row; this gives it a licence. They are separate +// because linking is about identity — proving which install is yours — and +// claiming is about entitlement, and a customer who links an install and then +// changes their mind should not have consumed their one Free allowance. +// +// Free is outside Paddle entirely, so there is no checkout, no subscription and +// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard; +// the count here exists to refuse politely before anything is written. +func claimFree(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + ctx := c.Request.Context() + + // Cloud Free is claimed at creation by POST /api/instances. Allowing it here + // too would be a second way to reach the same state, with its own bugs. + if inst.Deployment != license.DeploymentSelfHosted { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "cloud instances get their Free licence when they are created"}) + return + } + if inst.CurrentLicense != "" { + c.JSON(http.StatusConflict, gin.H{ + "error": "this instance already has a licence"}) + return + } + + plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"}) + return + } + if !plan.Active { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Free self-hosted is not currently offered"}) + return + } + + // The entitlement is written BEFORE the licence, so Issue snapshots it rather + // than falling back to the plan base. They are the same numbers today, but + // the ordering is what makes that a coincidence rather than a dependency. + if err := models.UpsertEntitlement(ctx, models.Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: license.DeploymentSelfHosted, + Tier: license.TierFree, + Term: "annual", + Desired: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}}, + Granted: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}}, + ResolvedLimits: plan.BaseLimits, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + s := auth.Current(c) + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: license.TierFree, + // Annual, and not a choice. Self-hosted sells annual only because the + // term length is the revocation window for an offline licence. + Term: "annual", + Reason: models.ReasonNew, + IssuedBy: s.Email, + }) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, licensing.ErrFreeLimit) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + deliver(c, inst, lic) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()}) + c.JSON(http.StatusCreated, lic) +} +``` + +`deliver(c, inst, lic)` is the existing session-aware delivery helper used by `relinkInstance` — for a self-hosted instance it emails the blob and the customer can also download it. Confirm its signature matches before using it. + +- [ ] **Step 2: Renew either deployment** + +`renewInstance` hardcodes `Term: "monthly"` and calls `inject.Deliver`, both of which are wrong for a self-hosted Free instance: it would issue a one-month licence and inject it into a control-plane row that does not exist. + +In `admin/internal/api/customer.go`'s `renewInstance`, replace the `licensing.Issue` call and the delivery that follows it: + +```go + // Free renews on its deployment's only term: monthly for cloud, annual for + // self-hosted. Reading it from TermsFor rather than hardcoding is what stops + // a self-hosted instance being handed a one-month licence. + terms := license.TermsFor(inst.Deployment) + term := terms[len(terms)-1] + + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: license.TierFree, + Term: term, + Reason: models.ReasonRenewal, + IssuedBy: "self-serve", + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Cloud is injected; self-hosted is delivered to the customer, because their + // database is theirs and we cannot write to it. + deliver(c, inst, lic) +``` + +For cloud, `deliver` must still inject. Read the helper first: if it only emails, keep the deployment branch explicit instead: + +```go + if inst.Deployment == license.DeploymentCloud { + inject.Deliver(ctx, lic) + } else { + deliver(c, inst, lic) + } +``` + +Use whichever of these two matches what `deliver` actually does. **Do not guess** — a cloud renewal that stops injecting leaves a paying instance serving its old licence until the reconciler catches up. + +- [ ] **Step 3: Keep the reaper away from self-hosted** + +The lifecycle sweep and the control-plane reaper exist for Free **cloud** instances. A self-hosted install is not ours to delete — we cannot see it, and its database is the customer's. + +In `admin/internal/lifecycle/lifecycle.go`, confirm every sweep that leads to deletion or a deletion warning filters on `deployment: cloud`. If any filters only on `tier: free`, add the deployment: + +```bash +grep -n "TierFree\|DeploymentCloud\|deployment" admin/internal/lifecycle/lifecycle.go +``` + +Expected after the edit: every query naming `tier: free` also names `deployment: cloud`. A self-hosted Free instance must still receive **expiry** notices — those are useful — but never a deletion warning, and never be reaped. + +- [ ] **Step 4: Mount the route** + +In `admin/internal/api/routes.go`, in the customer group beside `renew`: + +```go + cust.POST("/instances/:id/claim-free", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + claimFree) +``` + +Claiming consumes the account's one Free self-hosted allowance, so it is a mutation and sits behind the same owner/admin guard as the other mutations. Match the guard helper's actual name from the neighbouring routes. + +- [ ] **Step 5: Confirm the Free self-hosted lifecycle** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +Against scratch databases, as a customer with no self-hosted instance: + +```bash +# link an install +curl -s -b /tmp/cj -X POST localhost:8083/api/instances/link \ + -H 'Content-Type: application/json' \ + -d '{"instance_id":"'$(uuidgen)'","name":"my box"}' | python -m json.tool +``` +Expected: `201`, status `awaiting_link` or `active` per the existing behaviour, and **no licence**. + +```bash +curl -s -b /tmp/cj -X POST localhost:8083/api/instances//claim-free | python -m json.tool +``` +Expected: `201`, a licence whose `expires_at` is about **one year and three days** away — a year for the annual term plus `GracePeriod`. A one-month expiry means the term is still hardcoded. + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ + 'db.licenses.find({instance_id:""},{tier:1,deployment:1,"limits.max_servers":1,"limits.max_monitors":1,_id:0}).toArray()' +``` +Expected: `tier: "free"`, `deployment: "self_hosted"`, `max_servers: 3`, `max_monitors: 3`. + +Claim again: +Expected: `409`, "already has a licence". + +Now the allowance. Link a second install and claim: +Expected: `409` from `ErrFreeLimit`, naming the deployment type. + +Confirm a cloud Free instance does **not** block it. With the same account holding a cloud Free instance, the claim above must still have succeeded — if it did not, task 4's per-deployment scoping is not in effect. + +Confirm the claim is refused on a cloud instance: + +```bash +curl -s -b /tmp/cj -X POST localhost:8083/api/instances//claim-free +``` +Expected: `400`, "cloud instances get their Free licence when they are created". + +Renewal. Set the licence's expiry inside the window and renew: + +```bash +mongosh "$SCRATCH_ADMIN_URI" --quiet --eval ' + db.licenses.updateOne({license_id:""}, + {$set:{expires_at: new Date(Date.now() + 1000*60*60*24*3)}})' + +curl -s -b /tmp/cj -X POST localhost:8083/api/instances//renew | python -m json.tool +``` +Expected: a new licence, `reason: renewal`, expiry about a year and three days out, the previous one carrying `superseded_by`. + +Confirm the cloud Free renewal still injects. Renew a cloud Free instance and check the control-plane document: + +```bash +mongosh "$SCRATCH_CONTROL_URI" --quiet --eval \ + 'db.instances.findOne({instance_id:""},{license_tier:1,license_expiry:1,_id:0})' +``` +Expected: `license_expiry` matching the **new** licence. **This is the regression to watch** — the delivery branch is the only thing standing between a working cloud renewal and one that silently stops injecting. + +Confirm the reaper's scope: + +```bash +grep -n "TierFree" admin/internal/lifecycle/lifecycle.go +``` +Expected: every hit that leads to a deletion notice or a reap is accompanied by `DeploymentCloud` on the same query. + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/api/customer.go admin/internal/api/routes.go admin/internal/lifecycle/lifecycle.go +git commit -m "feat(admin): claim and renew Free self-hosted + +Free exists in both deployments now, and self-hosted had no path to it: +POST /api/instances creates cloud only, and link issues nothing. + +Linking and claiming stay separate. Linking proves which install is yours; +claiming spends your one Free allowance — and somebody who links an install +and changes their mind should not have spent it. + +renewInstance read its term from TermsFor rather than hardcoding monthly, +which is what stops a self-hosted instance being handed a one-month licence +when self-hosted sells annual only. Delivery branches on deployment: cloud +is injected, self-hosted is delivered to the customer, because their +database is theirs. + +The reaper stays cloud-only. A self-hosted install is not ours to delete — +we cannot see it. It still gets expiry notices, never deletion warnings. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 10: The licence page, the documentation, and plan 5's preamble + +**Files:** +- Modify: `web/app/(app)/settings/license/page.tsx`, `CLAUDE.md`, `docs/superpowers/specs/README.md`, `docs/superpowers/plans/2026-07-26-paddle-billing.md` + +**Interfaces:** +- Consumes: `GET /api/license` with `support_level`, `usage.monitors`, `limits.max_monitors`, `limits.audit_retention_days`. +- Produces: no code interfaces. + +- [ ] **Step 1: Extend the licence types** + +In `web/lib/api.ts`, find `LicenseInfo` and extend its limits and usage shapes, and add the support level: + +```ts + support_level?: string; +``` + +```ts + limits: { + max_servers: number; + max_monitors: number; + max_secret_groups: number; + max_channels: number; + audit_retention_days: number; + }; + usage: { + servers: number; + monitors: number; + secret_groups: number; + channels: number; + }; +``` + +Match the file's existing declaration style — if the two shapes are named interfaces rather than inline, edit those instead. + +- [ ] **Step 2: Add the monitor meter and the retention fact** + +`web/app/(app)/settings/license/page.tsx` already has an `Allowance` component that draws a labelled meter and handles `UNLIMITED` by drawing no bar. Monitors are a count with a usage figure, so they use it. Add the row after Servers, at line 227: + +```tsx + + + + +``` + +Audit history is **not** an allowance and must not use that component: there is no "used" figure, and a meter with a made-up numerator would be a lie drawn as a bar. Add a plain fact card beside it, matching `Allowance`'s own card treatment so the row still reads as one system: + +```tsx +/* + * Retention is a duration, not a cap, so it gets a fact rather than a meter. + * Allowance's bar needs a numerator and there isn't one — how much of a + * retention window have you "used"? + */ +function Retention({ days }: { days: number }) { + const forever = days === UNLIMITED; + return ( + +

+ Audit history +

+

+ {forever ? "Kept" : days} + + {forever ? "indefinitely" : "days"} + +

+

+ {forever ? "Never trimmed" : "Older entries removed daily"} +

+
+ ); +} +``` + +Then render it after the four allowances: + +```tsx + +``` + +- [ ] **Step 3: Show the support level** + +Add the map above the page component: + +```tsx +/* + * The three stable identifiers, in prose. An unknown or empty value renders + * NOTHING rather than a fallback: a licence signed before support_level existed + * has no support level, and inventing one would promise something we have not + * sold. + */ +const SUPPORT_LABELS: Record = { + community: "Community support", + email_24_5: "Email support, 24/5", + email_call_24_7: "Email and phone support, 24/7", +}; +``` + +Then beside wherever the tier is displayed in the licence summary: + +```tsx +{license.support_level && SUPPORT_LABELS[license.support_level] && ( +

+ {SUPPORT_LABELS[license.support_level]} +

+)} +``` + +Place it in the summary block that already shows the tier and expiry, following that block's own spacing and text tokens rather than the classes above if they differ. + +- [ ] **Step 2: Update CLAUDE.md** + +Three edits. + +The collection list under "MongoDB Collections" — add admin's two new collections to the admin sentence: + +``` +Admin's own database is separate and holds `accounts` · `admin_instances` · +`licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `plans` · +`staff_users` · `customer_users` · `instance_members` · `admin_audit`. +``` + +(Remove the duplicate `plans` if you introduce one — read the line before editing it.) + +Add a paragraph after that list: + +``` +`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three +tiers — and holds base allowances only. **Every Paddle price ID lives in +`catalogue`**, one row per priceable component (`base`, `limit`, `feature`), +because a metered plan is priced by several prices and one map on a plan row +cannot express that. `entitlements` holds one row per instance with `desired` +beside `granted`: the checkout is built from `desired`, a licence is only ever +signed from `granted`, and an abandoned checkout therefore leaves a `desired` +that reached nothing. The two Free plans have **no catalogue rows at all**, which +is what keeps Free outside Paddle. +``` + +The admin route table — add the new routes to the staff and customer blocks: + +``` +GET,PUT /plans/:deployment/:tier · GET,PUT /catalogue +GET,PUT /instances/:id/entitlement +``` + +and in the customer block: + +``` +GET /instances/:id/entitlement +``` + +The tier description in the licensing section — replace any claim that Free is cloud-only by construction: + +``` +Free exists in both deployments, so it is no longer cloud-only by construction. +The one-Free-per-account rule is enforced per account **and deployment**, in +`licensing.checkFreeLimit` and in `createInstance`'s friendly pre-check — the two +must stay scoped identically, because a pre-check stricter than the issuer +refuses what would have worked. +``` + +- [ ] **Step 3: Update the spec index** + +In `docs/superpowers/specs/README.md`, mark spec 7 shipped and note what it left: + +``` +| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | [plan](../plans/2026-07-26-metered-licensing.md) | **shipped** — staff can configure and issue any of the six plans; no customer can buy one until 5 lands | +``` + +- [ ] **Step 4: Narrow plan 5's preamble to what remains** + +In `docs/superpowers/plans/2026-07-26-paddle-billing.md`, the `⚠ REVISED BY SPEC 7` block currently describes work that is now done. Rewrite its opening so a reader knows spec 7 has landed: + +```markdown +## ⚠ REVISED BY SPEC 7 — WHICH HAS SHIPPED + +Spec 7 landed on . The data model this plan assumed no longer exists: +`plans` is keyed on `(deployment, tier)`, every price ID lives in `catalogue`, +and `entitlements` holds what a licence is signed from. `admin/internal/catalogue` +already provides both folds, including `ResolveItems`, which is what replaces this +plan's price-ID-to-tier lookup. + +**Re-derive tasks 1, 3, 4, 5, 7, 8 and 10 against the shipped code before +executing them.** Tasks 2, 6 and 9 stand as written. +``` + +Leave the per-task table in place — it is still the map of what changes — but update each row's wording from future to past tense where spec 7 did the work. + +- [ ] **Step 5: Confirm the docs match the code** + +```bash +sh /tmp/npmrun.sh web npm run build +``` +Expected: a successful build. + +```bash +grep -n "catalogue\|entitlements" CLAUDE.md | head +``` +Expected: the collection list and the new paragraph. + +```bash +grep -n "cloud-only by construction" CLAUDE.md +``` +Expected: no output, or only inside the replacement paragraph explaining that it no longer holds. + +```bash +grep -rn "PaddlePriceIDs\|PaddleProductID\|GetPlan(ctx, in.Tier)\|PlanFor(\*tier)" admin/ shared/ adminsite/ +``` +Expected: no output. Every reference to the old shapes is gone. + +Confirm the route table matches the router: + +```bash +grep -n "staff.PUT\|staff.GET\|cust.GET" admin/internal/api/routes.go | grep -c "plans\|catalogue\|entitlement" +``` +Expected: `6`. + +- [ ] **Step 6: Commit** + +```bash +git add web/app/\(app\)/settings/license CLAUDE.md docs/superpowers +git commit -m "docs(licensing): the metered model, and what plan 5 has left to do + +The licence page shows monitors, audit history and support level. -1 reads +as Unlimited for customers and stays literal on the staff screen, because +the two audiences need different things from the same number. A licence +with no support level renders nothing rather than a fallback — inventing +one would be a claim we cannot keep. + +CLAUDE.md gains the catalogue and entitlements collections and loses the +claim that Free is cloud-only by construction, which spec 7 ended. + +Plan 5's preamble now reads as history rather than warning, and names the +seven tasks to re-derive against the shipped code. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Done when + +- `plans` holds exactly six rows keyed on `(deployment, tier)`, and no row has tier `self_hosted`. +- `catalogue` holds sixteen rows and **none** for either Free plan. +- Every non-deleted instance with a tier has an entitlement, and restarting admin creates no more. +- A staff-set entitlement of 9 servers plus `console` produces a licence with `max_servers: 9` and `features: ["console"]`. +- A pending **reduction** leaves `granted` alone, and reissuing against it still signs the larger cap. +- An account holding a cloud Free instance can be issued a self-hosted Free licence, and still cannot hold two cloud Free instances. +- A customer can link a self-hosted install, claim Free on it, and receive a licence expiring in **a year and three days** — not a month. +- Renewing a cloud Free instance still writes the new expiry into the control-plane document. +- No query in `lifecycle.go` that warns about or performs deletion matches a self-hosted instance. +- The fourth monitor on a Free instance is refused with `{"error":"limit_exceeded","limit":"max_monitors"}`. +- `audit_logs` older than the licensed retention are deleted; an `Unlimited` licence's and a **lapsed** instance's are not. +- `/auth/oidc/callback` redirects to `/login?error=oidc_unavailable` on an instance whose licence lacks `oidc`, and sets no session. +- A licence signed before `max_monitors` existed reports the plan's base rather than `0`. +- `grep -rn "pri_" admin/ adminsite/ shared/ --include=*.go --include=*.ts --include=*.tsx` returns nothing but placeholder text. +- A self-hosted monthly price is refused at the point it is pasted. +- `grep -rn "\.Desired" admin/internal/licensing/` returns nothing. +- Both frontends build, and neither new screen carries a hex colour. + +**Not proven by this plan:** anything involving Paddle. There is no client, no webhook and no checkout here by design — `LineItems` and `ResolveItems` are written and compiled but exercised only by hand, because nothing calls them until plan 5 does. Their first real exercise is plan 5's sandbox pass, and `ResolveItems` in particular deserves a deliberate test against a real multi-item subscription before it is trusted with a licence. + +Also not proven: that the entitlement backfill's server counts are commercially correct. It maps `Unlimited` to the plan base, which understates every pre-metering customer. Task 2 step 9 requires a human to set those by hand before production, and nothing in the code can check that judgement. + +## Deployment order + +1. Deploy `server` first. It tolerates old licences (`FillUnset`) and new ones, so it can lead. Confirm `GET /api/license` reports the two new limits on an existing instance before going further. +2. Deploy `admin`. Boot runs `SeedPlans`, `SeedCatalogue` and `Backfill` in that order, which re-keys plans and creates the entitlements. **Take a database dump first** — passes 3 and 4 rewrite rows. +3. Review the entitlement server counts (task 2, step 9) and correct them by hand. Nothing new can be issued correctly until this is done. +4. Deploy `adminsite`. Paste sandbox price IDs into the catalogue screen if plan 5 is next. +5. `web` last, since it only displays fields the server already serves. + +## Not in this plan + +The Paddle client, webhooks, checkout, the outbound subscription update, and the customer's purchase configurator — all of which are plan 5's, and all of which now have a data model to build on. Also excluded, per the spec's non-goals: paid feature add-ons (the model supports one; no feature has a price), metered channels, monitors or secret groups (a catalogue row away, deliberately not taken), usage-based billing, refunds, Enterprise contract terms and POs, and anything that revokes or shortens a licence. diff --git a/docs/superpowers/specs/2026-07-26-metered-licensing-design.md b/docs/superpowers/specs/2026-07-26-metered-licensing-design.md index 496d56b..f0ffc0f 100644 --- a/docs/superpowers/specs/2026-07-26-metered-licensing-design.md +++ b/docs/superpowers/specs/2026-07-26-metered-licensing-design.md @@ -295,28 +295,28 @@ price, and issues with `ReasonTierChange` as it already would. ## Control-plane enforcement -Two of the six columns in the pricing table are enforced by nothing today. A -feature picker that sells an ungated checkbox sells nothing. +**Feature gating already exists and is already mounted.** `RequireFeature` in +`server/internal/api/licence.go` answers 403 `feature_unavailable`, and +`server/internal/api/handlers.go` already wraps `POST /api/console/connect`, +`GET /api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. Free's feature list +is empty, so a Free instance already cannot open the console. **No capability is +taken away from an existing tenant by this spec, and no customer email is owed.** -**`license.HasFeature` is currently called from nowhere.** Add gates: +**One gap remains, and it is a single check.** `HandleOIDCStart` already tests +`Feature("oidc")` and redirects to `/login?error=oidc_unavailable`. +`HandleOIDCCallback` does not test it at all. A start that 403s is a dead end; an +ungated callback completes a sign-in, so the unguarded half is the half that +matters. -- `POST /api/console/connect` and `GET /api/console/tunnel` require - `FeatureConsole`. -- `GET`/`PUT /api/org/oidc`, `/auth/oidc/start` and `/auth/oidc/callback` require - `FeatureOIDC`. The callback matters most: an expired or downgraded licence must - not leave a working side door into the instance. +The callback cannot copy the start's instance resolution: the start reads +`InstanceFromHost(c)`, while the callback resolves the instance from the OAuth +state it consumes, and by then it holds `instanceID` directly. The check goes +after `ConsumeStateInstance` and before `providerForInstance`, so a licence that +lapsed mid-flow stops the exchange rather than completing it. -A new `FeatureError` maps to 403 with a machine-readable body, mirroring the -existing `LimitError`. `web/` hides the Console button and the SSO card when the -feature is absent, but as everywhere else in this codebase the API is the -boundary and the UI is the courtesy. - -**This removes a capability from existing Free cloud tenants.** Free's features -list has always been empty, but nothing gated on it, so a Free instance can use -the browser console today and will not be able to afterwards. That is the -intended pricing, and it is a deliberate behaviour change rather than a -side-effect — it needs to be named in the release note and, ideally, emailed to -affected accounts before the gate lands. +`web/` hides the Console button and the SSO card when the feature is absent, but +as everywhere else in this codebase the API is the boundary and the UI is the +courtesy. **`CheckMonitorLimit`** joins the three existing checks in `server/internal/services/licence_limits.go`, counting `monitors` for the @@ -445,7 +445,9 @@ sharp edge in the whole migration and it is worth a comment at the decode site. - Free self-hosted can be created, renewed from HQ, and lapses to read-only without being reaped. - Unticking Browser console removes it from the next issued licence, and - `POST /api/console/connect` answers 403 on an instance whose licence lacks it. + `POST /api/console/connect` answers 403 on an instance whose licence lacks it + (already true; the new part is that a customer controls the tick). +- `/auth/oidc/callback` answers 403 on an instance whose licence lacks `oidc`. - A monitor beyond the cap is refused with a machine-readable 403. - `audit_logs` older than the licence's retention are gone, and an unlimited licence's are not.