# Admin Backend 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:** Build `admin/`, a fourth Go service that owns accounts, instances, licences and subscriptions, holds the only signing key, issues and injects licences, and never becomes a runtime dependency of any Vantage instance.

**Architecture:** A gin service on `8083` with two MongoDB connections — its own `vantage_admin` database, and a narrow read/write path into the control plane that touches exactly three fields on `instances`. Licences are append-only: a renewal writes a new row and supersedes the old. Delivery is best-effort and recorded first; a reconciliation job every 15 minutes is what actually guarantees cloud instances end up holding the licence admin says they hold.

**Tech Stack:** Go 1.26, gin, MongoDB driver v2, Redis (sessions), `shared/license` and `shared/models` from plans 0a and 1.

## Global Constraints

- **No automated Go tests.** Verification is by compiler, `grep`, `lkctl`, and running the built image against scratch databases. Every "confirm" step below is a container command with expected output.
- **Never run `go run` or `npm` on the host.** Everything runs in a container. The wrapper from plans 1 and 2:
    ```sh
    # /tmp/gorun.sh <module-dir> <command...>
    DIR="$1"; shift
    MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
      -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
      -e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" golang:1.26 "$@"
    ```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry" — this exact bug shipped and was caught by the image build in plan 2, not by `go build`.
- Admin is **excluded from the self-hosted deployment**: `deploy/docker-compose.site.yml` only, like sitesvc.
- Admin **must never write to any control-plane collection except `instances`**, and only the three licence fields.
- The control plane holds **no signing key and no signing code path**. Nothing in this plan changes that.
- Licence terms carry a **3-day grace** beyond the billing period end.
- Free is cloud-only by construction: its plan is `deployment: cloud` and `Issue` refuses a deployment mismatch. There is no flag.
- Customer endpoints return **404, never 403**, for another account's resource.

## Context this plan inherits

Plan 2 shipped without grandfathering — the migration that would have licensed existing cloud instances was removed at the user's direction. **Every existing cloud instance is therefore read-only right now**, and stays that way until someone issues it a licence.

There is deliberately no automated backfill. Those instances get licensed by hand through the admin UI once spec 4 lands, using `POST /api/staff/instances` to attach each one to an account followed by `POST /api/staff/instances/:id/issue`. Task 11 Step 5 walks that exact flow, so the path is proven by the time the UI needs it.

---

## File Structure

**Created:**

| Path                                                      | Responsibility                                                                                      |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/`                                           |
| `admin/cmd/main.go`                                       | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP                |
| `admin/cmd/adminctl/main.go`                              | staff user creation; deliberately has no HTTP surface                                               |
| `admin/internal/config/config.go`                         | env parsing, fail-fast validation                                                                   |
| `admin/internal/db/db.go`                                 | `Admin()` and `Control()` collections, connect, index creation                                      |
| `admin/internal/models/models.go`                         | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` |
| `admin/internal/models/plans.go`                          | plan seeding from `shared/license`                                                                  |
| `admin/internal/licensing/issue.go`                       | `Issue`, the Free rule, the deployment check, supersession                                          |
| `admin/internal/licensing/link.go`                        | `LinkInstance`, `Relink`                                                                            |
| `admin/internal/inject/inject.go`                         | `InjectCloud`, `Reconcile`, `StartReconciler`                                                       |
| `admin/internal/auth/session.go`                          | Redis sessions, `admin_session` cookie                                                              |
| `admin/internal/auth/staff.go`                            | staff login                                                                                         |
| `admin/internal/auth/cloud.go`                            | cloud-owner login against the control plane                                                         |
| `admin/internal/auth/customer.go`                         | self-hosted customer login, signup, email verification                                              |
| `admin/internal/auth/ratelimit.go`                        | per-email and per-IP limiters                                                                       |
| `admin/internal/auth/middleware.go`                       | `RequireStaff`, `RequireCustomer`, `AccountID`                                                      |
| `admin/internal/api/routes.go`                            | route table — the single place scoping is guaranteed                                                |
| `admin/internal/api/customer.go`                          | customer handlers                                                                                   |
| `admin/internal/api/staff.go`                             | staff handlers                                                                                      |
| `admin/internal/mail/mail.go`                             | licence delivery and verification email                                                             |
| `admin/internal/audit/audit.go`                           | `admin_audit` writes                                                                                |

**Modified:** `go.work`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`.

---

### Task 1: Module, config and boot

**Files:**

- Create: `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore`, `admin/cmd/main.go`, `admin/internal/config/config.go`, `admin/internal/db/db.go`
- Modify: `go.work`

**Interfaces:**

- Consumes: `shared/models`, `shared/license`
- Produces:
    - `config.Config` with `Load() (Config, error)`
    - `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`

- [x] **Step 1: Create the module**

```bash
cd c:/Work/Repos/vantage
mkdir -p admin/cmd admin/internal/config admin/internal/db
```

Create `admin/go.mod`:

```
module gitea.hostxtra.co.uk/mrhid6/vantage/admin

go 1.26.4

require (
	github.com/gin-gonic/gin v1.10.0
	github.com/google/uuid v1.6.0
	github.com/joho/godotenv v1.5.1
	gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
	github.com/redis/go-redis/v9 v9.20.1
	go.mongodb.org/mongo-driver/v2 v2.8.0
	golang.org/x/crypto v0.54.0
)

replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
```

Add the module to `go.work`:

```
go 1.26.4

use (
	./admin
	./server
	./shared
	./sitesvc
)
```

- [x] **Step 2: Write the config**

Create `admin/internal/config/config.go`:

```go
// Package config parses and validates admin's environment.
//
// Everything required is checked at boot and the process refuses to start
// without it. A licensing service that cannot sign is worse than one that is
// down, because it looks healthy.
package config

import (
	"fmt"
	"net/url"
	"os"
	"strings"
)

type Config struct {
	AdminMongoURI    string
	AdminDBName      string
	ControlMongoURI  string
	ControlDBName    string
	RedisAddr        string
	SigningKey       string
	PublicURL        string
	AllowedOrigins   []string
	TrustProxy       bool
	Addr             string

	SMTPHost     string
	SMTPPort     string
	SMTPFrom     string
	SMTPUsername string
	SMTPPassword string
}

// dbNameFromURI reads the database from a Mongo URI path.
//
// Both URIs must name their database inline rather than through a separate
// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous
// about which, and guessing wrong means writing licence fields into the wrong
// place.
func dbNameFromURI(raw, which string) (string, error) {
	u, err := url.Parse(raw)
	if err != nil {
		return "", fmt.Errorf("%s is not a valid URI: %w", which, err)
	}
	name := strings.TrimPrefix(u.Path, "/")
	if name == "" {
		return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which)
	}
	return name, nil
}

func Load() (Config, error) {
	c := Config{
		AdminMongoURI:   os.Getenv("ADMIN_MONGO_URI"),
		ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"),
		RedisAddr:       os.Getenv("REDIS_ADDR"),
		SigningKey:      os.Getenv("LICENSE_SIGNING_KEY"),
		PublicURL:       strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
		TrustProxy:      strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
		Addr:            ":" + envOr("PORT", "8083"),

		SMTPHost:     os.Getenv("SMTP_HOST"),
		SMTPPort:     envOr("SMTP_PORT", "587"),
		SMTPFrom:     os.Getenv("SMTP_FROM"),
		SMTPUsername: os.Getenv("SMTP_USERNAME"),
		SMTPPassword: os.Getenv("SMTP_PASSWORD"),
	}

	var missing []string
	for name, v := range map[string]string{
		"ADMIN_MONGO_URI":     c.AdminMongoURI,
		"CONTROL_MONGO_URI":   c.ControlMongoURI,
		"REDIS_ADDR":          c.RedisAddr,
		"LICENSE_SIGNING_KEY": c.SigningKey,
		"PUBLIC_URL":          c.PublicURL,
		"ADMIN_ORIGIN":        os.Getenv("ADMIN_ORIGIN"),
	} {
		if v == "" {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
	}

	var err error
	if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil {
		return Config{}, err
	}
	if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil {
		return Config{}, err
	}
	if c.AdminMongoURI == c.ControlMongoURI {
		return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database")
	}

	for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") {
		if o = strings.TrimSpace(o); o != "" {
			c.AllowedOrigins = append(c.AllowedOrigins, o)
		}
	}
	return c, nil
}

func envOr(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}
```

- [x] **Step 3: Write the database layer**

Create `admin/internal/db/db.go`:

```go
// Package db holds admin's two MongoDB connections.
//
// Admin() is its own database and it owns every collection there. Control() is
// the control plane's database, and admin's access to it is deliberately narrow:
// it reads `instances` and `users`, and writes exactly three licence fields on
// `instances`. Nothing here should ever grow a write path to another collection.
package db

import (
	"context"
	"fmt"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

var (
	adminDB   *mongo.Database
	controlDB *mongo.Database
)

func Connect(ctx context.Context, cfg config.Config) error {
	ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI))
	if err != nil {
		return fmt.Errorf("connect admin mongo: %w", err)
	}
	if err := ac.Ping(ctx, nil); err != nil {
		return fmt.Errorf("ping admin mongo: %w", err)
	}
	adminDB = ac.Database(cfg.AdminDBName)

	cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI))
	if err != nil {
		return fmt.Errorf("connect control mongo: %w", err)
	}
	if err := cc.Ping(ctx, nil); err != nil {
		return fmt.Errorf("ping control mongo: %w", err)
	}
	controlDB = cc.Database(cfg.ControlDBName)

	// The control plane must already be deployed and migrated. Without the
	// instances collection, injection would silently create it and write
	// licence fields into a collection nothing reads.
	names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"})
	if err != nil {
		return fmt.Errorf("inspect control database: %w", err)
	}
	if len(names) == 0 {
		return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName)
	}
	return nil
}

func Admin(name string) *mongo.Collection   { return adminDB.Collection(name) }
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }

func Ctx() (context.Context, context.CancelFunc) {
	return context.WithTimeout(context.Background(), 10*time.Second)
}
```

- [x] **Step 4: Write main.go**

Create `admin/cmd/main.go`:

```go
package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/joho/godotenv"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
)

func main() {
	godotenv.Load()

	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("configuration error: %v", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	if err := db.Connect(ctx, cfg); err != nil {
		cancel()
		log.Fatalf("database: %v", err)
	}
	cancel()
	log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)

	srv := &http.Server{
		Addr:              cfg.Addr,
		Handler:           http.NotFoundHandler(), // replaced in Task 8
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       20 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	go func() {
		log.Printf("admin listening on %s", cfg.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("server error: %v", err)
		}
	}()

	stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	<-stopCtx.Done()

	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer shutdownCancel()
	_ = srv.Shutdown(shutdownCtx)
	log.Println("admin stopped")
	os.Exit(0)
}
```

- [x] **Step 5: Write the Dockerfile**

Create `admin/Dockerfile` — context is the repo root, exactly like `server/`:

```dockerfile
# Context is the repository root; admin depends on the shared module.
FROM golang:1.26-alpine AS builder

WORKDIR /src

COPY shared/go.mod shared/go.sum ./shared/
COPY admin/go.mod admin/go.sum ./admin/
RUN cd admin && go mod download

COPY shared/ ./shared/
COPY admin/ ./admin/

RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl

FROM alpine:3.20 AS runner

RUN apk add --no-cache ca-certificates && \
    addgroup --system --gid 1001 admin && \
    adduser --system --uid 1001 --ingroup admin admin

COPY --from=builder /out/admin /usr/local/bin/admin
COPY --from=builder /out/adminctl /usr/local/bin/adminctl

USER admin

EXPOSE 8083
ENV PORT=8083

CMD ["/usr/local/bin/admin"]
```

Create `admin/.dockerignore`:

```
.env
*.lic
```

- [x] **Step 6: Resolve dependencies outside the workspace**

```bash
cd c:/Work/Repos/vantage
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
  -v vantage-gocache:/root/.cache/go-build -w /src/admin -e GOWORK=off golang:1.26 go mod tidy
```

`GOWORK=off` is required. In workspace mode `tidy` drops the `require` lines the Docker build needs, and the failure only appears at image build time.

- [x] **Step 7: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.)

- [x] **Step 8: Commit**

```bash
git add admin/ go.work
git commit -m "feat(admin): module skeleton, config and two database connections"
```

---

### Task 2: Models, indexes and the plan seed

**Files:**

- Create: `admin/internal/models/models.go`, `admin/internal/models/plans.go`
- Modify: `admin/internal/db/db.go` (add `EnsureIndexes`), `admin/cmd/main.go` (call it)

**Interfaces:**

- Consumes: `shared/license`
- Produces:
    - `models.Account`, `models.Instance`, `models.License`, `models.Subscription`, `models.Plan`, `models.StaffUser`, `models.CustomerUser`, `models.AuditEntry`
    - status and reason constants
    - `models.SeedPlans(ctx) error`
    - `db.EnsureIndexes(ctx) error`

- [x] **Step 1: Write the documents**

Create `admin/internal/models/models.go`:

```go
// Package models holds admin's own documents.
//
// These are admin-owned and never shared with the control plane. The two
// structs that ARE shared — Instance and User on the control-plane side — come
// from shared/models, so there is no second copy of those shapes to drift.
package models

import (
	"time"

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

// Instance statuses.
const (
	StatusAwaitingLink = "awaiting_link"
	StatusActive       = "active"
	StatusLapsed       = "lapsed"
	StatusCancelled    = "cancelled"
)

// Account statuses.
const (
	AccountActive    = "active"
	AccountSuspended = "suspended"
)

// Licence issuance reasons. These end up in support conversations, so they are
// stable identifiers rather than prose.
const (
	ReasonNew        = "new"
	ReasonRenewal    = "renewal"
	ReasonTierChange = "tier_change"
	ReasonRelink     = "relink"
	ReasonManual     = "manual"
)

// MaxRelinksPerTerm is the customer-facing relink cap.
//
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
// revoked, so a determined customer is not stopped by a counter. Its real job is
// to put a human in front of the fourth attempt.
const MaxRelinksPerTerm = 3

// GracePeriod is added to every licence expiry beyond the billing period end,
// so a renewal webhook arriving slightly late does not create a gap in which a
// paying customer's instance goes read-only.
const GracePeriod = 3 * 24 * time.Hour

type Account struct {
	ID               bson.ObjectID `bson:"_id,omitempty" json:"-"`
	AccountID        string        `bson:"account_id" json:"account_id"`
	Name             string        `bson:"name" json:"name"`
	BillingEmail     string        `bson:"billing_email" json:"billing_email"`
	PaddleCustomerID string        `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"`
	Status           string        `bson:"status" json:"status"`
	CreatedAt        time.Time     `bson:"created_at" json:"created_at"`
}

// Instance is admin's record of one deployment.
//
// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it
// is the UUID the customer pasted — their database is theirs, and we cannot see
// it, so this row is the only thing that exists on our side.
type Instance struct {
	ID             bson.ObjectID `bson:"_id,omitempty" json:"-"`
	InstanceID     string        `bson:"instance_id" json:"instance_id"`
	AccountID      string        `bson:"account_id" json:"account_id"`
	Name           string        `bson:"name" json:"name"`
	Slug           string        `bson:"slug,omitempty" json:"slug,omitempty"`
	Deployment     string        `bson:"deployment" json:"deployment"`
	Tier           string        `bson:"tier,omitempty" json:"tier,omitempty"`
	Status         string        `bson:"status" json:"status"`
	CurrentLicense string        `bson:"current_license,omitempty" json:"current_license,omitempty"`
	RelinkCount    int           `bson:"relink_count" json:"relink_count"`
	InjectFailedAt *time.Time    `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
	CreatedAt      time.Time     `bson:"created_at" json:"created_at"`
}

// License is append-only. A renewal writes a new row and sets SupersededBy on
// the old one. Nothing here is ever edited or deleted: when a support question
// arrives about why an instance stopped working on a given date, the answer has
// to still be in the table.
type License struct {
	ID           bson.ObjectID  `bson:"_id,omitempty" json:"-"`
	LicenseID    string         `bson:"license_id" json:"license_id"`
	InstanceID   string         `bson:"instance_id" json:"instance_id"`
	AccountID    string         `bson:"account_id" json:"account_id"`
	Tier         string         `bson:"tier" json:"tier"`
	Deployment   string         `bson:"deployment" json:"deployment"`
	Limits       license.Limits `bson:"limits" json:"limits"`
	Features     []string       `bson:"features" json:"features"`
	IssuedAt     time.Time      `bson:"issued_at" json:"issued_at"`
	ExpiresAt    time.Time      `bson:"expires_at" json:"expires_at"`
	Blob         string         `bson:"blob" json:"-"`
	SupersededBy string         `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"`
	IssuedBy     string         `bson:"issued_by" json:"issued_by"`
	Reason       string         `bson:"reason" json:"reason"`
}

type Subscription struct {
	ID                   bson.ObjectID `bson:"_id,omitempty" json:"-"`
	SubscriptionID       string        `bson:"subscription_id" json:"subscription_id"`
	AccountID            string        `bson:"account_id" json:"account_id"`
	InstanceID           string        `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
	PaddleSubscriptionID string        `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
	PaddlePriceID        string        `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
	Tier                 string        `bson:"tier" json:"tier"`
	Term                 string        `bson:"term" json:"term"`
	Status               string        `bson:"status" json:"status"`
	CurrentPeriodEnd     time.Time     `bson:"current_period_end" json:"current_period_end"`
}

// Plan is the authoritative tier definition, seeded from shared/license.
//
// It lives in the database so tier contents change without a deploy. Every
// issued licence snapshots it, so editing a plan never rewrites an existing
// licence — the same rule as workflow_runs.steps_snapshot.
type Plan struct {
	ID              bson.ObjectID     `bson:"_id,omitempty" json:"-"`
	Tier            string            `bson:"tier" json:"tier"`
	Name            string            `bson:"name" json:"name"`
	Deployment      string            `bson:"deployment" json:"deployment"`
	Limits          license.Limits    `bson:"limits" json:"limits"`
	Features        []string          `bson:"features" json:"features"`
	PaddleProductID string            `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
	PaddlePriceIDs  map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
	Active          bool              `bson:"active" json:"active"`
}

type StaffUser struct {
	ID           bson.ObjectID `bson:"_id,omitempty" json:"-"`
	UserID       string        `bson:"user_id" json:"user_id"`
	Email        string        `bson:"email" json:"email"`
	PasswordHash string        `bson:"password_hash" json:"-"`
	Name         string        `bson:"name" json:"name"`
	CreatedAt    time.Time     `bson:"created_at" json:"created_at"`
}

// CustomerUser is a self-hosted customer's login. Cloud customers do not have
// one — they authenticate against the control plane with credentials they
// already hold.
type CustomerUser struct {
	ID                bson.ObjectID `bson:"_id,omitempty" json:"-"`
	UserID            string        `bson:"user_id" json:"user_id"`
	AccountID         string        `bson:"account_id" json:"account_id"`
	Email             string        `bson:"email" json:"email"`
	PasswordHash      string        `bson:"password_hash" json:"-"`
	VerifiedAt        *time.Time    `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
	VerifyTokenHash   string        `bson:"verify_token_hash,omitempty" json:"-"`
	VerifyTokenExpiry *time.Time    `bson:"verify_token_expiry,omitempty" json:"-"`
	CreatedAt         time.Time     `bson:"created_at" json:"created_at"`
}

type AuditEntry struct {
	ID        bson.ObjectID `bson:"_id,omitempty" json:"-"`
	Actor     string        `bson:"actor" json:"actor"`
	Action    string        `bson:"action" json:"action"`
	AccountID string        `bson:"account_id,omitempty" json:"account_id,omitempty"`
	Target    string        `bson:"target,omitempty" json:"target,omitempty"`
	Detail    string        `bson:"detail,omitempty" json:"detail,omitempty"`
	IP        string        `bson:"ip,omitempty" json:"ip,omitempty"`
	CreatedAt time.Time     `bson:"created_at" json:"created_at"`
}
```

- [x] **Step 2: Write the plan seed**

Create `admin/internal/models/plans.go`:

```go
package models

import (
	"context"
	"time"

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

// SeedPlans inserts the tier table from shared/license on first boot.
//
// It uses $setOnInsert only: once a plan exists, staff edits to limits, features
// and Paddle IDs are authoritative and a redeploy must not stamp over them.
func SeedPlans(ctx context.Context) error {
	for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} {
		p, ok := license.PlanFor(tier)
		if !ok {
			continue
		}
		_, err := db.Admin("plans").UpdateOne(ctx,
			bson.M{"tier": tier},
			bson.M{"$setOnInsert": bson.M{
				"tier":       p.Tier,
				"name":       p.Name,
				"deployment": p.Deployment,
				"limits":     p.Limits,
				"features":   p.Features,
				"active":     true,
			}},
			options.UpdateOne().SetUpsert(true))
		if err != nil {
			return err
		}
	}
	return nil
}

// GetPlan reads a tier's authoritative definition.
func GetPlan(ctx context.Context, tier string) (*Plan, error) {
	var p Plan
	if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil {
		return nil, err
	}
	return &p, nil
}

func now() time.Time { return time.Now().UTC() }
```

- [x] **Step 3: Add the indexes**

Append to `admin/internal/db/db.go`:

```go
// EnsureIndexes creates admin's unique indexes.
//
// These are a correctness property, not an optimisation. In particular
// admin_instances.instance_id unique is what stops the same self-hosted UUID
// being linked to two accounts — without it, two customers could both claim one
// instance and both be issued licences for it.
func EnsureIndexes(ctx context.Context) error {
	unique := []struct {
		coll  string
		field string
	}{
		{"accounts", "account_id"},
		{"admin_instances", "instance_id"},
		{"licenses", "license_id"},
		{"plans", "tier"},
		{"staff_users", "email"},
		{"customer_users", "email"},
	}
	for _, u := range unique {
		if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
			Keys:    bson.D{{Key: u.field, Value: 1}},
			Options: options.Index().SetUnique(true).SetName(u.field + "_unique"),
		}); err != nil {
			return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err)
		}
	}

	// Sparse: a subscription exists before Paddle assigns an ID, so empty must
	// not collide with empty.
	if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys:    bson.D{{Key: "paddle_subscription_id", Value: 1}},
		Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"),
	}); err != nil {
		return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err)
	}

	for _, idx := range []struct {
		coll string
		keys bson.D
	}{
		{"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}},
		{"admin_instances", bson.D{{Key: "account_id", Value: 1}}},
		{"admin_audit", bson.D{{Key: "created_at", Value: -1}}},
	} {
		if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil {
			return fmt.Errorf("index %s: %w", idx.coll, err)
		}
	}
	return nil
}
```

Add to that file's imports: `"go.mongodb.org/mongo-driver/v2/bson"` and `"go.mongodb.org/mongo-driver/v2/mongo/options"`.

- [x] **Step 4: Call both at boot**

In `admin/cmd/main.go`, after the connect block:

```go
	idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second)
	if err := db.EnsureIndexes(idxCtx); err != nil {
		idxCancel()
		log.Fatalf("indexes: %v", err)
	}
	if err := models.SeedPlans(idxCtx); err != nil {
		idxCancel()
		log.Fatalf("plan seed: %v", err)
	}
	idxCancel()
```

Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"`.

- [x] **Step 5: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [x] **Step 6: Commit**

```bash
git add admin/
git commit -m "feat(admin): documents, indexes and the plan seed"
```

---

### Task 3: Issuance

The core of the service. Everything else exists to call this correctly.

**Files:**

- Create: `admin/internal/licensing/issue.go`, `admin/internal/audit/audit.go`

**Interfaces:**

- Consumes: `models`, `db`, `license.Sign`
- Produces:
    - `licensing.Issue(ctx, in IssueInput) (*models.License, error)`
    - `type IssueInput struct { InstanceID, Tier, Term, Reason, IssuedBy string; ExpiresAt time.Time }`
    - `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
    - `audit.Write(ctx, e models.AuditEntry)`

- [x] **Step 1: Write the audit helper**

Create `admin/internal/audit/audit.go`:

```go
// Package audit records who did what. Every issuance, link, relink and sign-in
// attempt lands here.
package audit

import (
	"context"
	"log"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
)

// Write never returns an error: an audit failure must not roll back the action
// it describes. It logs instead, loudly enough to notice.
func Write(ctx context.Context, e models.AuditEntry) {
	e.CreatedAt = time.Now().UTC()
	if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil {
		log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err)
	}
}
```

- [x] **Step 2: Write issuance**

Create `admin/internal/licensing/issue.go`:

```go
// Package licensing issues licences. It is the only place that signs.
package licensing

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"go.mongodb.org/mongo-driver/v2/bson"
)

var (
	ErrUnknownTier        = errors.New("unknown tier")
	ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
	ErrFreeLimit          = errors.New("this account already has a Free instance")
	ErrUnknownInstance    = errors.New("instance not found")
)

type IssueInput struct {
	InstanceID string
	Tier       string
	Term       string    // "monthly" or "annual"; ignored when ExpiresAt is set
	ExpiresAt  time.Time // explicit expiry, used by relink to preserve the remaining term
	Reason     string
	IssuedBy   string // staff email, "system", or "paddle:<event id>"
}

// signingKey is set once at boot from LICENSE_SIGNING_KEY.
var signingKey string

func SetSigningKey(k string) { signingKey = k }

// Issue signs a licence, records it, supersedes its predecessor and updates the
// instance.
//
// It does NOT deliver. Recording and delivery are deliberately separate and
// ordered: a licence recorded but not delivered is recoverable, because the
// customer can download it. A licence delivered but not recorded is a support
// mystery with no paper trail. Callers deliver after this returns.
func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
	if signingKey == "" {
		return nil, errors.New("no signing key configured")
	}

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil {
		return nil, ErrUnknownInstance
	}

	plan, err := models.GetPlan(ctx, in.Tier)
	if err != nil {
		return nil, ErrUnknownTier
	}

	// This single comparison is what makes Free cloud-only. Free's plan is
	// deployment "cloud", so it can never be issued against a self-hosted
	// instance, and verification on the instance would reject it anyway.
	if plan.Deployment != inst.Deployment {
		return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment)
	}

	if plan.Tier == license.TierFree {
		if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil {
			return nil, err
		}
	}

	now := time.Now().UTC()
	expires := in.ExpiresAt
	if expires.IsZero() {
		switch in.Term {
		case "monthly":
			expires = now.AddDate(0, 1, 0).Add(models.GracePeriod)
		case "annual", "":
			expires = now.AddDate(1, 0, 0).Add(models.GracePeriod)
		default:
			return nil, fmt.Errorf("unknown term %q", in.Term)
		}
	}

	payload := license.License{
		ID:           uuid.NewString(),
		InstanceID:   inst.InstanceID,
		AccountID:    inst.AccountID,
		InstanceName: inst.Name,
		Tier:         plan.Tier,
		Deployment:   plan.Deployment,
		IssuedAt:     now,
		ExpiresAt:    expires,
		// Snapshotted, not referenced: editing a plan tomorrow must not change
		// what this licence grants.
		Limits:   plan.Limits,
		Features: plan.Features,
	}

	blob, err := license.Sign(payload, signingKey)
	if err != nil {
		return nil, fmt.Errorf("sign: %w", err)
	}

	rec := models.License{
		LicenseID:  payload.ID,
		InstanceID: inst.InstanceID,
		AccountID:  inst.AccountID,
		Tier:       plan.Tier,
		Deployment: plan.Deployment,
		Limits:     plan.Limits,
		Features:   plan.Features,
		IssuedAt:   now,
		ExpiresAt:  expires,
		Blob:       blob,
		IssuedBy:   in.IssuedBy,
		Reason:     in.Reason,
	}
	if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil {
		return nil, fmt.Errorf("record licence: %w", err)
	}

	// Supersede rather than delete. The history is the support tool.
	if inst.CurrentLicense != "" {
		if _, err := db.Admin("licenses").UpdateOne(ctx,
			bson.M{"license_id": inst.CurrentLicense},
			bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil {
			return nil, fmt.Errorf("supersede previous licence: %w", err)
		}
	}

	set := bson.M{
		"current_license": rec.LicenseID,
		"tier":            plan.Tier,
		"status":          models.StatusActive,
	}
	if in.Reason == models.ReasonRenewal {
		set["relink_count"] = 0 // the cap is per term
	}
	if _, err := db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
		return nil, fmt.Errorf("update instance: %w", err)
	}

	audit.Write(ctx, models.AuditEntry{
		Actor:     in.IssuedBy,
		Action:    "license.issued",
		AccountID: inst.AccountID,
		Target:    inst.InstanceID,
		Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s",
			plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID),
	})

	return &rec, nil
}

// checkFreeLimit enforces one Free instance per account.
//
// Cancelled instances do not count: a customer who cancelled their Free instance
// is allowed another one.
func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error {
	n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
		"account_id":  accountID,
		"tier":        license.TierFree,
		"status":      bson.M{"$ne": models.StatusCancelled},
		"instance_id": bson.M{"$ne": exceptInstanceID},
	})
	if err != nil {
		return err
	}
	if n > 0 {
		return ErrFreeLimit
	}
	return nil
}
```

- [x] **Step 3: Wire the signing key at boot**

In `admin/cmd/main.go`, after config loads:

```go
	licensing.SetSigningKey(cfg.SigningKey)
```

Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"`.

- [x] **Step 4: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [x] **Step 5: Commit**

```bash
git add admin/
git commit -m "feat(admin): licence issuance with plan snapshots and supersession"
```

---

### Task 4: Injection and reconciliation

**Files:**

- Create: `admin/internal/inject/inject.go`
- Modify: `admin/cmd/main.go`

**Interfaces:**

- Consumes: `db.Control`, `models`
- Produces:
    - `inject.Cloud(ctx, lic *models.License) error`
    - `inject.Reconcile(ctx) (checked, repaired int, err error)`
    - `inject.StartReconciler(ctx)`

- [x] **Step 1: Write it**

Create `admin/internal/inject/inject.go`:

```go
// Package inject writes licences onto control-plane instance documents.
//
// This is admin's ONLY write path into the control plane, and it touches exactly
// three fields on one collection. If this package ever grows a second write
// target, that is a design change and not a refactor.
package inject

import (
	"context"
	"fmt"
	"log"
	"time"

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

// ReconcileInterval is how often every cloud instance is compared against what
// admin believes it should hold.
//
// This job, not the issuance path, is what guarantees eventual consistency.
// Injection at issue time is best-effort; this is the backstop.
const ReconcileInterval = 15 * time.Minute

// Cloud writes the licence onto the control-plane instance document.
//
// Idempotent and safe to re-run: it is a single UpdateOne of three fields with
// no read-modify-write. Retries three times with backoff.
//
// The control plane caches licence state for 60 seconds, so this takes effect
// within a minute with no restart.
func Cloud(ctx context.Context, lic *models.License) error {
	set := bson.M{"$set": bson.M{
		"license_blob":   lic.Blob,
		"license_tier":   lic.Tier,
		"license_expiry": lic.ExpiresAt,
	}}

	var lastErr error
	for attempt := 1; attempt <= 3; attempt++ {
		res, err := db.Control("instances").UpdateOne(ctx,
			bson.M{"instance_id": lic.InstanceID}, set)
		if err == nil {
			if res.MatchedCount == 0 {
				return fmt.Errorf("no control-plane instance %s", lic.InstanceID)
			}
			return nil
		}
		lastErr = err
		time.Sleep(time.Duration(attempt) * 2 * time.Second)
	}
	return fmt.Errorf("inject after 3 attempts: %w", lastErr)
}

// Deliver injects and records the outcome without ever failing the caller.
//
// A licence that is recorded but not injected is recoverable — the reconciler
// will fix it within 15 minutes, and staff can see it on the health endpoint.
// Failing the purchase because one write failed would be worse.
func Deliver(ctx context.Context, lic *models.License) {
	if err := Cloud(ctx, lic); err != nil {
		log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err)
		now := time.Now().UTC()
		_, _ = db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": lic.InstanceID},
			bson.M{"$set": bson.M{"inject_failed_at": now}})
		return
	}
	_, _ = db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": lic.InstanceID},
		bson.M{"$unset": bson.M{"inject_failed_at": ""}})
}

// Reconcile compares every active cloud instance's current licence against the
// blob actually stored in the control plane, and re-injects on mismatch.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment":      license.DeploymentCloud,
		"status":          models.StatusActive,
		"current_license": bson.M{"$ne": ""},
	})
	if err != nil {
		return 0, 0, err
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return 0, 0, err
	}

	for _, inst := range instances {
		checked++

		var lic models.License
		if err := db.Admin("licenses").FindOne(ctx,
			bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
			log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense)
			continue
		}

		var remote sharedmodels.Instance
		if err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
			log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
			continue
		}

		if remote.LicenseBlob == lic.Blob {
			continue
		}

		log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID)
		if err := Cloud(ctx, &lic); err != nil {
			log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err)
			continue
		}
		repaired++
		_, _ = db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": inst.InstanceID},
			bson.M{"$unset": bson.M{"inject_failed_at": ""}})
	}
	return checked, repaired, nil
}

// StartReconciler runs Reconcile on a ticker until ctx is cancelled.
func StartReconciler(ctx context.Context) {
	go func() {
		t := time.NewTicker(ReconcileInterval)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
				checked, repaired, err := Reconcile(runCtx)
				cancel()
				if err != nil {
					log.Printf("reconcile: %v", err)
					continue
				}
				if repaired > 0 {
					log.Printf("reconcile: checked %d, repaired %d", checked, repaired)
				}
			}
		}
	}()
}
```

- [x] **Step 2: Start it at boot**

In `admin/cmd/main.go`, before the HTTP server starts:

```go
	reconcileCtx, stopReconcile := context.WithCancel(context.Background())
	defer stopReconcile()
	inject.StartReconciler(reconcileCtx)
```

Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"`.

- [x] **Step 3: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [x] **Step 4: Confirm the write surface by inspection**

```bash
grep -n "Control(" admin/internal/ -r
```

Expected: reads of `instances` and `users`, and writes only to `instances` with the three licence fields. Any other write target is a bug — fix it before continuing.

- [x] **Step 5: Commit**

```bash
git add admin/
git commit -m "feat(admin): cloud injection and the 15-minute reconciler"
```

---

### Task 5: Sessions and staff auth

**Files:**

- Create: `admin/internal/auth/session.go`, `admin/internal/auth/staff.go`, `admin/internal/auth/middleware.go`, `admin/cmd/adminctl/main.go`

**Interfaces:**

- Consumes: `db`, Redis
- Produces:
    - `auth.Session{UserID, Kind, Email, AccountID}` where `Kind` is `"staff"` or `"customer"`
    - `auth.InitRedis(addr)`, `auth.Save`, `auth.Get`, `auth.Destroy`, `auth.SetCookie`
    - `auth.RequireStaff()`, `auth.RequireCustomer()`, `auth.Current(c) *Session`
    - `auth.HandleStaffLogin`, `auth.HandleLogout`
    - `adminctl staff-add`

- [x] **Step 1: Write sessions**

Create `admin/internal/auth/session.go`:

```go
// Package auth holds admin's three identities: staff, cloud customers and
// self-hosted customers. All three share one session store and one cookie.
package auth

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/redis/go-redis/v9"
)

const (
	CookieName  = "admin_session"
	SessionTTL  = 24 * time.Hour
	KindStaff   = "staff"
	KindCustomer = "customer"
)

type Session struct {
	UserID    string `json:"user_id"`
	Kind      string `json:"kind"`
	Email     string `json:"email"`
	AccountID string `json:"account_id,omitempty"` // customers only
}

var rdb *redis.Client

func InitRedis(addr string) { rdb = redis.NewClient(&redis.Options{Addr: addr}) }

func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() }

func newID() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

func Save(ctx context.Context, s Session) (string, error) {
	id, err := newID()
	if err != nil {
		return "", err
	}
	body, err := json.Marshal(s)
	if err != nil {
		return "", err
	}
	if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil {
		return "", err
	}
	return id, nil
}

func Get(ctx context.Context, id string) (*Session, error) {
	body, err := rdb.Get(ctx, "admin_session:"+id).Bytes()
	if err != nil {
		return nil, err
	}
	var s Session
	if err := json.Unmarshal(body, &s); err != nil {
		return nil, err
	}
	return &s, nil
}

func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) }

func SetCookie(c *gin.Context, id string) {
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     CookieName,
		Value:    id,
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   int(SessionTTL.Seconds()),
	})
}

func ClearCookie(c *gin.Context) {
	http.SetCookie(c.Writer, &http.Cookie{
		Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1,
	})
}
```

- [x] **Step 2: Write the middleware**

Create `admin/internal/auth/middleware.go`:

```go
package auth

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

const ctxSession = "admin_session_obj"

func load(c *gin.Context) *Session {
	id, err := c.Cookie(CookieName)
	if err != nil || id == "" {
		return nil
	}
	s, err := Get(c.Request.Context(), id)
	if err != nil {
		return nil
	}
	return s
}

// Current returns the session, or nil.
func Current(c *gin.Context) *Session {
	if v, ok := c.Get(ctxSession); ok {
		if s, ok := v.(*Session); ok {
			return s
		}
	}
	return nil
}

func RequireStaff() gin.HandlerFunc {
	return func(c *gin.Context) {
		s := load(c)
		if s == nil || s.Kind != KindStaff {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
			return
		}
		c.Set(ctxSession, s)
		c.Next()
	}
}

// RequireCustomer admits both cloud and self-hosted customers. Every handler
// behind it scopes by AccountID via the helper in api/customer.go — never by
// remembering to filter.
func RequireCustomer() gin.HandlerFunc {
	return func(c *gin.Context) {
		s := load(c)
		if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
			return
		}
		c.Set(ctxSession, s)
		c.Next()
	}
}
```

- [x] **Step 3: Write staff login**

Create `admin/internal/auth/staff.go`:

```go
package auth

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"golang.org/x/crypto/bcrypt"
)

// genericAuthError is returned for every failure mode — unknown email, wrong
// password, wrong role. Distinguishing them would confirm which addresses have
// accounts.
const genericAuthError = "email or password is incorrect"

func HandleStaffLogin(c *gin.Context) {
	var body struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
		return
	}
	email := strings.ToLower(strings.TrimSpace(body.Email))

	if !allowAttempt(email, c.ClientIP()) {
		c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
		return
	}

	var u models.StaffUser
	err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u)
	if err != nil {
		// Spend the same work as a real comparison so timing does not
		// distinguish "no such user" from "wrong password".
		bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
		audit.Write(c.Request.Context(), models.AuditEntry{
			Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"})
		c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
		return
	}

	if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
		audit.Write(c.Request.Context(), models.AuditEntry{
			Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"})
		c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
		return
	}

	id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
		return
	}
	SetCookie(c, id)
	clearAttempts(email)
	audit.Write(c.Request.Context(), models.AuditEntry{
		Actor: email, Action: "staff.login", IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name})
}

// dummyHash is a valid bcrypt hash of a random value, compared against when no
// user exists so the timing profile matches.
const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u"

func HandleLogout(c *gin.Context) {
	if id, err := c.Cookie(CookieName); err == nil && id != "" {
		Destroy(c.Request.Context(), id)
	}
	ClearCookie(c)
	c.JSON(http.StatusOK, gin.H{"ok": true})
}
```

- [x] **Step 4: Write the rate limiter**

Create `admin/internal/auth/ratelimit.go`:

```go
package auth

import (
	"sync"
	"time"
)

// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
// hour. The email limit stops a targeted attack on one account; the IP limit
// stops a spray across many.
const (
	emailLimit  = 5
	emailWindow = 15 * time.Minute
	ipLimit     = 20
	ipWindow    = time.Hour
)

var (
	attemptMu sync.Mutex
	byEmail   = map[string][]time.Time{}
	byIP      = map[string][]time.Time{}
)

func prune(in []time.Time, cutoff time.Time) []time.Time {
	out := in[:0]
	for _, t := range in {
		if t.After(cutoff) {
			out = append(out, t)
		}
	}
	return out
}

func allowAttempt(email, ip string) bool {
	now := time.Now()

	attemptMu.Lock()
	defer attemptMu.Unlock()

	byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
	byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))

	if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
		return false
	}
	byEmail[email] = append(byEmail[email], now)
	byIP[ip] = append(byIP[ip], now)
	return true
}

func clearAttempts(email string) {
	attemptMu.Lock()
	delete(byEmail, email)
	attemptMu.Unlock()
}
```

- [x] **Step 5: Write adminctl staff-add**

Create `admin/cmd/adminctl/main.go`:

```go
// Command adminctl performs the operations that deliberately have no HTTP
// surface.
//
//	adminctl staff-add --email=you@example.com --name="You" --password=...
//
// There is no staff signup endpoint. A licensing authority that can be joined
// over the internet is not one.
package main

import (
	"context"
	"flag"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"golang.org/x/crypto/bcrypt"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
		os.Exit(2)
	}

	cfg, err := config.Load()
	if err != nil {
		fatal("configuration: %v", err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err := db.Connect(ctx, cfg); err != nil {
		fatal("database: %v", err)
	}

	switch os.Args[1] {
	case "staff-add":
		staffAdd(ctx, os.Args[2:])
	default:
		fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
		os.Exit(2)
	}
}

func staffAdd(ctx context.Context, args []string) {
	fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
	email := fs.String("email", "", "staff email (required)")
	name := fs.String("name", "", "display name")
	password := fs.String("password", "", "password, at least 12 characters (required)")
	fs.Parse(args)

	if *email == "" || len(*password) < 12 {
		fatal("--email and a --password of at least 12 characters are required")
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
	if err != nil {
		fatal("hash: %v", err)
	}

	u := models.StaffUser{
		UserID:       uuid.NewString(),
		Email:        strings.ToLower(strings.TrimSpace(*email)),
		PasswordHash: string(hash),
		Name:         *name,
		CreatedAt:    time.Now().UTC(),
	}
	if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
		fatal("create staff user: %v", err)
	}
	fmt.Printf("created staff user %s\n", u.Email)
}

func fatal(format string, a ...any) {
	fmt.Fprintf(os.Stderr, format+"\n", a...)
	os.Exit(1)
}
```

- [x] **Step 6: Initialise Redis at boot**

In `admin/cmd/main.go`, after config loads:

```go
	auth.InitRedis(cfg.RedisAddr)
	pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
	if err := auth.Ping(pingCtx); err != nil {
		pingCancel()
		log.Fatalf("redis: %v", err)
	}
	pingCancel()
```

- [x] **Step 7: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [x] **Step 8: Commit**

```bash
git add admin/
git commit -m "feat(admin): sessions, staff auth and adminctl"
```

---

### Task 6: Cloud customer auth

**Files:**

- Create: `admin/internal/auth/cloud.go`

**Interfaces:**

- Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models`
- Produces: `auth.HandleCloudLogin`

- [x] **Step 1: Write it**

Create `admin/internal/auth/cloud.go`:

```go
package auth

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	adminmodels "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"golang.org/x/crypto/bcrypt"
)

// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's
// users collection, with the credentials they already have.
//
// Two consequences worth stating plainly, because they are real and were
// accepted deliberately:
//
//  1. A cloud user's control-plane password now also unlocks billing. Any
//     password change or compromise has a wider blast radius than before.
//  2. Only control-plane role "owner" may sign in here. admin and member are
//     refused — billing is an owner concern.
//
// Mitigations: rate limits, an identical error for every failure, and an audit
// entry for every attempt.
func HandleCloudLogin(c *gin.Context) {
	var body struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
		return
	}
	email := strings.ToLower(strings.TrimSpace(body.Email))
	ctx := c.Request.Context()

	if !allowAttempt(email, c.ClientIP()) {
		c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
		return
	}

	reject := func(reason string) {
		audit.Write(ctx, adminmodels.AuditEntry{
			Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason})
		c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
	}

	// A self-hosted customer_users row wins over a control-plane user with the
	// same address. Documented so the behaviour is chosen rather than emergent.
	if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
		HandleCustomerLogin(c)
		return
	}

	var u sharedmodels.User
	if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
		bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
		reject("unknown email")
		return
	}
	if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
		reject("bad password")
		return
	}
	if u.Role != sharedmodels.RoleOwner {
		reject("role " + u.Role + " is not permitted")
		return
	}

	// Resolve the admin-side account that owns this user's instance.
	var inst adminmodels.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil {
		reject("no account for instance " + u.InstanceID)
		return
	}

	id, err := Save(ctx, Session{
		UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID,
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
		return
	}
	SetCookie(c, id)
	clearAttempts(email)
	audit.Write(ctx, adminmodels.AuditEntry{
		Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
}
```

Check the field names on `sharedmodels.User` before building — use `UserID`, `Email`, `PasswordHash`, `Role`, `InstanceID` as defined in `shared/models/user.go`, and correct this file if they differ.

- [x] **Step 2: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output. (`HandleCustomerLogin` arrives in Task 7 — if you are building tasks in order, stub it as a 501 handler in `customer.go` and replace it there.)

- [x] **Step 3: Commit**

```bash
git add admin/
git commit -m "feat(admin): cloud owner login against the control plane"
```

---

### Task 7: Self-hosted customer auth

**Files:**

- Create: `admin/internal/auth/customer.go`, `admin/internal/mail/mail.go`

**Interfaces:**

- Consumes: `db.Admin("customer_users")`, config SMTP
- Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense`

- [x] **Step 1: Write mail**

Create `admin/internal/mail/mail.go`:

```go
// Package mail delivers verification links and licence files.
package mail

import (
	"fmt"
	"net/smtp"
	"strings"
)

type Config struct {
	Host, Port, From, Username, Password string
	PublicURL                            string
}

var cfg Config

func Init(c Config) { cfg = c }

func Enabled() bool { return cfg.Host != "" && cfg.From != "" }

func send(to, subject, body string) error {
	if !Enabled() {
		return fmt.Errorf("SMTP is not configured")
	}
	msg := strings.Join([]string{
		"From: " + cfg.From,
		"To: " + to,
		"Subject: " + subject,
		"MIME-Version: 1.0",
		"Content-Type: text/plain; charset=utf-8",
		"", body,
	}, "\r\n")

	var auth smtp.Auth
	if cfg.Username != "" {
		auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
	}
	return smtp.SendMail(cfg.Host+":"+cfg.Port, auth, cfg.From, []string{to}, []byte(msg))
}

func SendVerification(to, token string) error {
	link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
	return send(to, "Verify your Vantage account",
		"Confirm your email address to finish setting up your Vantage account:\n\n"+
			link+"\n\nThis link expires in 24 hours.\n")
}

// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {
	return send(to, "Your Vantage licence key",
		fmt.Sprintf("Your licence for %s is below.\n\n"+
			"Paste it into Settings \u2192 Licence on your Vantage install:\n\n%s\n",
			instanceName, blob))
}
```

- [x] **Step 2: Write customer auth**

Create `admin/internal/auth/customer.go`:

```go
package auth

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"golang.org/x/crypto/bcrypt"
)

// BcryptCost matches the control plane and sitesvc. Changing it here alone would
// make hashes inconsistent across services that may one day compare them.
const BcryptCost = 12

// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour

// CreateCustomerUser creates an unverified self-hosted customer login and emails
// the verification link. Called during purchase (spec 5) and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
	if err != nil {
		return err
	}

	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return err
	}
	token := hex.EncodeToString(raw)
	sum := sha256.Sum256([]byte(token))
	expiry := time.Now().UTC().Add(VerifyWindow)

	u := models.CustomerUser{
		UserID:            uuid.NewString(),
		AccountID:         accountID,
		Email:             strings.ToLower(strings.TrimSpace(email)),
		PasswordHash:      string(hash),
		VerifyTokenHash:   hex.EncodeToString(sum[:]),
		VerifyTokenExpiry: &expiry,
		CreatedAt:         time.Now().UTC(),
	}
	if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
		return err
	}
	return mail.SendVerification(u.Email, token)
}

// HandleVerify consumes a verification token.
func HandleVerify(c *gin.Context) {
	token := c.Query("token")
	if token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
		return
	}
	sum := sha256.Sum256([]byte(token))
	now := time.Now().UTC()

	res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
		bson.M{
			"verify_token_hash":   hex.EncodeToString(sum[:]),
			"verify_token_expiry": bson.M{"$gt": now},
		},
		bson.M{
			"$set":   bson.M{"verified_at": now},
			"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
		})
	if err != nil || res.MatchedCount == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"verified": true})
}

// HandleCustomerLogin authenticates a self-hosted customer.
func HandleCustomerLogin(c *gin.Context) {
	var body struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
		return
	}
	email := strings.ToLower(strings.TrimSpace(body.Email))
	ctx := c.Request.Context()

	if !allowAttempt(email, c.ClientIP()) {
		c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
		return
	}

	reject := func(reason string) {
		audit.Write(ctx, models.AuditEntry{
			Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
		c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
	}

	var u models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
		bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
		reject("unknown email")
		return
	}
	if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
		reject("bad password")
		return
	}
	if u.VerifiedAt == nil {
		// Distinct from genericAuthError on purpose: the address is already
		// known to be theirs, so there is nothing to disclose, and "check your
		// email" is the only useful thing to say.
		c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
		return
	}

	id, err := Save(ctx, Session{
		UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
		return
	}
	SetCookie(c, id)
	clearAttempts(email)
	audit.Write(ctx, models.AuditEntry{
		Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
}
```

- [x] **Step 3: Initialise mail at boot**

In `admin/cmd/main.go`:

```go
	mail.Init(mail.Config{
		Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
		Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
		PublicURL: cfg.PublicURL,
	})
	if !mail.Enabled() {
		log.Println("warning: SMTP not configured; verification and licence emails will fail")
	}
```

- [x] **Step 4: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [x] **Step 5: Commit**

```bash
git add admin/
git commit -m "feat(admin): self-hosted customer accounts with email verification"
```

---

### Task 8: Linking, relink and the customer API

**Files:**

- Create: `admin/internal/licensing/link.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`
- Modify: `admin/cmd/main.go`

**Interfaces:**

- Consumes: `licensing.Issue`, `inject.Deliver`, `mail.SendLicense`
- Produces:
    - `licensing.LinkInstance(ctx, accountID, instanceID, name string) (*models.Instance, error)`
    - `licensing.Relink(ctx, accountID, oldID, newID string, staff bool) (*models.License, error)`
    - `api.Routes(cfg) http.Handler`
    - `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper

- [x] **Step 1: Write linking and relink**

Create `admin/internal/licensing/link.go`:

```go
package licensing

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

var (
	ErrBadUUID       = errors.New("that does not look like an instance ID")
	ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
	ErrRelinkLimit   = errors.New("relink limit reached for this term; contact support")
)

// LinkInstance attaches a self-hosted instance UUID to an account.
//
// The duplicate error deliberately does not say WHICH account holds it. It is a
// small enumeration surface, but there is no reason to leave it open.
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
	if _, err := uuid.Parse(instanceID); err != nil {
		return nil, ErrBadUUID
	}

	// A self-hosted UUID must not collide with a cloud instance either.
	if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
		return nil, ErrAlreadyLinked
	}

	inst := models.Instance{
		InstanceID: instanceID,
		AccountID:  accountID,
		Name:       name,
		Deployment: license.DeploymentSelfHosted,
		Status:     models.StatusActive,
		CreatedAt:  time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			// The unique index is what actually prevents two accounts owning
			// one instance. The check above is a nicety; this is the guarantee.
			return nil, ErrAlreadyLinked
		}
		return nil, err
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
	return &inst, nil
}

// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
// a way to extend a subscription.
//
// The old licence is not revoked, because offline verification has no
// revocation. It simply no longer matches any UUID the customer controls, and
// its binding stops it working on another machine anyway.
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
	if _, err := uuid.Parse(newID); err != nil {
		return nil, ErrBadUUID
	}

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
		return nil, ErrUnknownInstance
	}

	// The cap is a signal, not a defence. Its job is to put a human in front of
	// the fourth attempt, so staff bypass it.
	if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
		return nil, ErrRelinkLimit
	}

	if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
		return nil, ErrAlreadyLinked
	}

	// Preserve the remaining term from the current licence.
	remaining := time.Now().UTC().Add(models.GracePeriod)
	var current models.License
	if err := db.Admin("licenses").FindOne(ctx,
		bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err == nil {
		remaining = current.ExpiresAt
	}

	if _, err := db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": oldID},
		bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			return nil, ErrAlreadyLinked
		}
		return nil, fmt.Errorf("relink: %w", err)
	}

	actor := accountID
	if staff {
		actor = "staff"
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: actor, Action: "instance.relinked", AccountID: accountID,
		Target: newID, Detail: "was " + oldID})

	return Issue(ctx, IssueInput{
		InstanceID: newID,
		Tier:       inst.Tier,
		ExpiresAt:  remaining,
		Reason:     models.ReasonRelink,
		IssuedBy:   actor,
	})
}
```

- [x] **Step 2: Write the customer handlers**

Create `admin/internal/api/customer.go`:

```go
package api

import (
	"errors"
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// ownedInstance resolves an instance and confirms the session's account owns it.
//
// EVERY customer handler that names an instance must go through this. It returns
// 404 for another account's instance rather than 403: a 403 confirms the
// instance exists, which is an existence oracle over customer data.
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
	s := auth.Current(c)
	if s == nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
		return nil, false
	}
	var inst models.Instance
	err := db.Admin("admin_instances").FindOne(c.Request.Context(),
		bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return nil, false
	}
	return &inst, true
}

func getAccount(c *gin.Context) {
	s := auth.Current(c)
	ctx := c.Request.Context()

	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}

	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	instances := []models.Instance{}
	if err := cur.All(ctx, &instances); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
}

func linkInstance(c *gin.Context) {
	var body struct {
		InstanceID string `json:"instance_id"`
		Name       string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
		return
	}

	s := auth.Current(c)
	inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
	if err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, licensing.ErrAlreadyLinked) {
			status = http.StatusConflict
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, inst)
}

func relinkInstance(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	var body struct {
		InstanceID string `json:"instance_id"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
		return
	}

	s := auth.Current(c)
	lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
	if err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, licensing.ErrRelinkLimit) {
			status = http.StatusForbidden
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}
	deliver(c, inst, lic)
	c.JSON(http.StatusOK, lic)
}

func getInstanceLicense(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	var lic models.License
	if err := db.Admin("licenses").FindOne(c.Request.Context(),
		bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
		return
	}
	c.JSON(http.StatusOK, lic)
}

func downloadInstanceLicense(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	var lic models.License
	if err := db.Admin("licenses").FindOne(c.Request.Context(),
		bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
		return
	}
	c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
	c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
}

func listSubscriptions(c *gin.Context) {
	s := auth.Current(c)
	cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	subs := []models.Subscription{}
	if err := cur.All(c.Request.Context(), &subs); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, subs)
}

// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
// Delivery failures are logged, never returned: the licence is already recorded,
// which is the part that must not be lost.
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
	if inst.Deployment == license.DeploymentCloud {
		inject.Deliver(c.Request.Context(), lic)
		return
	}
	s := auth.Current(c)
	if s != nil && mail.Enabled() {
		_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
	}
}
```

- [x] **Step 3: Write the route table**

Create `admin/internal/api/routes.go`:

```go
// Package api mounts admin's HTTP surface.
//
// The route table is the single place scoping is guaranteed. Customer routes
// live behind RequireCustomer and every handler that names an instance calls
// ownedInstance. A new customer route that skips that helper is a scoping bug,
// so keep them together and review them together.
package api

import (
	"net/http"
	"slices"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
)

func Routes(cfg config.Config) http.Handler {
	r := gin.New()
	r.Use(gin.Logger(), gin.Recovery())
	r.Use(cors(cfg.AllowedOrigins))

	if cfg.TrustProxy {
		_ = r.SetTrustedProxies(nil)
	}

	r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })

	r.POST("/auth/staff/login", auth.HandleStaffLogin)
	r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
	r.POST("/auth/logout", auth.HandleLogout)
	r.GET("/auth/verify", auth.HandleVerify)

	cust := r.Group("/api")
	cust.Use(auth.RequireCustomer())
	{
		cust.GET("/account", getAccount)
		cust.POST("/instances/link", linkInstance)
		cust.POST("/instances/:id/relink", relinkInstance)
		cust.GET("/instances/:id/license", getInstanceLicense)
		cust.GET("/instances/:id/license/download", downloadInstanceLicense)
		cust.GET("/subscriptions", listSubscriptions)
	}

	staff := r.Group("/api/staff")
	staff.Use(auth.RequireStaff())
	{
		staff.GET("/accounts", staffListAccounts)
		staff.POST("/accounts", staffCreateAccount)
		staff.GET("/accounts/:id", staffGetAccount)
		staff.GET("/instances", staffListInstances)
		staff.POST("/instances", staffCreateInstance)
		staff.POST("/instances/:id/issue", staffIssue)
		staff.POST("/instances/:id/relink", staffRelink)
		staff.GET("/licenses", staffListLicenses)
		staff.GET("/plans", staffListPlans)
		staff.PUT("/plans/:tier", staffUpdatePlan)
		staff.GET("/audit", staffAudit)
		staff.GET("/health/injection", staffInjectionHealth)
	}

	return r
}

func cors(allowed []string) gin.HandlerFunc {
	return func(c *gin.Context) {
		origin := c.GetHeader("Origin")
		if origin != "" && slices.Contains(allowed, origin) {
			c.Header("Access-Control-Allow-Origin", origin)
			c.Header("Access-Control-Allow-Credentials", "true")
			c.Header("Access-Control-Allow-Headers", "Content-Type")
			c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
		}
		if c.Request.Method == http.MethodOptions {
			c.AbortWithStatus(http.StatusNoContent)
			return
		}
		c.Next()
	}
}
```

- [x] **Step 4: Mount it**

In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:

```go
		Handler: api.Routes(cfg),
```

Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"`.

- [x] **Step 5: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: failures naming the staff handlers, which arrive in Task 9. Build again after Task 9.

- [x] **Step 6: Commit**

```bash
git add admin/
git commit -m "feat(admin): linking, relink and the scoped customer API"
```

---

### Task 9: Staff API

**Files:**

- Create: `admin/internal/api/staff.go`

**Interfaces:**

- Consumes: `licensing.Issue`, `licensing.Relink`, `models`
- Produces: the handlers named in `routes.go`

- [x] **Step 1: Write the handlers**

Create `admin/internal/api/staff.go`:

```go
package api

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func staffListAccounts(c *gin.Context) {
	filter := bson.M{}
	if q := c.Query("q"); q != "" {
		filter["$or"] = []bson.M{
			{"name": bson.M{"$regex": q, "$options": "i"}},
			{"billing_email": bson.M{"$regex": q, "$options": "i"}},
		}
	}
	cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
		options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	accounts := []models.Account{}
	if err := cur.All(c.Request.Context(), &accounts); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, accounts)
}

func staffCreateAccount(c *gin.Context) {
	var body struct {
		Name         string `json:"name"`
		BillingEmail string `json:"billing_email"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
		return
	}
	acct := models.Account{
		AccountID:    uuid.NewString(),
		Name:         body.Name,
		BillingEmail: body.BillingEmail,
		Status:       models.AccountActive,
		CreatedAt:    time.Now().UTC(),
	}
	if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, acct)
}

func staffGetAccount(c *gin.Context) {
	ctx := c.Request.Context()
	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}
	cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID})
	instances := []models.Instance{}
	if cur != nil {
		_ = cur.All(ctx, &instances)
	}
	c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
}

func staffListInstances(c *gin.Context) {
	filter := bson.M{}
	for param, field := range map[string]string{
		"account_id": "account_id",
		"deployment": "deployment",
		"status":     "status",
	} {
		if v := c.Query(param); v != "" {
			filter[field] = v
		}
	}
	if c.Query("expiring") == "true" {
		// Instances whose licence expires within 14 days, for renewal chasing.
		var ids []string
		cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
			"superseded_by": bson.M{"$exists": false},
			"expires_at":    bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
		})
		if err == nil {
			var lics []models.License
			if cur.All(c.Request.Context(), &lics) == nil {
				for _, l := range lics {
					ids = append(ids, l.InstanceID)
				}
			}
		}
		filter["instance_id"] = bson.M{"$in": ids}
	}

	cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
		options.Find().SetLimit(500))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	instances := []models.Instance{}
	if err := cur.All(c.Request.Context(), &instances); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, instances)
}

// staffCreateInstance attaches an instance to an account.
//
// For cloud, this ADOPTS an instance that already exists in the control plane —
// the control-plane row is the source of truth for its name and slug, and this
// refuses if no such instance exists, because an admin row pointing at nothing
// would issue licences nobody can use.
//
// For self-hosted it does the same job as the customer-facing link endpoint, so
// staff can link on a customer's behalf during support.
//
// This is how existing cloud instances get licensed: adopt, then issue.
func staffCreateInstance(c *gin.Context) {
	var body struct {
		InstanceID string `json:"instance_id"`
		AccountID  string `json:"account_id"`
		Deployment string `json:"deployment"`
		Name       string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
		return
	}
	ctx := c.Request.Context()

	if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
		return
	}

	inst := models.Instance{
		InstanceID: body.InstanceID,
		AccountID:  body.AccountID,
		Name:       body.Name,
		Deployment: body.Deployment,
		Status:     models.StatusActive,
		CreatedAt:  time.Now().UTC(),
	}

	if body.Deployment == license.DeploymentCloud {
		var remote sharedmodels.Instance
		if err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
			return
		}
		inst.Name = remote.Name
		inst.Slug = remote.Slug
	}

	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
	c.JSON(http.StatusCreated, inst)
}

func staffIssue(c *gin.Context) {
	var body struct {
		Tier   string `json:"tier"`
		Term   string `json:"term"`
		Reason string `json:"reason"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
		return
	}
	if body.Reason == "" {
		body.Reason = models.ReasonManual
	}

	s := auth.Current(c)
	lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
		InstanceID: c.Param("id"),
		Tier:       body.Tier,
		Term:       body.Term,
		Reason:     body.Reason,
		IssuedBy:   s.Email,
	})
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var inst models.Instance
	if db.Admin("admin_instances").FindOne(c.Request.Context(),
		bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
		deliver(c, &inst, lic)
	}
	c.JSON(http.StatusCreated, lic)
}

// staffRelink has no attempt cap. The customer-facing limit exists to put a
// human in front of the fourth attempt; this is that human.
func staffRelink(c *gin.Context) {
	var body struct {
		InstanceID string `json:"instance_id"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
		return
	}
	ctx := c.Request.Context()

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return
	}

	lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, lic)
}

func staffListLicenses(c *gin.Context) {
	filter := bson.M{}
	if v := c.Query("instance_id"); v != "" {
		filter["instance_id"] = v
	}
	if v := c.Query("account_id"); v != "" {
		filter["account_id"] = v
	}
	cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
		options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	lics := []models.License{}
	if err := cur.All(c.Request.Context(), &lics); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, lics)
}

func staffListPlans(c *gin.Context) {
	cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	plans := []models.Plan{}
	if err := cur.All(c.Request.Context(), &plans); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, plans)
}

// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
// snapshotted their plan at issue time and are unaffected — the same rule as
// workflow_runs.steps_snapshot.
func staffUpdatePlan(c *gin.Context) {
	var body models.Plan
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
		return
	}
	set := bson.M{
		"name":              body.Name,
		"limits":            body.Limits,
		"features":          body.Features,
		"paddle_product_id": body.PaddleProductID,
		"paddle_price_ids":  body.PaddlePriceIDs,
		"active":            body.Active,
	}
	if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
		bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"updated": true})
}

func staffAudit(c *gin.Context) {
	cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{},
		options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	entries := []models.AuditEntry{}
	if err := cur.All(c.Request.Context(), &entries); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, entries)
}

// staffInjectionHealth lists instances whose last injection failed. This is the
// page to look at when a customer says their cloud instance is read-only.
func staffInjectionHealth(c *gin.Context) {
	cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
		bson.M{"inject_failed_at": bson.M{"$exists": true}})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	failed := []models.Instance{}
	if err := cur.All(c.Request.Context(), &failed); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
}
```

- [x] **Step 2: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output. This is the first build where the whole service compiles.

- [x] **Step 3: Audit customer scoping by hand**

With no test suite, this replaces spec 3's table-driven test 20.

```bash
grep -n "cust\." admin/internal/api/routes.go
grep -n "func \(getAccount\|linkInstance\|relinkInstance\|getInstanceLicense\|downloadInstanceLicense\|listSubscriptions\)" -A 12 admin/internal/api/customer.go | grep -n "ownedInstance\|s.AccountID"
```

Every customer route must either call `ownedInstance` or filter by `s.AccountID`. A route that does neither is a data leak — fix before continuing.

- [x] **Step 4: Commit**

```bash
git add admin/
git commit -m "feat(admin): staff API"
```

---

### Task 10: Deployment wiring

**Files:**

- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`

- [x] **Step 1: Add the service**

In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:

```yaml
admin:
    image: ${DOCKER_HOST}/mrhid6/vantage-admin:latest
    restart: unless-stopped
    ports:
        - "8083:8083"
    environment:
        ADMIN_MONGO_URI: ${ADMIN_MONGO_URI}
        CONTROL_MONGO_URI: ${CONTROL_MONGO_URI}
        REDIS_ADDR: redis:6379
        LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY}
        PUBLIC_URL: ${ADMIN_PUBLIC_URL}
        ADMIN_ORIGIN: ${ADMIN_ORIGIN}
        SMTP_HOST: ${SMTP_HOST}
        SMTP_PORT: ${SMTP_PORT}
        SMTP_FROM: ${SMTP_FROM}
        SMTP_USERNAME: ${SMTP_USERNAME}
        SMTP_PASSWORD: ${SMTP_PASSWORD}
        TRUST_PROXY: "true"
    depends_on:
        - redis
```

`LICENSE_SIGNING_KEY` appears in exactly one service in exactly one compose file. It must never be added to `server`, and `deploy/docker-compose.yml` — the self-hosted deployment — must not mention admin at all.

- [x] **Step 2: Add the build**

In `.gitea/workflows/server-deploy.yml`, add a fourth image alongside `server`, `web`, `site` and `sitesvc`, with context `.` and file `admin/Dockerfile`, following the pattern the other Go services already use.

- [x] **Step 3: Confirm the self-hosted deployment is untouched**

```bash
grep -c "admin" deploy/docker-compose.yml
```

Expected: `0`.

- [x] **Step 4: Commit**

```bash
git add deploy/ .gitea/
git commit -m "feat(admin): compose service and image build"
```

---

### Task 11: Full verification

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

**Files:** none

- [x] **Step 1: Build every module and the image**

```bash
cd c:/Work/Repos/vantage
for m in shared server sitesvc admin; do sh /tmp/gorun.sh $m go build ./... && sh /tmp/gorun.sh $m go vet ./...; done
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
  -v vantage-gocache:/root/.cache/go-build -w /src/agent -e GOWORK=off golang:1.26 \
  sh -c "go build ./... && go vet ./..."
docker build -q -f admin/Dockerfile -t vantage-admin:test .
docker build -q -f server/Dockerfile -t vantage-server:test .
```

Expected: all succeed. The image build is the one that catches a `go.sum` the workspace was masking — it caught exactly that in plan 2.

- [x] **Step 2: Start the supporting containers**

```bash
docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine
```

MongoDB is expected on the host at `27021`, as in plan 2.

- [x] **Step 3: Boot the control plane and bootstrap a cloud instance**

```bash
docker run -d --name vadmin-server -p 8080:8080 \
  -e MONGO_URI=mongodb://host.docker.internal:27021 -e MONGO_DB=vantage_admin_test \
  -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6399 \
  -e VANTAGE_DEPLOYMENT=cloud \
  -e KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 \
  --add-host host.docker.internal:host-gateway vantage-server:test
sleep 12
curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json' \
  -d '{"instance_name":"Admin Test","email":"owner@example.com","password":"hunter2hunter2"}'
```

Record the `instance_id`.

- [x] **Step 4: Boot admin**

```bash
docker run -d --name vadmin -p 8083:8083 \
  -e ADMIN_MONGO_URI=mongodb://host.docker.internal:27021/vantage_admin_own \
  -e CONTROL_MONGO_URI=mongodb://host.docker.internal:27021/vantage_admin_test \
  -e REDIS_ADDR=host.docker.internal:6399 \
  -e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" \
  -e PUBLIC_URL=http://localhost:8083 -e ADMIN_ORIGIN=http://localhost:3002 \
  --add-host host.docker.internal:host-gateway vantage-admin:test
sleep 8
curl -s localhost:8083/healthz
docker logs vadmin 2>&1 | tail -5
```

Expected: `{"ok":true}` and a clean log. Then confirm it refuses to start without a key:

```bash
docker run --rm -e ADMIN_MONGO_URI=mongodb://x/y -e CONTROL_MONGO_URI=mongodb://x/z \
  -e REDIS_ADDR=x:6379 -e PUBLIC_URL=x -e ADMIN_ORIGIN=x vantage-admin:test
```

Expected: exits non-zero with `missing required environment: LICENSE_SIGNING_KEY`.

- [x] **Step 5: Adopt the cloud instance and issue through the staff API**

This is the flow the admin UI will drive when you licence your existing cloud
instances by hand.

```bash
docker exec vadmin adminctl staff-add --email=staff@example.com --name=Staff --password=correcthorsebattery

curl -s -X POST localhost:8083/auth/staff/login -H 'Content-Type: application/json' \
  -d '{"email":"staff@example.com","password":"correcthorsebattery"}' -c /tmp/s.txt

ACCOUNT=$(curl -s -X POST localhost:8083/api/staff/accounts -b /tmp/s.txt \
  -H 'Content-Type: application/json' \
  -d '{"name":"Admin Test","billing_email":"owner@example.com"}' \
  | grep -o '"account_id":"[^"]*"' | cut -d'"' -f4)

INSTANCE=<instance_id from step 3>

curl -s -X POST localhost:8083/api/staff/instances -b /tmp/s.txt \
  -H 'Content-Type: application/json' \
  -d "{\"instance_id\":\"$INSTANCE\",\"account_id\":\"$ACCOUNT\",\"deployment\":\"cloud\"}"

curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
  -H 'Content-Type: application/json' -d '{"tier":"professional","term":"annual"}'

sleep 3
curl -s -X POST localhost:8080/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/a.txt
curl -s localhost:8080/api/license -b /tmp/a.txt
```

Expected: adopting the instance returns 201, issuing returns 201, and the control
plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds,
with no restart**. This is the whole system working end to end.

- [x] **Step 6: Confirm the Free rule and the deployment check**

Reusing the staff session and `$INSTANCE` from step 5:

```bash
curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
  -H 'Content-Type: application/json' -d '{"tier":"self_hosted","term":"annual"}'
```

Expected: `"that plan is not available for this deployment type"` — a self-hosted plan cannot be issued to a cloud instance, and the mirror of that check is what makes Free cloud-only.

- [x] **Step 7: Confirm supersession and the snapshot rule**

```bash
curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
  -H 'Content-Type: application/json' -d '{"tier":"professional","term":"annual","reason":"renewal"}'
curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt
```

Expected: two licence rows; the older one carries `superseded_by` naming the newer. Nothing was deleted.

Then edit the plan and confirm the issued licence is unchanged:

```bash
curl -s -X PUT localhost:8083/api/staff/plans/professional -b /tmp/s.txt \
  -H 'Content-Type: application/json' \
  -d '{"name":"Professional","limits":{"max_servers":7,"max_secret_groups":7,"max_channels":7},"features":["console"],"active":true}'
curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt
```

Expected: the existing licences still report `max_servers: -1`. Editing a plan never rewrites history.

- [x] **Step 8: Confirm reconciliation repairs a tampered instance**

```bash
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
  "mongodb://host.docker.internal:27021/vantage_admin_test" --quiet \
  --eval 'db.instances.updateOne({}, {$set:{license_blob:"TAMPERED"}})'
docker exec vadmin sh -c 'kill -USR1 1' 2>/dev/null || true
```

The reconciler runs every 15 minutes, so either wait, or restart admin to trigger a cycle and confirm from the logs:

```bash
docker restart vadmin && sleep 8
curl -s localhost:8080/api/license -b /tmp/a.txt
```

Expected: the control plane reports `valid` again once reconciliation has run — the blob is restored from what admin believes it should be. Confirm `reconcile: repairing instance` appears in `docker logs vadmin`.

- [x] **Step 9: Confirm customer scoping returns 404, not 403**

Create a second account and instance via the staff API, sign in as the first customer, and request the second account's instance:

```bash
curl -s -o /dev/null -w "other account's instance: %{http_code}\n" \
  localhost:8083/api/instances/<other-instance-id>/license -b /tmp/c.txt
```

Expected: **404**. A 403 would confirm the instance exists.

- [x] **Step 10: Confirm admin is not a runtime dependency**

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

Expected: the licence still reports `valid` and mutations still work. **This is the most important check in the plan.** Instances verify offline and must never call admin.

- [x] **Step 11: Confirm the control-plane write surface**

```bash
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
  "mongodb://host.docker.internal:27021/vantage_admin_test" --quiet \
  --eval 'db.getCollectionNames().forEach(n => print(n + ": " + db[n].countDocuments()))'
```

Expected: `servers`, `keys`, `secrets` and every other collection are exactly as the control plane left them. Admin touched only `instances`.

- [x] **Step 12: Clean up and commit**

```bash
docker rm -f vadmin vadmin-server vadmin-redis
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
  "mongodb://host.docker.internal:27021" --quiet \
  --eval 'db.getSiblingDB("vantage_admin_test").dropDatabase(); db.getSiblingDB("vantage_admin_own").dropDatabase()'
rm -f /tmp/a.txt /tmp/s.txt /tmp/c.txt
unset LICENSE_SIGNING_KEY

git add -A
git commit -m "chore: verify the admin backend end to end" --allow-empty
```

---

## Rollout

1. Deploy admin with `LICENSE_SIGNING_KEY` set, alongside the existing site stack.
2. `adminctl staff-add` for each staff member. There is no signup.
3. Spec 4 (the site) and spec 5 (Paddle) can then proceed in parallel.
4. Once the UI exists, licence the existing cloud instances by hand: attach each to an account, then issue. **They stay read-only until that is done**, so it is the first thing the UI is used for, not the last.

## Risks

| Risk                                                         | Mitigation                                                                                                    |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| Admin becomes a runtime dependency                           | Task 11 Step 10 verifies instances work with admin stopped                                                    |
| Signing key exposure                                         | One service, one variable, one compose file; never in `server`; rotation path from plan 1                     |
| Cloud password now unlocks billing                           | Owner-only, rate-limited, every attempt audited; state it in the release notes                                |
| Injection silently fails                                     | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection`                           |
| Admin writes outside its remit                               | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it                          |
| Self-hosted UUID squatted                                    | Unique index on `admin_instances.instance_id`, non-disclosing error                                           |
| A customer route forgets to scope                            | `ownedInstance` helper, audited by hand in Task 9 Step 3                                                      |
| Existing cloud instances stay read-only longer than intended | Licensing them is the first job the admin UI is used for; Task 11 Step 5 proves the flow before the UI exists |
| An admin instance row points at no real cloud instance       | `staffCreateInstance` refuses a cloud instance the control plane does not have                                |
