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

+