# 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"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/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"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/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"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"gitea.hostxtra.co.uk/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 signature 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
<div className="mt-4 rounded border border-border p-3">
    <p className="text-xs text-text-tertiary">Instance ID — needed to activate a licence</p>
    <div className="mt-1 flex items-center gap-2">
        <code className="text-sm text-text-primary">{created.instance_id}</code>
        <button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(created.instance_id)}>
            Copy
        </button>
    </div>
</div>
```

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()`, `<LicenseBanner />`

- [ ] **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<string, boolean>;
    usage: { servers: number; secret_groups: number; channels: number };
    source: string;
}

export const licence = {
    get(): Promise<LicenseInfo> {
        return request<LicenseInfo>("/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<LicenseInfo>({
        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 (
            <div className="bg-amber-900/40 px-4 py-2 text-sm text-amber-100">
                Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "}
                <Link href="/settings/license" className="underline">
                    Add a licence
                </Link>
            </div>
        );
    }

    if (license.state === "invalid") {
        return (
            <div className="bg-red-900/40 px-4 py-2 text-sm text-red-100">
                This instance has no valid licence. Changes are disabled.{" "}
                <Link href="/settings/license" className="underline">
                    Add a licence
                </Link>
            </div>
        );
    }

    if (typeof license.days_remaining === "number" && license.days_remaining <= 14) {
        return (
            <div className="bg-amber-900/25 px-4 py-2 text-sm text-amber-100">
                Your licence expires in {license.days_remaining} day
                {license.days_remaining === 1 ? "" : "s"}.
            </div>
        );
    }

    return null;
}
```

- [ ] **Step 4: Mount the banner**

In `web/app/(app)/layout.tsx`, render `<LicenseBanner />` 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 (
        <div className="space-y-6">
            <h1 className="text-2xl font-bold text-text-primary">Licence</h1>

            <section className="rounded border border-border p-4">
                <p className="text-sm text-text-secondary">
                    State: <b>{license.state}</b>
                    {license.tier ? (
                        <>
                            {" "}
                            · Tier: <b>{license.tier}</b>
                        </>
                    ) : null}
                    {license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()}</> : null}
                </p>
                <p className="mt-2 text-xs text-text-tertiary">Instance ID — quote this when buying or activating a licence</p>
                <div className="mt-1 flex items-center gap-2">
                    <code className="text-sm">{license.instance_id}</code>
                    <button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(license.instance_id)}>
                        Copy
                    </button>
                </div>
            </section>

            <section className="rounded border border-border p-4">
                <h2 className="font-semibold text-text-primary">Usage</h2>
                <ul className="mt-2 space-y-1 text-sm text-text-secondary">
                    <li>
                        Servers: {license.usage.servers} of {cap(license.limits.max_servers)}
                    </li>
                    <li>
                        Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}
                    </li>
                    <li>
                        Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}
                    </li>
                    <li>Browser console: {license.features.console ? "Included" : "Not included"}</li>
                    <li>Single sign-on: {license.features.oidc ? "Included" : "Not included"}</li>
                </ul>
            </section>

            <section className="rounded border border-border p-4">
                <h2 className="font-semibold text-text-primary">Add or replace a licence</h2>
                <textarea
                    className="mt-2 h-32 w-full rounded border border-border bg-transparent p-2 font-mono text-xs"
                    placeholder="Paste your licence key"
                    value={blob}
                    onChange={(e) => setBlob(e.target.value)}
                />
                <input
                    type="file"
                    accept=".lic,.txt"
                    className="mt-2 block text-xs"
                    onChange={async (e) => {
                        const f = e.target.files?.[0];
                        if (f) setBlob((await f.text()).trim());
                    }}
                />
                {error ? <p className="mt-2 text-sm text-red-400">{error}</p> : null}
                <button type="button" className="mt-3 rounded bg-accent px-3 py-1.5 text-sm" disabled={!blob.trim() || save.isPending} onClick={() => save.mutate()}>
                    {save.isPending ? "Checking…" : "Save licence"}
                </button>
            </section>
        </div>
    );
}
```

- [ ] **Step 6: Add the sidebar entry and gate the console link**

In `web/components/Sidebar.tsx`, add a `/settings/license` entry labelled "Licence". Where the console link is rendered on the server detail page, render it disabled with `title="Upgrade to use the browser console"` when `hasFeature("console")` is false — **disabled, not hidden.** A customer cannot buy what they cannot see, and a feature that vanishes reads as a bug.

- [ ] **Step 7: Build**

```bash
cd c:/Work/Repos/vantage/web
rm -rf .next && npm run build
```

Expected: build succeeds, `/settings/license` appears in the route list.

- [ ] **Step 8: Commit**

```bash
git add web/
git commit -m "feat(web): licence banner, settings page and feature gating"
```

---

### Task 9: Grandfather existing cloud instances

Migration `0005`, cloud only. It cannot sign — the server holds no private key and, per plan 1, no signing code — so the blobs are cut ahead of time with `lkctl` and handed in through the environment.

Clumsy, and correct. The alternative is putting a signing key in the control plane, which is the thing this design most wants to avoid.

**Files:**

- Create: `server/internal/services/migrate_licence.go`
- Modify: `server/cmd/main.go`

**Interfaces:**

- Consumes: `license.Verify`, `DeploymentMode`
- Produces: `func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error`

- [ ] **Step 1: Write the migration**

Create `server/internal/services/migrate_licence.go`:

```go
package services

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

// MigrateGrandfatherLicences stores pre-issued licences on instances that have
// none. Cloud only.
//
// The blobs are supplied through VANTAGE_GRANDFATHER_BLOBS, a JSON object
// mapping instance_id to licence blob, because this process cannot sign: it
// holds no private key and the signing code is compiled out. Cut the blobs
// beforehand with lkctl.
//
// The variable is single-use. Unset it on the next deploy.
func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error {
	const marker = "0005_grandfather_licences"

	if n, _ := db.Collection("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
		return nil
	}
	if DeploymentMode() != license.DeploymentCloud {
		log.Printf("0005: not a cloud deployment, skipping")
		return nil
	}

	raw := os.Getenv("VANTAGE_GRANDFATHER_BLOBS")
	if raw == "" {
		log.Printf("0005: VANTAGE_GRANDFATHER_BLOBS not set, skipping (no marker recorded)")
		return nil
	}

	var blobs map[string]string
	if err := json.Unmarshal([]byte(raw), &blobs); err != nil {
		return fmt.Errorf("0005: VANTAGE_GRANDFATHER_BLOBS is not valid JSON: %w", err)
	}

	cur, err := db.Collection("instances").Find(ctx, bson.M{
		"$or": []bson.M{
			{"license_blob": bson.M{"$exists": false}},
			{"license_blob": ""},
		},
	})
	if err != nil {
		return fmt.Errorf("0005: list instances: %w", err)
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return fmt.Errorf("0005: decode instances: %w", err)
	}

	var stored, missing int
	for _, inst := range instances {
		blob, ok := blobs[inst.InstanceID]
		if !ok || blob == "" {
			log.Printf("0005: no blob supplied for instance %s (%s)", inst.InstanceID, inst.Slug)
			missing++
			continue
		}

		res := license.Verify(blob, license.VerifyOpts{
			InstanceID: inst.InstanceID,
			Deployment: license.DeploymentCloud,
		})
		if res.State == license.StateInvalid {
			return fmt.Errorf("0005: blob for instance %s is rejected: %s", inst.InstanceID, res.Reason)
		}

		if _, err := db.Collection("instances").UpdateOne(ctx,
			bson.M{"instance_id": inst.InstanceID},
			bson.M{"$set": bson.M{
				"license_blob":   blob,
				"license_tier":   res.License.Tier,
				"license_expiry": res.License.ExpiresAt,
			}}); err != nil {
			return fmt.Errorf("0005: store blob for %s: %w", inst.InstanceID, err)
		}
		log.Printf("0005: stored %s licence for instance %s (%s), expires %s",
			res.License.Tier, inst.InstanceID, inst.Slug,
			res.License.ExpiresAt.Format(time.RFC3339))
		stored++
	}

	if missing > 0 {
		return fmt.Errorf("0005: %d instance(s) had no blob supplied; issue them with lkctl and rerun", missing)
	}

	_, err = db.Collection("migrations").InsertOne(ctx,
		bson.M{"_id": marker, "applied_at": time.Now()})
	log.Printf("0005: grandfathered %d instance(s)", stored)
	return err
}
```

Refusing to record the marker when a blob is missing is deliberate: a half-grandfathered fleet must be fixed and rerun, not silently accepted.

- [ ] **Step 2: Call it at boot**

In `server/cmd/main.go`, after the `AssertNoScopedCollectionMissed` block and before `EnsureAuthIndexes`:

```go
	gfCtx, gfCancel := context.WithTimeout(context.Background(), 2*time.Minute)
	gfErr := services.MigrateGrandfatherLicences(gfCtx, db.Database)
	gfCancel()
	if gfErr != nil {
		log.Fatalf("licence grandfather migration failed: %v", gfErr)
	}
```

- [ ] **Step 3: Build**

Run: `cd server && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add server/internal/services/migrate_licence.go server/cmd/main.go
git commit -m "feat(server): grandfather existing cloud instances onto Professional"
```

---

### Task 10: Full verification

Everything runs in containers. With no test suite this is the only evidence.

**Files:** none

- [ ] **Step 1: Build everything**

```bash
cd c:/Work/Repos/vantage
(cd shared  && go build ./... && go vet ./...)
(cd server  && go build ./... && go vet ./...)
(cd sitesvc && go build ./... && go vet ./...)
(cd agent   && GOWORK=off go build ./... && GOWORK=off go vet ./...)
(cd web     && rm -rf .next && npm run build)
docker build -q -f server/Dockerfile -t vantage-server:lic .
```

Expected: all succeed.

- [ ] **Step 2: Boot against a scratch database and bootstrap**

```bash
cd c:/Work/Repos/vantage
docker run --rm -d --name vlic -p 8080:8080 \
  -e MONGO_URI=mongodb://host.docker.internal:27021 -e MONGO_DB=vantage_lic \
  -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6379 \
  -e VANTAGE_DEPLOYMENT=self_hosted \
  -e KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 \
  --add-host host.docker.internal:host-gateway vantage-server:lic

sleep 12
curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json' \
  -d '{"instance_name":"Licence Test","email":"owner@example.com","password":"hunter2hunter2"}'
```

Expected: JSON including `instance_id`. **Record that UUID** — every step below uses it.

- [ ] **Step 3: Confirm the unlicensed instance is read-only**

```bash
curl -s -X POST localhost:8080/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/lic.txt
curl -s localhost:8080/api/license -b /tmp/lic.txt
curl -s -o /dev/null -w "create server: %{http_code}\n" \
  -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
curl -s -o /dev/null -w "list servers: %{http_code}\n" \
  localhost:8080/api/servers -b /tmp/lic.txt
```

Expected: `/api/license` reports `"state":"invalid"`, `"reason":"no_license"`. Creating a server returns **403**. Listing servers returns **200** — reading is never blocked.

- [ ] **Step 4: Issue a Self Hosted licence and paste it**

```bash
cd c:/Work/Repos/vantage/shared
export LICENSE_SIGNING_KEY='<the production private key>'
go run ./cmd/lkctl issue --instance-id=<UUID from step 2> \
  --instance-name="Licence Test" --tier=self_hosted --term=1y > /tmp/sh.lic

BLOB=$(cat /tmp/sh.lic)
curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
  -H 'Content-Type: application/json' -d "{\"blob\":\"$BLOB\"}"
```

Expected: `{"state":"valid","tier":"self_hosted",...}`.

- [ ] **Step 5: Confirm mutations now work, within a minute and without a restart**

```bash
curl -s -o /dev/null -w "create server: %{http_code}\n" \
  -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
curl -s localhost:8080/api/license -b /tmp/lic.txt
```

Expected: **201**, and the licence reports `valid`, tier `self_hosted`, `max_servers: -1`, both features true.

- [ ] **Step 6: Confirm each rejection message**

```bash
# Wrong instance
cd c:/Work/Repos/vantage/shared
go run ./cmd/lkctl issue --instance-id=99999999-9999-9999-9999-999999999999 \
  --tier=self_hosted --term=1y > /tmp/other.lic
curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
  -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/other.lic)\"}"

# Cloud-only Free licence on a self-hosted install
go run ./cmd/lkctl issue --instance-id=<UUID from step 2> --tier=free --term=1m > /tmp/free.lic
curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
  -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/free.lic)\"}"

# Garbage
curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
  -H 'Content-Type: application/json' -d '{"blob":"NOTALICENCE"}'
```

Expected, in order:

1. `"This licence was issued for a different instance. Your instance ID is <UUID>."`
2. `"This licence is for Vantage Cloud and cannot be used on a self-hosted install."` — **this is the check that makes Free cloud-only**
3. `"This licence key is not valid. Check it was copied in full."`

And after all three, `GET /api/license` still reports `valid` — a rejected blob never replaces a good one.

- [ ] **Step 7: Confirm expiry degrades correctly, and that monitors keep running**

```bash
cd c:/Work/Repos/vantage/shared
go run ./cmd/lkctl issue --instance-id=<UUID from step 2> --tier=self_hosted \
  --expires=$(date -u -d '+70 seconds' +%Y-%m-%dT%H:%M:%SZ) > /tmp/soon.lic
curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
  -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/soon.lic)\"}"
```

Create a monitor while it is still valid, then wait past expiry plus the 60-second cache and check:

```bash
sleep 140
curl -s localhost:8080/api/license -b /tmp/lic.txt
curl -s -o /dev/null -w "create server:   %{http_code}\n" -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
curl -s -o /dev/null -w "list servers:    %{http_code}\n" localhost:8080/api/servers -b /tmp/lic.txt
curl -s -o /dev/null -w "delete a server: %{http_code}\n" -X DELETE localhost:8080/api/servers/<id> -b /tmp/lic.txt
docker logs vlic 2>&1 | grep -ci "monitor" || true
```

Expected: state `expired`; create **403**; list **200**; delete **2xx** (deletes are always allowed); and the monitor scheduler still logging activity. **Monitoring must not have gone dark.**

- [ ] **Step 8: Confirm recovery without a restart**

```bash
curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
  -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/sh.lic)\"}"
curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
```

Expected: **201**. Pasting a valid licence restores normal operation immediately.

- [ ] **Step 9: Confirm the Free tier caps, on a cloud instance**

Restart the container with `-e VANTAGE_DEPLOYMENT=cloud` against a fresh database, bootstrap, issue a Free licence for the new instance ID, paste it, then create servers until refused.

Expected: servers 1, 2 and 3 succeed; the 4th returns **403** with
`{"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
Then delete one and confirm creating again succeeds — the customer is never trapped.

Also confirm `POST /api/console/connect` returns **403** with
`{"error":"feature_unavailable","feature":"console"}`.

- [ ] **Step 10: Confirm apply-updates is never blocked**

With the licence expired, call `POST /api/servers/<id>/apply-updates`.

Expected: **not** a `license_required` 403. Security patching is exempt by design.

- [ ] **Step 11: Clean up**

```bash
docker rm -f vlic
docker exec mongo-h mongosh --quiet --eval 'db.getSiblingDB("vantage_lic").dropDatabase()'
unset LICENSE_SIGNING_KEY
rm -f /tmp/sh.lic /tmp/other.lic /tmp/free.lic /tmp/soon.lic /tmp/lic.txt
```

- [ ] **Step 12: Commit**

```bash
git add -A
git commit -m "chore: verify instance licensing end to end" --allow-empty
```

## Rollout

**Cloud:**

1. Issue a Professional licence for every existing instance with `lkctl`, one year out.
2. Build the JSON map and deploy once with `VANTAGE_GRANDFATHER_BLOBS` set and `VANTAGE_DEPLOYMENT=cloud`.
3. Confirm every instance reports `valid` before the traffic switch.
4. Unset the variable on the next deploy — it is single-use.

**Self-hosted:** the release notes must lead with the fact that upgrading now requires a licence key, where to get one, and that the instance ID is shown at `Settings → Licence`. Without a licence the instance is read-only on first boot — monitors keep running, but nothing can be changed.

## Risks

| Risk                                        | Mitigation                                                                                                     |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| A mutating route added later without a gate | Mounted on the `/api` group, so new routes are covered by default; Task 5 Step 6 audits the exceptions         |
| Customer locked out and unable to recover   | `POST /api/license` and every `DELETE` are exempt                                                              |
| Existing cloud tenants degrade on deploy    | Migration 0005, verified before the traffic switch; it refuses to record its marker if any instance was missed |
| Over-limit customer trapped                 | Deletes always allowed; existing resources never truncated                                                     |
| Monitoring lost on billing failure          | Designed out — the scheduler has no licence check, and Task 10 Step 7 verifies it                              |
| Clock wrong on a self-hosted host           | `Verify` reports `ClockSkewed`; surface it in the settings page if it becomes a support theme                  |
