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
+
Single sign-on: {license.features.oidc ? "Included" : "Not included"}
+
+
+
+
+
Add or replace a licence
+
+
+ );
+}
+```
+
+- [ ] **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"
+
+ "github.com/mrhid6/vantage/shared/license"
+ "github.com/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=''
+go run ./cmd/lkctl issue --instance-id= \
+ --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= --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 ."`
+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= --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/ -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//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 |
diff --git a/docs/superpowers/plans/2026-07-24-licensing-core.md b/docs/superpowers/plans/2026-07-24-licensing-core.md
new file mode 100644
index 0000000..1f837bd
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-24-licensing-core.md
@@ -0,0 +1,1032 @@
+# Licensing Core 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:** Add `shared/license`, the ed25519-signed licence payload with offline verification, plus `lkctl` to issue licences by hand.
+
+**Architecture:** A new package inside the existing `shared` module, so the control plane (verifier) and the future admin service (signer) read one struct definition. Signing is excluded from the server binary by a build tag; the trusted public keys are compiled in as a slice. No network calls anywhere — verification checks a signature, an expiry, a deployment mode and an instance ID, and asks nobody's permission.
+
+**Tech Stack:** Go 1.26, `github.com/hyperboloide/lk` (ed25519 + base32), the existing `shared` module.
+
+**No automated tests.** Verification is by compiler, a probe command, and `lkctl` round trips at the command line. Every task ends with observable output.
+
+## Global Constraints
+
+- Package path: `github.com/mrhid6/vantage/shared/license`
+- `shared/go.mod` gains **one** dependency: `github.com/hyperboloide/lk`. This is a deliberate exception to plan 0a's three-dependency limit, and the only one. `shared` must still not import gin, redis, guac or the mongo-driver from this package.
+- Tier and deployment values are exact strings: `free`, `professional`, `self_hosted`; `cloud`, `self_hosted`.
+- Feature keys are exact strings: `console`, `oidc`.
+- `-1` means unlimited in every `Limits` field.
+- **The verifier never branches on `Tier`.** It reads `Limits` and `Features` only. `Tier` is for display, support and analytics.
+- **Every licence is bound to an instance.** There is no unbound licence and no claim protocol.
+- The private signing key never appears in the repo, in an image, or in the control plane's environment.
+- Do not run the app with `go run` — use the Docker images. `lkctl` is a local CLI, not the app, and may be run directly.
+
+---
+
+## File Structure
+
+**Created:**
+
+| Path | Responsibility |
+|---|---|
+| `shared/license/license.go` | `License`, `Limits`, tier/deployment/feature constants |
+| `shared/license/keys.go` | `trustedPublicKeys`, key lookup |
+| `shared/license/sign.go` | `Sign`, build-tagged out of the server |
+| `shared/license/verify.go` | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State` |
+| `shared/license/plans.go` | The tier seed table used by `lkctl` |
+| `shared/cmd/lkctl/main.go` | `keypair`, `issue`, `inspect` |
+
+**Modified:** `shared/go.mod`, `shared/go.sum`.
+
+---
+
+### Task 1: Add the dependency and confirm the library's API
+
+The rest of the plan assumes specific `lk` function names. Confirm them first rather than discovering a mismatch three tasks later.
+
+**Files:**
+- Modify: `shared/go.mod`, `shared/go.sum`
+- Create (temporary): `shared/cmd/lkprobe/main.go`
+
+**Interfaces:**
+- Consumes: nothing
+- Produces: a pinned `lk` version and a confirmed API surface
+
+- [ ] **Step 1: Add the dependency**
+
+```bash
+cd c:/Work/Repos/vantage/shared
+go get github.com/hyperboloide/lk@latest
+```
+
+- [ ] **Step 2: Write a probe that exercises the whole surface this plan needs**
+
+Create `shared/cmd/lkprobe/main.go`:
+
+```go
+package main
+
+import (
+ "fmt"
+
+ "github.com/hyperboloide/lk"
+)
+
+func main() {
+ priv, err := lk.NewPrivateKey()
+ if err != nil {
+ panic(err)
+ }
+ privStr, err := priv.ToB32String()
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println("private b32 len:", len(privStr))
+
+ pub := priv.GetPublicKey()
+ pubStr, err := pub.ToB32String()
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println("public b32 len:", len(pubStr))
+
+ l, err := lk.NewLicense(priv, []byte(`{"hello":"world"}`))
+ if err != nil {
+ panic(err)
+ }
+ blob, err := l.ToB32String()
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println("licence b32 len:", len(blob))
+
+ parsed, err := lk.LicenseFromB32String(blob)
+ if err != nil {
+ panic(err)
+ }
+ pub2, err := lk.PublicKeyFromB32String(pubStr)
+ if err != nil {
+ panic(err)
+ }
+ ok, err := parsed.Verify(pub2)
+ fmt.Println("verify:", ok, err)
+ fmt.Println("data:", string(parsed.Data))
+
+ // Tamper: a licence signed by a different key must not verify.
+ other, _ := lk.NewPrivateKey()
+ bad, _ := lk.NewLicense(other, []byte(`{"hello":"world"}`))
+ badBlob, _ := bad.ToB32String()
+ badParsed, _ := lk.LicenseFromB32String(badBlob)
+ ok2, _ := badParsed.Verify(pub2)
+ fmt.Println("foreign key verify (must be false):", ok2)
+}
+```
+
+- [ ] **Step 3: Run the probe**
+
+Run: `cd shared && go run ./cmd/lkprobe`
+
+Expected: non-zero lengths for all three, `verify: true `, the JSON echoed back, and `foreign key verify (must be false): false`.
+
+**If any function name does not compile, fix the probe against the installed version and carry the corrected names through every later task in this plan.** The names used below are `lk.NewPrivateKey`, `PrivateKey.ToB32String`, `PrivateKey.GetPublicKey`, `PublicKey.ToB32String`, `lk.PrivateKeyFromB32String`, `lk.PublicKeyFromB32String`, `lk.NewLicense`, `License.ToB32String`, `lk.LicenseFromB32String`, `License.Verify`, `License.Data`.
+
+- [ ] **Step 4: Delete the probe and commit**
+
+```bash
+cd c:/Work/Repos/vantage
+rm -rf shared/cmd/lkprobe
+git add shared/go.mod shared/go.sum
+git commit -m "chore(shared): add hyperboloide/lk for licence signing"
+```
+
+---
+
+### Task 2: The payload and the tier table
+
+**Files:**
+- Create: `shared/license/license.go`
+- Create: `shared/license/plans.go`
+
+**Interfaces:**
+- Consumes: nothing
+- Produces:
+ - `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
+ - `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
+ - `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
+ - `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
+ - `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
+ - `func (l License) HasFeature(name string) bool`
+ - `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
+ - `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`
+
+- [ ] **Step 1: Write the payload**
+
+Create `shared/license/license.go`:
+
+```go
+// Package license defines the Vantage licence payload and its offline
+// verification.
+//
+// A licence is an ed25519-signed blob. The server checks a signature, an
+// expiry, a deployment mode and an instance ID, and asks nobody's permission.
+// That buys air-gapped self-hosting and means no instance depends on the
+// licensing service being reachable.
+//
+// It costs revocation: once issued, a licence is valid until it expires
+// whatever the billing system later says. Self Hosted is sold annually only so
+// that window is bounded.
+package license
+
+import "time"
+
+const (
+ TierFree = "free"
+ TierProfessional = "professional"
+ TierSelfHosted = "self_hosted"
+
+ DeploymentCloud = "cloud"
+ DeploymentSelfHosted = "self_hosted"
+
+ FeatureConsole = "console" // browser SSH/RDP/VNC
+ FeatureOIDC = "oidc" // per-instance single sign-on
+)
+
+// Unlimited is the sentinel for "no cap" in every Limits field.
+const Unlimited = -1
+
+// Limits are the countable caps a licence grants.
+type Limits struct {
+ MaxServers int `json:"max_servers"`
+ MaxSecretGroups int `json:"max_secret_groups"`
+ MaxChannels int `json:"max_channels"`
+}
+
+// License is the signed payload.
+//
+// InstanceID is always populated: the self-hosted purchase flow links the
+// instance UUID before the licence is signed, so there is no unbound licence
+// and no claim protocol.
+type License struct {
+ ID string `json:"id"` // uuid, for support and audit
+ InstanceID string `json:"instance_id"` // the instance this licence is bound to
+ AccountID string `json:"account_id"` // admin-side customer, informational
+ InstanceName string `json:"instance_name"` // display only
+ Tier string `json:"tier"`
+ Deployment string `json:"deployment"`
+ IssuedAt time.Time `json:"issued_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Limits Limits `json:"limits"`
+ Features []string `json:"features"`
+}
+
+// HasFeature reports whether the licence grants a named feature.
+//
+// Callers must use this rather than switching on Tier. Adding a tier, or
+// changing what a tier includes, must never require a server release.
+func (l License) HasFeature(name string) bool {
+ for _, f := range l.Features {
+ if f == name {
+ return true
+ }
+ }
+ return false
+}
+
+// WithinLimit reports whether one more of something is allowed.
+// A max of Unlimited always allows.
+func WithinLimit(current, max int) bool {
+ if max == Unlimited {
+ return true
+ }
+ return current < max
+}
+```
+
+- [ ] **Step 2: Write the tier seed table**
+
+Create `shared/license/plans.go`:
+
+```go
+package license
+
+// Plan is the contents of a tier at issue time.
+//
+// This table is the seed. Once the admin service exists (spec 3) it owns the
+// authoritative copy in its `plans` collection, and every issued licence
+// snapshots the plan it was cut from — so editing a plan never rewrites an
+// existing licence, the same rule as workflow_runs.steps_snapshot.
+//
+// lkctl uses this table to issue by hand until then.
+type Plan struct {
+ Tier string
+ Name string
+ Deployment string
+ Limits Limits
+ Features []string
+}
+
+var plans = map[string]Plan{
+ TierFree: {
+ Tier: TierFree,
+ Name: "Free",
+ Deployment: DeploymentCloud, // cloud only, by construction
+ Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
+ Features: nil,
+ },
+ TierProfessional: {
+ Tier: TierProfessional,
+ Name: "Professional",
+ Deployment: DeploymentCloud,
+ Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
+ Features: []string{FeatureConsole, FeatureOIDC},
+ },
+ TierSelfHosted: {
+ Tier: TierSelfHosted,
+ Name: "Self Hosted",
+ Deployment: DeploymentSelfHosted,
+ Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
+ Features: []string{FeatureConsole, FeatureOIDC},
+ },
+}
+
+// PlanFor returns the seed plan for a tier.
+func PlanFor(tier string) (Plan, bool) {
+ p, ok := plans[tier]
+ return p, ok
+}
+```
+
+Note Free's `Deployment` is `cloud`. That single value is what makes Free cloud-only: verification rejects a deployment mismatch, so a self-hosted install can never hold a valid Free licence, and there is no server-side flag to edit.
+
+- [ ] **Step 3: Build**
+
+Run: `cd shared && go build ./... && go vet ./...`
+Expected: no output.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add shared/license/license.go shared/license/plans.go
+git commit -m "feat(license): add the licence payload and tier seed table"
+```
+
+---
+
+### Task 3: Signing, and keeping it out of the server
+
+**Files:**
+- Create: `shared/license/keys.go`
+- Create: `shared/license/sign.go`
+
+**Interfaces:**
+- Consumes: `License`
+- Produces:
+ - `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
+ - `var trustedPublicKeys []string`
+ - `func publicKeys() ([]*lk.PublicKey, error)`
+
+- [ ] **Step 1: Write the trusted key list**
+
+Create `shared/license/keys.go`:
+
+```go
+package license
+
+import (
+ "fmt"
+
+ "github.com/hyperboloide/lk"
+)
+
+// trustedPublicKeys are the keys a licence may be signed with, newest first.
+//
+// To rotate: prepend the new key, ship a server release that trusts both, then
+// reissue. Remove a retired key only once every licence signed with it has
+// expired.
+//
+// This is a slice from day one even though it holds one entry, because
+// retrofitting a single-key verifier into a multi-key one during an incident is
+// not a thing to plan for.
+//
+// These are compiled in and deliberately not configurable. A configurable trust
+// root is a licensing bypass: a self-hosted operator could point it at a keypair
+// they generated themselves.
+var trustedPublicKeys = []string{
+ // Populated in Task 6 with the real production key.
+ // Until then this slice is empty and every licence fails to verify,
+ // which is the correct default for a build with no trust root.
+}
+
+func publicKeys() ([]*lk.PublicKey, error) {
+ out := make([]*lk.PublicKey, 0, len(trustedPublicKeys))
+ for i, s := range trustedPublicKeys {
+ k, err := lk.PublicKeyFromB32String(s)
+ if err != nil {
+ return nil, fmt.Errorf("trusted public key %d is malformed: %w", i, err)
+ }
+ out = append(out, k)
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 2: Write the signer, excluded from the server build**
+
+Create `shared/license/sign.go`:
+
+```go
+//go:build !noSign
+
+package license
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/hyperboloide/lk"
+)
+
+// Sign marshals a licence and signs it, returning the base32 blob.
+//
+// This file carries the !noSign build tag so the signing path can be compiled
+// out of the control plane. The server has no reason to hold signing code and
+// no reason to ship it into a customer's data centre.
+//
+// privateKeyB32 comes from LICENSE_SIGNING_KEY on the issuing side only.
+func Sign(l License, privateKeyB32 string) (string, error) {
+ if privateKeyB32 == "" {
+ return "", fmt.Errorf("no signing key provided")
+ }
+ priv, err := lk.PrivateKeyFromB32String(privateKeyB32)
+ if err != nil {
+ return "", fmt.Errorf("parse signing key: %w", err)
+ }
+
+ data, err := json.Marshal(l)
+ if err != nil {
+ return "", fmt.Errorf("marshal licence: %w", err)
+ }
+
+ signed, err := lk.NewLicense(priv, data)
+ if err != nil {
+ return "", fmt.Errorf("sign licence: %w", err)
+ }
+
+ blob, err := signed.ToB32String()
+ if err != nil {
+ return "", fmt.Errorf("encode licence: %w", err)
+ }
+ return blob, nil
+}
+```
+
+- [ ] **Step 3: Build both ways**
+
+Run:
+
+```bash
+cd c:/Work/Repos/vantage/shared
+go build ./... && go vet ./...
+go build -tags noSign ./...
+```
+
+Expected: no output from any of them.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add shared/license/keys.go shared/license/sign.go
+git commit -m "feat(license): add signing and the trusted key list"
+```
+
+---
+
+### Task 4: Verification
+
+The heart of the system. Check order is part of the contract, because the reason drives the message a customer sees.
+
+**Files:**
+- Create: `shared/license/verify.go`
+
+**Interfaces:**
+- Consumes: `License`, `publicKeys()`
+- Produces:
+ - `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
+ - `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
+ - `type Result struct { License License; State State; Reason string }`
+ - `func Verify(blob string, opts VerifyOpts) Result`
+ - `func Parse(blob string) (License, error)`
+ - Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`
+
+- [ ] **Step 1: Write the verifier**
+
+Create `shared/license/verify.go`:
+
+```go
+package license
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/hyperboloide/lk"
+)
+
+type State string
+
+const (
+ StateValid State = "valid"
+ StateExpired State = "expired"
+ StateInvalid State = "invalid"
+)
+
+// Reasons a licence is not valid. These are stable identifiers: the API returns
+// them and the UI maps them to messages, so do not reword them casually.
+const (
+ ReasonNoLicense = "no_license"
+ ReasonBadSignature = "bad_signature"
+ ReasonDeploymentMismatch = "deployment_mismatch"
+ ReasonInstanceMismatch = "instance_mismatch"
+ ReasonExpired = "expired"
+)
+
+// VerifyOpts is what the verifier knows about itself.
+type VerifyOpts struct {
+ InstanceID string // this instance's own ID; required
+ Deployment string // "cloud" or "self_hosted"; required
+ Now time.Time // zero means time.Now()
+}
+
+type Result struct {
+ License License
+ State State
+ Reason string
+ // ClockSkewed is set when IssuedAt is in the future, which usually means
+ // the host clock is wrong. It does not by itself invalidate the licence.
+ ClockSkewed bool
+}
+
+// Verify checks a licence blob against this instance.
+//
+// The checks run in a fixed order and stop at the first failure:
+//
+// 1. signature against a trusted public key -> bad_signature
+// 2. deployment matches this install -> deployment_mismatch
+// 3. instance ID matches this instance -> instance_mismatch
+// 4. not past ExpiresAt -> expired
+//
+// The order matters. A blob that is both expired and bound to another instance
+// reports instance_mismatch, not expired, because that is the more useful thing
+// to tell the person holding it.
+//
+// No clock tolerance is applied. Terms are a month or a year; a host whose clock
+// is wrong by enough to matter has larger problems, and a tolerance window is a
+// thing to get wrong.
+func Verify(blob string, opts VerifyOpts) Result {
+ if blob == "" {
+ return Result{State: StateInvalid, Reason: ReasonNoLicense}
+ }
+
+ l, err := Parse(blob)
+ if err != nil {
+ return Result{State: StateInvalid, Reason: ReasonBadSignature}
+ }
+
+ res := Result{License: l}
+
+ if l.Deployment != opts.Deployment {
+ res.State, res.Reason = StateInvalid, ReasonDeploymentMismatch
+ return res
+ }
+ if l.InstanceID != opts.InstanceID {
+ res.State, res.Reason = StateInvalid, ReasonInstanceMismatch
+ return res
+ }
+
+ now := opts.Now
+ if now.IsZero() {
+ now = time.Now()
+ }
+ res.ClockSkewed = l.IssuedAt.After(now)
+
+ if !now.Before(l.ExpiresAt) {
+ res.State, res.Reason = StateExpired, ReasonExpired
+ return res
+ }
+
+ res.State = StateValid
+ return res
+}
+
+// Parse verifies the signature only, ignoring binding and expiry.
+//
+// Used to display a licence and to inspect a blob a customer has emailed in.
+// Never use it for enforcement — it does not check who the licence is for.
+func Parse(blob string) (License, error) {
+ parsed, err := lk.LicenseFromB32String(blob)
+ if err != nil {
+ return License{}, fmt.Errorf("licence is not readable: %w", err)
+ }
+
+ keys, err := publicKeys()
+ if err != nil {
+ return License{}, err
+ }
+ if len(keys) == 0 {
+ return License{}, fmt.Errorf("this build trusts no licence signing keys")
+ }
+
+ verified := false
+ for _, k := range keys {
+ ok, err := parsed.Verify(k)
+ if err == nil && ok {
+ verified = true
+ break
+ }
+ }
+ if !verified {
+ return License{}, fmt.Errorf("licence signature does not match any trusted key")
+ }
+
+ var l License
+ if err := json.Unmarshal(parsed.Data, &l); err != nil {
+ return License{}, fmt.Errorf("licence contents are not readable: %w", err)
+ }
+ return l, nil
+}
+```
+
+- [ ] **Step 2: Build both ways**
+
+Run:
+
+```bash
+cd c:/Work/Repos/vantage/shared
+go build ./... && go vet ./...
+go build -tags noSign ./...
+```
+
+Expected: no output. The `noSign` build must succeed — `verify.go` must not reference anything in `sign.go`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add shared/license/verify.go
+git commit -m "feat(license): add offline verification"
+```
+
+---
+
+### Task 5: lkctl
+
+The production issuance path until the admin service exists, and the disaster-recovery path forever after — if admin is down and a customer's licence expires, a blob can still be cut by hand.
+
+**Files:**
+- Create: `shared/cmd/lkctl/main.go`
+
+**Interfaces:**
+- Consumes: `Sign`, `Parse`, `PlanFor`, `License`
+- Produces: the `lkctl` binary
+
+- [ ] **Step 1: Write it**
+
+Create `shared/cmd/lkctl/main.go`:
+
+```go
+// Command lkctl issues and inspects Vantage licences by hand.
+//
+// lkctl keypair
+// lkctl issue --instance-id= --instance-name="Acme" --tier=professional --term=1y
+// lkctl inspect
+//
+// issue reads the signing key from LICENSE_SIGNING_KEY.
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/hyperboloide/lk"
+ "github.com/mrhid6/vantage/shared/license"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ }
+ switch os.Args[1] {
+ case "keypair":
+ keypair()
+ case "issue":
+ issue(os.Args[2:])
+ case "inspect":
+ inspect(os.Args[2:])
+ default:
+ usage()
+ }
+}
+
+func usage() {
+ fmt.Fprintln(os.Stderr, "usage: lkctl keypair | issue | inspect")
+ os.Exit(2)
+}
+
+func keypair() {
+ priv, err := lk.NewPrivateKey()
+ if err != nil {
+ fatal("generate key: %v", err)
+ }
+ privStr, err := priv.ToB32String()
+ if err != nil {
+ fatal("encode private key: %v", err)
+ }
+ pubStr, err := priv.GetPublicKey().ToB32String()
+ if err != nil {
+ fatal("encode public key: %v", err)
+ }
+
+ fmt.Println("PRIVATE KEY (store in a password manager and in the admin service's")
+ fmt.Println("LICENSE_SIGNING_KEY; back it up in two places, it cannot be recovered):")
+ fmt.Println()
+ fmt.Println(privStr)
+ fmt.Println()
+ fmt.Println("PUBLIC KEY (paste into trustedPublicKeys in shared/license/keys.go):")
+ fmt.Println()
+ fmt.Println(pubStr)
+}
+
+func issue(args []string) {
+ fs := flag.NewFlagSet("issue", flag.ExitOnError)
+ instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
+ instanceName := fs.String("instance-name", "", "display name")
+ accountID := fs.String("account-id", "", "admin-side account id, optional")
+ tier := fs.String("tier", "", "free | professional | self_hosted (required)")
+ term := fs.String("term", "1y", "1m or 1y")
+ expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
+ out := fs.String("out", "", "write the blob to this file instead of stdout")
+ fs.Parse(args)
+
+ if *instanceID == "" || *tier == "" {
+ fatal("--instance-id and --tier are required")
+ }
+
+ plan, ok := license.PlanFor(*tier)
+ if !ok {
+ fatal("unknown tier %q", *tier)
+ }
+
+ key := os.Getenv("LICENSE_SIGNING_KEY")
+ if key == "" {
+ fatal("LICENSE_SIGNING_KEY is not set")
+ }
+
+ now := time.Now().UTC()
+ var exp time.Time
+ switch {
+ case *expires != "":
+ t, err := time.Parse(time.RFC3339, *expires)
+ if err != nil {
+ fatal("parse --expires: %v", err)
+ }
+ exp = t.UTC()
+ case *term == "1m":
+ exp = now.AddDate(0, 1, 0)
+ case *term == "1y":
+ exp = now.AddDate(1, 0, 0)
+ default:
+ fatal("--term must be 1m or 1y")
+ }
+
+ // Self Hosted is sold annually only, so the window in which a cancelled
+ // licence keeps working is bounded at a year.
+ if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
+ fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
+ }
+
+ name := *instanceName
+ if name == "" {
+ name = *instanceID
+ }
+
+ l := license.License{
+ ID: uuid.NewString(),
+ InstanceID: *instanceID,
+ AccountID: *accountID,
+ InstanceName: name,
+ Tier: plan.Tier,
+ Deployment: plan.Deployment,
+ IssuedAt: now,
+ ExpiresAt: exp,
+ Limits: plan.Limits,
+ Features: plan.Features,
+ }
+
+ blob, err := license.Sign(l, key)
+ if err != nil {
+ fatal("%v", err)
+ }
+
+ if *out != "" {
+ if err := os.WriteFile(*out, []byte(blob+"\n"), 0o600); err != nil {
+ fatal("write %s: %v", *out, err)
+ }
+ fmt.Fprintf(os.Stderr, "wrote %s (tier=%s deployment=%s expires=%s)\n",
+ *out, l.Tier, l.Deployment, l.ExpiresAt.Format(time.RFC3339))
+ return
+ }
+ fmt.Println(blob)
+}
+
+func inspect(args []string) {
+ if len(args) < 1 {
+ fatal("usage: lkctl inspect ")
+ }
+ blob := args[0]
+ if b, err := os.ReadFile(blob); err == nil {
+ blob = strings.TrimSpace(string(b))
+ }
+
+ l, err := license.Parse(blob)
+ if err != nil {
+ fatal("%v", err)
+ }
+
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(l); err != nil {
+ fatal("%v", err)
+ }
+
+ if time.Now().After(l.ExpiresAt) {
+ fmt.Fprintf(os.Stderr, "\nNOTE: expired %s\n", l.ExpiresAt.Format(time.RFC3339))
+ }
+}
+
+func fatal(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
+```
+
+- [ ] **Step 2: Build**
+
+Run: `cd shared && go build ./... && go vet ./...`
+Expected: no output.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add shared/cmd/lkctl
+git commit -m "feat(license): add lkctl for issuing licences by hand"
+```
+
+---
+
+### Task 6: Generate the production keypair and wire it in
+
+This is the step that makes the system real. Do it once, carefully.
+
+**Files:**
+- Modify: `shared/license/keys.go`
+
+**Interfaces:**
+- Consumes: `lkctl keypair`
+- Produces: a populated `trustedPublicKeys`
+
+- [ ] **Step 1: Generate the keypair**
+
+Run: `cd shared && go run ./cmd/lkctl keypair`
+
+- [ ] **Step 2: Store the private key**
+
+Put the private key in **two** places before continuing:
+
+1. A password manager entry named "Vantage licence signing key (production)".
+2. The admin service's secret store, ready for `LICENSE_SIGNING_KEY` in spec 3.
+
+**If this key is lost, no new licence can be issued for any existing customer without shipping a server release.** There is no recovery. Confirm both copies exist and are readable before moving on.
+
+- [ ] **Step 3: Paste the public key in**
+
+In `shared/license/keys.go`, replace the empty slice:
+
+```go
+var trustedPublicKeys = []string{
+ // Production signing key, generated 2026-07-24. Index 0 is current.
+ "",
+}
+```
+
+- [ ] **Step 4: Confirm a full round trip**
+
+```bash
+cd c:/Work/Repos/vantage/shared
+export LICENSE_SIGNING_KEY=''
+go run ./cmd/lkctl issue \
+ --instance-id=11111111-2222-3333-4444-555555555555 \
+ --instance-name="Round Trip Ltd" \
+ --tier=professional --term=1y > /tmp/rt.lic
+go run ./cmd/lkctl inspect /tmp/rt.lic
+```
+
+Expected: JSON showing `"tier": "professional"`, `"deployment": "cloud"`, the instance ID you passed, `max_servers: -1`, and `features` containing `console` and `oidc`.
+
+- [ ] **Step 5: Confirm the rejections**
+
+Create a temporary `shared/cmd/vercheck/main.go`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/mrhid6/vantage/shared/license"
+)
+
+func main() {
+ b, err := os.ReadFile(os.Args[1])
+ if err != nil {
+ panic(err)
+ }
+ blob := strings.TrimSpace(string(b))
+
+ const good = "11111111-2222-3333-4444-555555555555"
+
+ show := func(label string, r license.Result) {
+ fmt.Printf("%-22s state=%-8s reason=%s\n", label, r.State, r.Reason)
+ }
+
+ show("correct", license.Verify(blob, license.VerifyOpts{
+ InstanceID: good, Deployment: license.DeploymentCloud}))
+
+ show("wrong instance", license.Verify(blob, license.VerifyOpts{
+ InstanceID: "99999999-9999-9999-9999-999999999999", Deployment: license.DeploymentCloud}))
+
+ show("wrong deployment", license.Verify(blob, license.VerifyOpts{
+ InstanceID: good, Deployment: license.DeploymentSelfHosted}))
+
+ show("expired", license.Verify(blob, license.VerifyOpts{
+ InstanceID: good, Deployment: license.DeploymentCloud,
+ Now: time.Now().AddDate(2, 0, 0)}))
+
+ show("tampered", license.Verify(blob[:len(blob)-2]+"AA", license.VerifyOpts{
+ InstanceID: good, Deployment: license.DeploymentCloud}))
+
+ show("empty", license.Verify("", license.VerifyOpts{
+ InstanceID: good, Deployment: license.DeploymentCloud}))
+}
+```
+
+Run: `cd shared && go run ./cmd/vercheck /tmp/rt.lic`
+
+Expected, exactly:
+
+```
+correct state=valid reason=
+wrong instance state=invalid reason=instance_mismatch
+wrong deployment state=invalid reason=deployment_mismatch
+expired state=expired reason=expired
+tampered state=invalid reason=bad_signature
+empty state=invalid reason=no_license
+```
+
+**`wrong deployment` reporting `deployment_mismatch` rather than `instance_mismatch` proves the check order.** That order is what makes Free cloud-only.
+
+- [ ] **Step 6: Confirm a Free licence cannot be issued for self-hosted use**
+
+```bash
+cd shared
+go run ./cmd/lkctl issue --instance-id=$(uuidgen 2>/dev/null || echo 11111111-2222-3333-4444-555555555555) \
+ --tier=free --term=1m > /tmp/free.lic
+go run ./cmd/vercheck /tmp/free.lic
+```
+
+Expected: the `correct` line reports `valid` only when the instance matches; the `wrong deployment` line — which asks for `self_hosted` — reports `deployment_mismatch`. A Free licence is signed `deployment: cloud` and can never satisfy a self-hosted install.
+
+- [ ] **Step 7: Confirm Self Hosted refuses a monthly term**
+
+Run: `cd shared && go run ./cmd/lkctl issue --instance-id=x --tier=self_hosted --term=1m`
+Expected: `self_hosted is annual only; use --term=1y or an explicit --expires`, exit 1.
+
+- [ ] **Step 8: Confirm the signer is absent from a noSign build**
+
+```bash
+cd c:/Work/Repos/vantage/shared
+go build -tags noSign -o /tmp/nosign-probe ./cmd/lkctl 2>&1 | head -3
+```
+
+Expected: a **compile error** naming `license.Sign` as undefined. That failure is the proof the build tag works — `lkctl` needs the signer, so a `noSign` build of it must not link.
+
+- [ ] **Step 9: Clean up and commit**
+
+```bash
+cd c:/Work/Repos/vantage
+rm -rf shared/cmd/vercheck /tmp/rt.lic /tmp/free.lic
+unset LICENSE_SIGNING_KEY
+git add shared/license/keys.go
+git commit -m "feat(license): trust the production signing key"
+```
+
+**Do not commit the private key. Check the diff before committing.**
+
+---
+
+### Task 7: Final verification
+
+**Files:** none
+
+- [ ] **Step 1: Build every module**
+
+```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 ./...)
+```
+
+Expected: no output.
+
+- [ ] **Step 2: Confirm the images still build**
+
+```bash
+cd c:/Work/Repos/vantage
+docker build -q -f server/Dockerfile -t vantage-server:lic .
+docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:lic .
+```
+
+Expected: both print an image digest.
+
+- [ ] **Step 3: Confirm the signing key is nowhere in the repo**
+
+```bash
+cd c:/Work/Repos/vantage
+git log -p --all | grep -c "LICENSE_SIGNING_KEY='" || true
+grep -rn "$(echo YOUR_PRIVATE_KEY_PREFIX)" --include=* . 2>/dev/null | head -3
+```
+
+Expected: no commit contains the private key. Check the first 12 characters of the real private key against the working tree and the log before considering this done.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "chore: verify licensing core end to end" --allow-empty
+```
+
+## What this unblocks
+
+Plan 2 (`instance-licensing`) can store a blob on the instance document, resolve
+it into a runtime state, and gate the API on it — with real licences to test
+against, cut by `lkctl`, before any admin service exists.
diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md
index a69e8ec..9bbe909 100644
--- a/docs/superpowers/specs/README.md
+++ b/docs/superpowers/specs/README.md
@@ -2,15 +2,18 @@
Seven specs, designed 2026-07-24. Build in this order.
-| # | Spec | Ships alone | Blocks |
+| # | Spec | Plan | Status |
|---|---|---|---|
-| 0a | [shared-module](2026-07-24-shared-module-design.md) | yes | everything |
-| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | yes | 1, 2, 3 |
-| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | yes | 2, 3 |
-| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | yes, with `lkctl`-issued licences | — |
-| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | no | 4, 5 |
-| 4 | [admin-site](2026-07-24-admin-site-design.md) | no | — |
-| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | no | — |
+| 0a | [shared-module](2026-07-24-shared-module-design.md) | [plan](../plans/2026-07-24-shared-module.md) | **shipped** |
+| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
+| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | planned |
+| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | planned |
+| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | — | blocks 4 and 5 |
+| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 |
+| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 |
+
+Specs 1 and 2 together give working licensing with licences cut by hand with
+`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
4 and 5 can run in parallel once 3 lands.