From bc1cda26f8ccbbb6496470e2b0de1ab691f5acf0 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 14:48:45 +0100 Subject: [PATCH] docs: add implementation plans for licensing-core and instance-licensing - 2026-07-24-licensing-core.md: 7 tasks. lk payload, offline verify, the trusted key slice, the noSign build tag, and lkctl for issuing by hand. - 2026-07-24-instance-licensing.md: 10 tasks. Licence on the instance document, cached runtime state, deny-by-default mutation gate, feature gates, service-layer limits, settings UI, and migration 0005 to grandfather existing cloud instances. Plan 2 opens by finishing the Org to Instance rename: 18 private identifiers survived plan 0b's sweep. Nothing functional, but the file that gains the licence cache is one of the two still carrying the old names. Spec index updated with plan links and shipped status. --- .../plans/2026-07-24-instance-licensing.md | 1646 +++++++++++++++++ .../plans/2026-07-24-licensing-core.md | 1032 +++++++++++ docs/superpowers/specs/README.md | 19 +- 3 files changed, 2689 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-instance-licensing.md create mode 100644 docs/superpowers/plans/2026-07-24-licensing-core.md diff --git a/docs/superpowers/plans/2026-07-24-instance-licensing.md b/docs/superpowers/plans/2026-07-24-instance-licensing.md new file mode 100644 index 0000000..ebab166 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-instance-licensing.md @@ -0,0 +1,1646 @@ +# Instance Licensing and Enforcement 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:** Store a licence on the instance document, resolve it into a cached runtime state, gate every mutating API route on it, enforce countable limits in the service layer, and let an owner paste a licence in. + +**Architecture:** Three enforcement layers that answer different questions — a group-level gin middleware for mutations (deny by default), a per-route middleware for features, and service-layer checks for limits, which need a count middleware does not have. Monitors keep executing when a licence lapses; growth and change stop, running infrastructure does not. + +**Tech Stack:** Go 1.26, gin, MongoDB driver v2, `shared/license` from plan 1, Next.js 16 + TanStack Query. + +**No automated tests.** Verification is by compiler, `grep`, `lkctl`-issued licences, and running the Docker images against a scratch database. + +## Global Constraints + +- Depends on plan 1 (`licensing-core`) being merged, with a real key in `trustedPublicKeys`. +- **An expired licence must never break a running fleet.** Agents keep their keys, monitors keep checking, alerts keep firing. +- `expired` and `invalid` degrade identically and differ only in the message shown. +- `DELETE` on any resource is **always** permitted, in every licence state. A customer downgraded to Free with 10 servers must be able to remove 7. +- `POST /api/servers/:id/apply-updates` is always permitted. Security patching is never paywalled. +- Over-limit instances are **never** truncated. Existing resources keep running; only creation stops. +- `VANTAGE_DEPLOYMENT` defaults to `self_hosted` — the stricter mode — when unset or unrecognised. +- Do not run the app with `go run`. Build the images and run containers. +- Local environment: MongoDB on `27021`, Mailpit SMTP `1031` / UI `8031`. Containers reach the host via `host.docker.internal` with `--add-host host.docker.internal:host-gateway`. + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode | +| `server/internal/services/licence_limits.go` | `CheckServerLimit`, `CheckSecretGroupLimit`, `CheckChannelLimit`, `ErrLimitExceeded` | +| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` | +| `web/app/(app)/settings/license/page.tsx` | Licence settings page | +| `web/components/LicenseBanner.tsx` | Persistent banner | +| `web/lib/useLicense.ts` | `useLicense()` hook | + +**Modified:** `shared/models/instance.go`, `server/internal/api/handlers.go`, `server/internal/services/servers.go`, `secrets.go`, `channels.go`, `server/internal/auth/oidc.go`, `server/internal/auth/instancehost.go`, `server/internal/api/org.go` (renamed), `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/layout.tsx`, `web/app/setup/page.tsx`. + +--- + +### Task 1: Finish the Org to Instance rename + +Plan 0b's naming map said the rename was total. It is not: 18 private identifiers still say `Org`. None affect the wire format, the database or any route, so nothing is broken — but this plan adds a licence cache next to the instance cache in the same file, and leaving two naming conventions side by side there is how the next person gets confused. + +**Files:** +- Modify: `server/internal/auth/instancehost.go`, `server/internal/auth/oidc.go`, `server/internal/auth/session.go` +- Modify: `server/internal/services/instance_oidc.go` +- Rename: `server/internal/api/org.go` → `server/internal/api/instance.go` + +**Interfaces:** +- Consumes: nothing +- Produces: `services.SaveInstanceOIDC` replacing `SaveOrgOIDC`; `auth.SaveStateInstance` / `auth.ConsumeStateInstance` replacing the `...Org` forms + +- [ ] **Step 1: Rename the file** + +```bash +cd c:/Work/Repos/vantage +git mv server/internal/api/org.go server/internal/api/instance.go +``` + +- [ ] **Step 2: Rename the identifiers** + +```bash +cd c:/Work/Repos/vantage/server +FILES=$(find . -name '*.go' -not -name 'migrate.go') +sed -i 's/\bcachedOrg\b/cachedInstance/g' $FILES +sed -i 's/\borgCacheMu\b/instanceCacheMu/g' $FILES +sed -i 's/\borgCacheTTL\b/instanceCacheTTL/g' $FILES +sed -i 's/\borgCache\b/instanceCache/g' $FILES +sed -i 's/\bproviderForOrg\b/providerForInstance/g' $FILES +sed -i 's/\bSaveStateOrg\b/SaveStateInstance/g' $FILES +sed -i 's/\bConsumeStateOrg\b/ConsumeStateInstance/g' $FILES +sed -i 's/\bSaveOrgOIDC\b/SaveInstanceOIDC/g' $FILES +sed -i 's/\bGetOrgOIDC\b/GetInstanceOIDC/g' $FILES +``` + +The `migrate.go` exclusion is deliberate and permanent: migrations 0001 to 0003 run before the rename and must keep speaking the pre-rename shape. + +- [ ] **Step 3: Fix the struct field left behind** + +`cachedInstance` still has a field named `org`. In `server/internal/auth/instancehost.go`, change the struct and its two uses: + +```go +type cachedInstance struct { + instance *models.Instance + at time.Time +} +``` + +Then update the read and write sites in `InstanceFromHost` to use `e.instance` and `cachedInstance{instance: inst, at: time.Now()}`. `go build` will point at both. + +- [ ] **Step 4: Build and confirm** + +```bash +cd c:/Work/Repos/vantage/server +go build ./... && go vet ./... +cd .. +grep -rn "\bOrg\b\|orgCache\|cachedOrg\|ForOrg\|StateOrg\|OrgOIDC" --include=*.go server/ | grep -v migrate.go +``` + +Expected: no build output, and the grep returns only `MigrateOrgToInstance` in `main.go` and `migrate_instance.go`, which are migration names and must not change. + +- [ ] **Step 5: Commit** + +```bash +git add server/ +git commit -m "refactor(server): finish the Org to Instance rename + +Private identifiers plan 0b's naming map missed. No wire format, database +field or route changes." +``` + +--- + +### Task 2: Store the licence on the instance + +**Files:** +- Modify: `shared/models/instance.go` + +**Interfaces:** +- Consumes: nothing +- Produces: `models.Instance` gains `LicenseBlob string`, `LicenseTier string`, `LicenseExpiry *time.Time` + +- [ ] **Step 1: Add the fields** + +Replace the `Instance` struct in `shared/models/instance.go`: + +```go +// Instance is one deployment of Vantage: its own subdomain, users, servers, +// keys, workflows, monitors and secrets. It is the unit a licence attaches to. +// +// A paying customer may hold several. That grouping is called an Account and +// lives only in the admin control plane — this service never sees it. +type Instance struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + + // LicenseBlob is the authoritative licence. LicenseTier and LicenseExpiry + // are a denormalised cache for listing and for the admin service's queries, + // rewritten from the verified payload every time a blob is accepted. + // Nothing reads them for enforcement. + // + // The blob is json:"-" because there is no reason to spray it through API + // responses. It is signed public data, not a secret. + LicenseBlob string `bson:"license_blob,omitempty" json:"-"` + LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"` + LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"` +} +``` + +No migration is needed: `omitempty` means existing documents simply have no licence, which resolves to `no_license`. + +- [ ] **Step 2: Build** + +```bash +cd c:/Work/Repos/vantage +(cd shared && go build ./...) && (cd server && go build ./...) && (cd sitesvc && go build ./...) +``` + +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add shared/models/instance.go +git commit -m "feat(shared): add licence fields to Instance" +``` + +--- + +### Task 3: Runtime licence state + +**Files:** +- Create: `server/internal/services/licence.go` + +**Interfaces:** +- Consumes: `license.Verify`, `license.Result`, `services.GetInstance` +- Produces: + - `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }` + - `func (s LicenseState) Active() bool` + - `func (s LicenseState) Feature(name string) bool` + - `func DeploymentMode() string` + - `func GetLicenseState(instanceID string) LicenseState` + - `func StoreLicense(instanceID, blob string) (LicenseState, error)` + - `func InvalidateLicenseCache(instanceID string)` + +- [ ] **Step 1: Write it** + +Create `server/internal/services/licence.go`: + +```go +package services + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// LicenseState is the resolved licence for one instance. +type LicenseState struct { + Status license.State `json:"state"` + Reason string `json:"reason,omitempty"` + Tier string `json:"tier,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Limits license.Limits `json:"limits"` + Features map[string]bool `json:"features"` + // Source is "stored", "env" or "none" — useful when a self-hosted operator + // asks why the licence they pasted is not the one in effect. + Source string `json:"source"` +} + +// Active reports whether mutations are allowed. +func (s LicenseState) Active() bool { return s.Status == license.StateValid } + +// Feature reports whether a named feature is granted. +func (s LicenseState) Feature(name string) bool { return s.Features[name] } + +// DeploymentMode is how this install describes itself to the verifier. +// +// It defaults to self_hosted, the stricter mode. An operator who removes the +// variable gets the tighter behaviour, not the looser one. +func DeploymentMode() string { + if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud { + return license.DeploymentCloud + } + return license.DeploymentSelfHosted +} + +type cachedLicense struct { + state LicenseState + at time.Time +} + +var ( + licenseCacheMu sync.Mutex + licenseCache = map[string]cachedLicense{} +) + +const licenseCacheTTL = 60 * time.Second + +// InvalidateLicenseCache drops the cached state for one instance, so a pasted +// licence takes effect immediately rather than within the TTL. +func InvalidateLicenseCache(instanceID string) { + licenseCacheMu.Lock() + delete(licenseCache, instanceID) + licenseCacheMu.Unlock() +} + +// GetLicenseState resolves the licence for an instance, cached for 60 seconds. +// +// Resolution order: +// +// 1. the blob stored on the instance document +// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an +// automated self-hosted deployment can ship a licence without a human +// pasting one +// 3. neither -> invalid / no_license +// +// A blob stored through the UI always wins afterwards, so an operator is never +// locked out by a stale environment value. +func GetLicenseState(instanceID string) LicenseState { + licenseCacheMu.Lock() + if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL { + licenseCacheMu.Unlock() + return e.state + } + licenseCacheMu.Unlock() + + state := resolveLicenseState(instanceID) + + licenseCacheMu.Lock() + licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()} + licenseCacheMu.Unlock() + return state +} + +func resolveLicenseState(instanceID string) LicenseState { + inst, err := GetInstance(instanceID) + if err != nil { + return LicenseState{ + Status: license.StateInvalid, + Reason: license.ReasonNoLicense, + Features: map[string]bool{}, + Source: "none", + } + } + + blob, source := inst.LicenseBlob, "stored" + if blob == "" { + blob, source = os.Getenv("VANTAGE_LICENSE"), "env" + } + if blob == "" { + return LicenseState{ + Status: license.StateInvalid, + Reason: license.ReasonNoLicense, + Features: map[string]bool{}, + Source: "none", + } + } + + res := license.Verify(blob, license.VerifyOpts{ + InstanceID: instanceID, + Deployment: DeploymentMode(), + }) + return stateFromResult(res, source) +} + +func stateFromResult(res license.Result, source string) LicenseState { + feats := map[string]bool{} + for _, f := range res.License.Features { + feats[f] = true + } + s := LicenseState{ + Status: res.State, + Reason: res.Reason, + Tier: res.License.Tier, + Limits: res.License.Limits, + Features: feats, + Source: source, + } + if !res.License.ExpiresAt.IsZero() { + exp := res.License.ExpiresAt + s.ExpiresAt = &exp + } + return s +} + +// StoreLicense verifies a blob against this instance and stores it. +// +// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired +// and when. An invalid blob is rejected and the previous one kept. +func StoreLicense(instanceID, blob string) (LicenseState, error) { + blob = strings.TrimSpace(blob) + + res := license.Verify(blob, license.VerifyOpts{ + InstanceID: instanceID, + Deployment: DeploymentMode(), + }) + if res.State == license.StateInvalid { + return LicenseState{}, fmt.Errorf("%s", res.Reason) + } + + set := bson.M{ + "license_blob": blob, + "license_tier": res.License.Tier, + "license_expiry": res.License.ExpiresAt, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := db.Col("instances").UpdateOne(ctx, + bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil { + return LicenseState{}, err + } + + InvalidateLicenseCache(instanceID) + return stateFromResult(res, "stored"), nil +} +``` + +- [ ] **Step 2: Build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/services/licence.go +git commit -m "feat(server): resolve licence state per instance" +``` + +--- + +### Task 4: Limits in the service layer + +Limits need a count, which middleware does not have. + +**Files:** +- Create: `server/internal/services/licence_limits.go` +- Modify: `server/internal/services/servers.go` (`CreateServer`), `secrets.go` (`UpsertSecrets`), `channels.go` (`CreateChannel`) + +**Interfaces:** +- Consumes: `GetLicenseState`, `license.WithinLimit` +- Produces: + - `type LimitError struct { Limit string; Current, Max int }` with `Error() string` + - `func CheckServerLimit(instanceID string) error` + - `func CheckSecretGroupLimit(instanceID, group string) error` + - `func CheckChannelLimit(instanceID string) error` + - `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)` + +- [ ] **Step 1: Write the limit checks** + +Create `server/internal/services/licence_limits.go`: + +```go +package services + +import ( + "context" + "fmt" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// LimitError is returned when a licence cap would be exceeded. The API maps it +// to 403 with a machine-readable body. +type LimitError struct { + Limit string + Current int + Max int +} + +func (e *LimitError) Error() string { + return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max) +} + +func limitCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 5*time.Second) +} + +// CheckServerLimit refuses a new server when the instance is at its cap. +// +// Counts live rows only. An instance already over its cap keeps every server it +// has — nothing is truncated — it simply cannot add another. +func CheckServerLimit(instanceID string) error { + st := GetLicenseState(instanceID) + ctx, cancel := limitCtx() + defer cancel() + + n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if !license.WithinLimit(int(n), st.Limits.MaxServers) { + return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers} + } + return nil +} + +// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that +// already exists is always allowed, so a capped customer can still rotate the +// secrets they have. +func CheckSecretGroupLimit(instanceID, group string) error { + st := GetLicenseState(instanceID) + ctx, cancel := limitCtx() + defer cancel() + + existing, err := db.Col("secrets").CountDocuments(ctx, + bson.M{"instance_id": instanceID, "group": group}) + if err != nil { + return err + } + if existing > 0 { + return nil + } + + var groups []string + if err := db.Col("secrets").Distinct(ctx, "group", + bson.M{"instance_id": instanceID}).Decode(&groups); err != nil { + return err + } + if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) { + return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups} + } + return nil +} + +// CheckChannelLimit refuses a new notification channel at the cap. +func CheckChannelLimit(instanceID string) error { + st := GetLicenseState(instanceID) + ctx, cancel := limitCtx() + defer cancel() + + n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if !license.WithinLimit(int(n), st.Limits.MaxChannels) { + return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels} + } + return nil +} + +// 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, 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) + } + 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 2: Enforce in CreateServer** + +In `server/internal/services/servers.go`, at the top of `CreateServer`: + +```go +func CreateServer(instanceID string) (*models.Server, string, error) { + if err := CheckServerLimit(instanceID); err != nil { + return nil, "", err + } + // ... existing body unchanged +``` + +- [ ] **Step 3: Enforce in UpsertSecrets** + +In `server/internal/services/secrets.go`, at the top of `UpsertSecrets`: + +```go +func UpsertSecrets(instanceID, group string, values map[string]string) error { + if err := CheckSecretGroupLimit(instanceID, group); err != nil { + return err + } + // ... existing body unchanged +``` + +- [ ] **Step 4: Enforce in CreateChannel** + +In `server/internal/services/channels.go`, at the top of `CreateChannel`: + +```go +func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) { + if err := CheckChannelLimit(instanceID); err != nil { + return nil, err + } + // ... existing body unchanged +``` + +- [ ] **Step 5: Build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/services/ +git commit -m "feat(server): enforce licence limits on servers, secret groups and channels" +``` + +--- + +### Task 5: The API and the two middlewares + +**Files:** +- Create: `server/internal/api/licence.go` +- Modify: `server/internal/api/handlers.go` + +**Interfaces:** +- Consumes: `GetLicenseState`, `StoreLicense`, `LicenseUsage`, `LimitError` +- Produces: + - `func RequireActiveLicense() gin.HandlerFunc` + - `func RequireFeature(name string) gin.HandlerFunc` + - `GET /api/license`, `POST /api/license` + +- [ ] **Step 1: Write the handlers and middlewares** + +Create `server/internal/api/licence.go`: + +```go +package api + +import ( + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" + "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/server/internal/services" + "github.com/mrhid6/vantage/shared/license" +) + +// licenceExemptPaths are routes that must work while a licence is expired or +// missing, because they are how a customer recovers or stays safe. +// +// /api/license pasting a valid licence is the way out of degraded mode +// apply-updates security patching is never paywalled +// +// All DELETE requests are exempt separately (see RequireActiveLicense): a +// customer downgraded below their current usage must be able to delete their +// way back under the cap. +var licenceExemptPaths = map[string]bool{ + "/api/license": true, +} + +func licenceExempt(c *gin.Context) bool { + if c.Request.Method == http.MethodDelete { + return true + } + if licenceExemptPaths[c.FullPath()] { + return true + } + if c.FullPath() == "/api/servers/:id/apply-updates" { + return true + } + return false +} + +// RequireActiveLicense blocks mutating requests when the licence is not valid. +// +// Mounted on the /api group, so a route added tomorrow is gated because of where +// it lives rather than because someone remembered. GET and HEAD always pass — +// reading is never blocked. +func RequireActiveLicense() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead { + c.Next() + return + } + if licenceExempt(c) { + c.Next() + return + } + + st := services.GetLicenseState(auth.InstanceID(c)) + if st.Active() { + c.Next() + return + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "license_required", + "state": st.Status, + "reason": st.Reason, + }) + } +} + +// RequireFeature blocks a route when the licence does not grant a feature. +func RequireFeature(name string) gin.HandlerFunc { + return func(c *gin.Context) { + st := services.GetLicenseState(auth.InstanceID(c)) + if st.Feature(name) { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "feature_unavailable", + "feature": name, + }) + } +} + +type licenceResponse struct { + InstanceID string `json:"instance_id"` + State license.State `json:"state"` + Reason string `json:"reason,omitempty"` + Tier string `json:"tier,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + DaysRemaining *int `json:"days_remaining,omitempty"` + Limits license.Limits `json:"limits"` + Features map[string]bool `json:"features"` + Usage licenceUsageResponse `json:"usage"` + Source string `json:"source"` +} + +type licenceUsageResponse struct { + Servers int `json:"servers"` + SecretGroups int `json:"secret_groups"` + Channels int `json:"channels"` +} + +func getLicence(c *gin.Context) { + instanceID := auth.InstanceID(c) + st := services.GetLicenseState(instanceID) + servers, groups, channels := services.LicenseUsage(instanceID) + + resp := licenceResponse{ + InstanceID: instanceID, + State: st.Status, + Reason: st.Reason, + Tier: st.Tier, + ExpiresAt: st.ExpiresAt, + Limits: st.Limits, + Features: st.Features, + Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels}, + Source: st.Source, + } + if st.ExpiresAt != nil { + d := int(time.Until(*st.ExpiresAt).Hours() / 24) + resp.DaysRemaining = &d + } + c.JSON(http.StatusOK, resp) +} + +func postLicence(c *gin.Context) { + var body struct { + Blob string `json:"blob"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"}) + return + } + + instanceID := auth.InstanceID(c) + st, err := services.StoreLicense(instanceID, body.Blob) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": licenceRejectionMessage(err.Error(), instanceID), + "reason": err.Error(), + }) + return + } + + services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "", + "licence accepted (tier "+st.Tier+")") + c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt}) +} + +// licenceRejectionMessage turns a machine reason into something a person can act +// on. The instance ID is included in the mismatch case because that is the one +// piece of information the customer needs and cannot guess. +func licenceRejectionMessage(reason, instanceID string) string { + switch reason { + case license.ReasonBadSignature: + return "This licence key is not valid. Check it was copied in full." + case license.ReasonDeploymentMismatch: + return "This licence is for Vantage Cloud and cannot be used on a self-hosted install." + case license.ReasonInstanceMismatch: + return "This licence was issued for a different instance. Your instance ID is " + instanceID + "." + case license.ReasonNoLicense: + return "No licence key was provided." + default: + return "This licence could not be accepted." + } +} + +// limitStatus maps a LimitError to a 403 body. Handlers that create countable +// resources call this so the UI gets a machine-readable limit name. +func limitStatus(c *gin.Context, err error) bool { + var le *services.LimitError + if !errors.As(err, &le) { + return false + } + c.JSON(http.StatusForbidden, gin.H{ + "error": "limit_exceeded", + "limit": le.Limit, + "current": le.Current, + "max": le.Max, + }) + return true +} + +var _ = models.RoleOwner // keep the models import honest if unused elsewhere +``` + +Remove the trailing `var _ = models.RoleOwner` line and the `models` import if `go build` reports them unused. + +- [ ] **Step 2: Mount the middlewares** + +In `server/internal/api/handlers.go`, change the `/api` group and add the two routes: + +```go + apiGroup := r.Group("/api") + apiGroup.Use(auth.Middleware()) + // Deny by default: every non-GET route under /api is gated unless it is on + // the exemption list in licence.go. A route added later is covered because + // of where it is mounted, not because someone remembered. + apiGroup.Use(RequireActiveLicense()) + { + apiGroup.GET("/license", getLicence) + apiGroup.POST("/license", auth.RequireRole("owner"), postLicence) +``` + +Then gate the console: + +```go + apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect) + apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel) +``` + +And OIDC settings: + +```go + instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC) + instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC) +``` + +- [ ] **Step 3: Return limit errors properly** + +In `server/internal/api/handlers.go`, in `createServer` and `newServer`, replace the error branch: + +```go + s, token, err := services.CreateServer(auth.InstanceID(c)) + if err != nil { + if limitStatus(c, err) { + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } +``` + +Apply the same `if limitStatus(c, err) { return }` guard in the secrets handler that calls `services.UpsertSecrets` (`createSecretGroup` and `putSecretGroup` in `server/internal/api/secrets.go`) and in the channel handler that calls `services.CreateChannel` (`server/internal/api/channels.go`). + +- [ ] **Step 4: Gate the unauthenticated OIDC entry points** + +`/auth/oidc/start` and `/auth/oidc/callback` sit outside `/api` and have no session, so they check the feature directly against the instance resolved from the host. + +In `server/internal/auth/oidc.go`, at the top of `HandleOIDCStart`, after the instance is resolved: + +```go + if !services.GetLicenseState(inst.InstanceID).Feature("oidc") { + c.Redirect(http.StatusFound, "/login?error=oidc_unavailable") + return + } +``` + +**Do not add this to the session-validation path.** Losing the feature stops new SSO logins; it must not evict people mid-session. + +- [ ] **Step 5: Build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 6: Rate-limit the licence endpoint** + +There is no oracle here worth protecting — the blob is signed public data — but an +unbounded verify endpoint is an unbounded CPU endpoint, and ed25519 verification +is not free. + +Add to `server/internal/api/licence.go`: + +```go +var ( + licencePostMu sync.Mutex + licencePostCounts = map[string][]time.Time{} +) + +const licencePostLimit = 10 + +// licencePostAllowed permits 10 attempts per instance per hour. +func licencePostAllowed(instanceID string) bool { + cutoff := time.Now().Add(-time.Hour) + + licencePostMu.Lock() + defer licencePostMu.Unlock() + + kept := licencePostCounts[instanceID][:0] + for _, t := range licencePostCounts[instanceID] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= licencePostLimit { + licencePostCounts[instanceID] = kept + return false + } + licencePostCounts[instanceID] = append(kept, time.Now()) + return true +} +``` + +Add `"sync"` to the imports, and guard the handler at the top of `postLicence`: + +```go + instanceID := auth.InstanceID(c) + if !licencePostAllowed(instanceID) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "Too many licence attempts. Try again later.", + }) + return + } +``` + +Move the existing `instanceID := auth.InstanceID(c)` line so it is not declared twice. + +- [ ] **Step 7: Audit the route coverage by hand** + +With no test suite, this is the check that stops layer 1 rotting. + +```bash +cd c:/Work/Repos/vantage +grep -n "apiGroup\.\(POST\|PUT\|DELETE\|PATCH\)\|Group(\"" server/internal/api/handlers.go +grep -rn "\.\(POST\|PUT\|DELETE\|PATCH\)(" server/internal/api/workflows.go server/internal/api/monitors.go server/internal/api/channels.go +``` + +For every non-`GET` route listed, confirm it is either under `apiGroup` (and therefore gated) or deliberately exempt. Record the exempt list in a comment above `licenceExemptPaths` if it grows. + +- [ ] **Step 8: Commit** + +```bash +git add server/internal/api/ server/internal/auth/oidc.go +git commit -m "feat(server): gate mutations and features on the licence" +``` + +--- + +### Task 6: Degraded background behaviour + +The paths that do not go through gin. This is where "read-only" has to be specific. + +**Files:** +- Modify: `server/internal/monitorsched/scheduler.go` (comment only) +- Modify: `server/internal/grpc/server.go` (`Register`) + +**Interfaces:** +- Consumes: `services.GetLicenseState`, `services.CheckServerLimit` +- Produces: no new API + +- [ ] **Step 1: Leave the monitor scheduler alone, and say why** + +Add above the loop body in `server/internal/monitorsched/scheduler.go`: + +```go +// Monitors run regardless of licence state, deliberately. +// +// A customer whose card failed must not lose the ability to know their +// infrastructure is on fire. Creating and editing monitors is blocked by the +// API gate; executing the ones that already exist is not. +``` + +Make no functional change here. The absence of a check is the feature. + +- [ ] **Step 2: Leave the workflow runner alone too, and say why** + +Add above the step-dispatch loop in `server/internal/services/workflow_runner.go`: + +```go +// A run already in flight when the licence expires finishes its remaining +// steps. New runs are blocked at the API, but killing a workflow midway leaves +// a server in a half-configured state, which is worse than letting it complete. +``` + +Again, no functional change — the runner must not gain a licence check. + +- [ ] **Step 3: Refuse NEW agent registrations against an over-limit instance** + +In `server/internal/grpc/server.go`, inside `Register`, after the pre-registration token is validated and the instance is known, before the server is marked active: + +```go + // An existing agent re-registering is always allowed — its server row already + // exists, so the limit check passes. This only stops a NEW server being + // added past the cap by going around the API. + if err := services.CheckServerLimit(instanceID); err != nil { + return nil, status.Errorf(codes.FailedPrecondition, + "this Vantage instance has reached its licenced server limit; "+ + "remove a server or upgrade, then retry") + } +``` + +Add `"google.golang.org/grpc/codes"` and `"google.golang.org/grpc/status"` to the imports if not already present. + +- [ ] **Step 4: Build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/monitorsched/ server/internal/grpc/ +git commit -m "feat(server): keep monitors running when a licence lapses + +Blocks new agent registration past the server cap. Monitor execution is +deliberately unguarded: billing state must not take away a customer's +ability to know their infrastructure is on fire." +``` + +--- + +### Task 7: Show the instance ID at setup + +A self-hosted operator needs the UUID to activate a licence, and `/setup` is where they first need it. + +**Files:** +- Modify: `server/internal/auth/local.go` (`HandleBootstrap` response) +- Modify: `web/app/setup/page.tsx` + +**Interfaces:** +- Consumes: nothing +- Produces: `POST /auth/bootstrap` response gains `instance_id` + +- [ ] **Step 1: Return the instance ID from bootstrap** + +In `server/internal/auth/local.go`, in the bootstrap success response, change: + +```go + c.JSON(http.StatusCreated, gin.H{ + "instance": inst, + "slug": inst.Slug, + "instance_id": inst.InstanceID, + }) +``` + +- [ ] **Step 2: Show it on the setup confirmation** + +In `web/app/setup/page.tsx`, in the "Instance created" block, add below the slug line: + +```tsx +
+

Instance ID — needed to activate a licence

+
+ {created.instance_id} + +
+
+``` + +Add `instance_id: string;` to the bootstrap response type in `web/lib/api.ts`. + +- [ ] **Step 3: Build** + +```bash +cd c:/Work/Repos/vantage/server && go build ./... +cd ../web && npx tsc --noEmit +``` + +Expected: no output from either. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth/local.go web/app/setup/page.tsx web/lib/api.ts +git commit -m "feat: show the instance ID after setup" +``` + +--- + +### Task 8: Frontend licence state + +**Files:** +- Create: `web/lib/useLicense.ts`, `web/components/LicenseBanner.tsx`, `web/app/(app)/settings/license/page.tsx` +- Modify: `web/lib/api.ts`, `web/app/(app)/layout.tsx`, `web/components/Sidebar.tsx` + +**Interfaces:** +- Consumes: `GET /api/license`, `POST /api/license` +- Produces: `useLicense()`, `` + +- [ ] **Step 1: Add the API client methods and types** + +In `web/lib/api.ts`: + +```ts +export type LicenseState = "valid" | "expired" | "invalid"; + +export interface LicenseInfo { + instance_id: string; + state: LicenseState; + reason?: string; + tier?: string; + expires_at?: string; + days_remaining?: number; + limits: { max_servers: number; max_secret_groups: number; max_channels: number }; + features: Record; + usage: { servers: number; secret_groups: number; channels: number }; + source: string; +} + +export const licence = { + get(): Promise { + return request("/api/license"); + }, + put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> { + return request("/api/license", { method: "POST", body: JSON.stringify({ blob }) }); + }, +}; +``` + +Match `request` to whatever the existing client helper is called in that file; the other methods there show the pattern. + +- [ ] **Step 2: Write the hook** + +Create `web/lib/useLicense.ts`: + +```ts +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { licence, type LicenseInfo } from "@/lib/api"; + +export function useLicense() { + const { data, isLoading } = useQuery({ + queryKey: ["license"], + queryFn: licence.get, + staleTime: 60_000, + }); + + return { + license: data, + isLoading, + isActive: data?.state === "valid", + // Features render disabled rather than hidden, so treat "unknown while + // loading" as available to avoid a flash of disabled controls. + hasFeature: (name: string) => (data ? Boolean(data.features?.[name]) : true), + }; +} +``` + +- [ ] **Step 3: Write the banner** + +Create `web/components/LicenseBanner.tsx`: + +```tsx +"use client"; + +import Link from "next/link"; +import { useLicense } from "@/lib/useLicense"; + +export function LicenseBanner() { + const { license } = useLicense(); + if (!license) return null; + + if (license.state === "expired") { + const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently"; + return ( +
+ Your Vantage licence expired on {when}. Your servers and monitors are still running, + but changes are disabled until it is renewed.{" "} + Add a licence +
+ ); + } + + if (license.state === "invalid") { + return ( +
+ This instance has no valid licence. Changes are disabled.{" "} + Add a licence +
+ ); + } + + if (typeof license.days_remaining === "number" && license.days_remaining <= 14) { + return ( +
+ Your licence expires in {license.days_remaining} day + {license.days_remaining === 1 ? "" : "s"}. +
+ ); + } + + return null; +} +``` + +- [ ] **Step 4: Mount the banner** + +In `web/app/(app)/layout.tsx`, render `` immediately inside the main content wrapper, above the page children, and import it. + +- [ ] **Step 5: Write the settings page** + +Create `web/app/(app)/settings/license/page.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { licence } from "@/lib/api"; +import { useLicense } from "@/lib/useLicense"; + +function cap(n: number) { + return n === -1 ? "Unlimited" : String(n); +} + +export default function LicensePage() { + const { license } = useLicense(); + const queryClient = useQueryClient(); + const [blob, setBlob] = useState(""); + const [error, setError] = useState(""); + + const save = useMutation({ + mutationFn: () => licence.put(blob.trim()), + onSuccess: () => { + setBlob(""); + setError(""); + queryClient.invalidateQueries({ queryKey: ["license"] }); + }, + onError: (e: Error) => setError(e.message), + }); + + if (!license) return null; + + return ( +
+

Licence

+ +
+

+ State: {license.state} + {license.tier ? <> · Tier: {license.tier} : null} + {license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()} : null} +

+

+ Instance ID — quote this when buying or activating a licence +

+
+ {license.instance_id} + +
+
+ +
+

Usage

+
    +
  • Servers: {license.usage.servers} of {cap(license.limits.max_servers)}
  • +
  • Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}
  • +
  • Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}
  • +
  • Browser console: {license.features.console ? "Included" : "Not included"}
  • +
  • Single sign-on: {license.features.oidc ? "Included" : "Not included"}
  • +
+
+ +
+

Add or replace a licence

+