diff --git a/docs/superpowers/plans/2026-07-24-admin-backend.md b/docs/superpowers/plans/2026-07-24-admin-backend.md
deleted file mode 100644
index 4e0984c..0000000
--- a/docs/superpowers/plans/2026-07-24-admin-backend.md
+++ /dev/null
@@ -1,3181 +0,0 @@
-# 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
- 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:"
-}
-
-// 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(¤t); 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=
-
-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//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 |
diff --git a/docs/superpowers/plans/2026-07-24-instance-licensing.md b/docs/superpowers/plans/2026-07-24-instance-licensing.md
deleted file mode 100644
index 9c96cd4..0000000
--- a/docs/superpowers/plans/2026-07-24-instance-licensing.md
+++ /dev/null
@@ -1,1664 +0,0 @@
-# Instance Licensing and Enforcement Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Store a licence on the instance document, resolve it into a cached runtime state, gate every mutating API route on it, enforce countable limits in the service layer, and let an owner paste a licence in.
-
-**Architecture:** Three enforcement layers that answer different questions — a group-level gin middleware for mutations (deny by default), a per-route middleware for features, and service-layer checks for limits, which need a count middleware does not have. Monitors keep executing when a licence lapses; growth and change stop, running infrastructure does not.
-
-**Tech Stack:** Go 1.26, gin, MongoDB driver v2, `shared/license` from plan 1, Next.js 16 + TanStack Query.
-
-**No automated tests.** Verification is by compiler, `grep`, `lkctl`-issued licences, and running the Docker images against a scratch database.
-
-## Global Constraints
-
-- Depends on plan 1 (`licensing-core`) being merged, with a real key in `trustedPublicKeys`.
-- **An expired licence must never break a running fleet.** Agents keep their keys, monitors keep checking, alerts keep firing.
-- `expired` and `invalid` degrade identically and differ only in the message shown.
-- `DELETE` on any resource is **always** permitted, in every licence state. A customer downgraded to Free with 10 servers must be able to remove 7.
-- `POST /api/servers/:id/apply-updates` is always permitted. Security patching is never paywalled.
-- Over-limit instances are **never** truncated. Existing resources keep running; only creation stops.
-- `VANTAGE_DEPLOYMENT` defaults to `self_hosted` — the stricter mode — when unset or unrecognised.
-- Do not run the app with `go run`. Build the images and run containers.
-- Local environment: MongoDB on `27021`, Mailpit SMTP `1031` / UI `8031`. Containers reach the host via `host.docker.internal` with `--add-host host.docker.internal:host-gateway`.
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| -------------------------------------------- | ------------------------------------------------------------------------------------ |
-| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode |
-| `server/internal/services/licence_limits.go` | `CheckServerLimit`, `CheckSecretGroupLimit`, `CheckChannelLimit`, `ErrLimitExceeded` |
-| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` |
-| `web/app/(app)/settings/license/page.tsx` | Licence settings page |
-| `web/components/LicenseBanner.tsx` | Persistent banner |
-| `web/lib/useLicense.ts` | `useLicense()` hook |
-
-**Modified:** `shared/models/instance.go`, `server/internal/api/handlers.go`, `server/internal/services/servers.go`, `secrets.go`, `channels.go`, `server/internal/auth/oidc.go`, `server/internal/auth/instancehost.go`, `server/internal/api/org.go` (renamed), `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/layout.tsx`, `web/app/setup/page.tsx`.
-
----
-
-### Task 1: Finish the Org to Instance rename
-
-Plan 0b's naming map said the rename was total. It is not: 18 private identifiers still say `Org`. None affect the wire format, the database or any route, so nothing is broken — but this plan adds a licence cache next to the instance cache in the same file, and leaving two naming conventions side by side there is how the next person gets confused.
-
-**Files:**
-
-- Modify: `server/internal/auth/instancehost.go`, `server/internal/auth/oidc.go`, `server/internal/auth/session.go`
-- Modify: `server/internal/services/instance_oidc.go`
-- Rename: `server/internal/api/org.go` → `server/internal/api/instance.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces: `services.SaveInstanceOIDC` replacing `SaveOrgOIDC`; `auth.SaveStateInstance` / `auth.ConsumeStateInstance` replacing the `...Org` forms
-
-- [ ] **Step 1: Rename the file**
-
-```bash
-cd c:/Work/Repos/vantage
-git mv server/internal/api/org.go server/internal/api/instance.go
-```
-
-- [ ] **Step 2: Rename the identifiers**
-
-```bash
-cd c:/Work/Repos/vantage/server
-FILES=$(find . -name '*.go' -not -name 'migrate.go')
-sed -i 's/\bcachedOrg\b/cachedInstance/g' $FILES
-sed -i 's/\borgCacheMu\b/instanceCacheMu/g' $FILES
-sed -i 's/\borgCacheTTL\b/instanceCacheTTL/g' $FILES
-sed -i 's/\borgCache\b/instanceCache/g' $FILES
-sed -i 's/\bproviderForOrg\b/providerForInstance/g' $FILES
-sed -i 's/\bSaveStateOrg\b/SaveStateInstance/g' $FILES
-sed -i 's/\bConsumeStateOrg\b/ConsumeStateInstance/g' $FILES
-sed -i 's/\bSaveOrgOIDC\b/SaveInstanceOIDC/g' $FILES
-sed -i 's/\bGetOrgOIDC\b/GetInstanceOIDC/g' $FILES
-```
-
-The `migrate.go` exclusion is deliberate and permanent: migrations 0001 to 0003 run before the rename and must keep speaking the pre-rename shape.
-
-- [ ] **Step 3: Fix the struct field left behind**
-
-`cachedInstance` still has a field named `org`. In `server/internal/auth/instancehost.go`, change the struct and its two uses:
-
-```go
-type cachedInstance struct {
- instance *models.Instance
- at time.Time
-}
-```
-
-Then update the read and write sites in `InstanceFromHost` to use `e.instance` and `cachedInstance{instance: inst, at: time.Now()}`. `go build` will point at both.
-
-- [ ] **Step 4: Build and confirm**
-
-```bash
-cd c:/Work/Repos/vantage/server
-go build ./... && go vet ./...
-cd ..
-grep -rn "\bOrg\b\|orgCache\|cachedOrg\|ForOrg\|StateOrg\|OrgOIDC" --include=*.go server/ | grep -v migrate.go
-```
-
-Expected: no build output, and the grep returns only `MigrateOrgToInstance` in `main.go` and `migrate_instance.go`, which are migration names and must not change.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add server/
-git commit -m "refactor(server): finish the Org to Instance rename
-
-Private identifiers plan 0b's naming map missed. No wire format, database
-field or route changes."
-```
-
----
-
-### Task 2: Store the licence on the instance
-
-**Files:**
-
-- Modify: `shared/models/instance.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces: `models.Instance` gains `LicenseBlob string`, `LicenseTier string`, `LicenseExpiry *time.Time`
-
-- [ ] **Step 1: Add the fields**
-
-Replace the `Instance` struct in `shared/models/instance.go`:
-
-```go
-// Instance is one deployment of Vantage: its own subdomain, users, servers,
-// keys, workflows, monitors and secrets. It is the unit a licence attaches to.
-//
-// A paying customer may hold several. That grouping is called an Account and
-// lives only in the admin control plane — this service never sees it.
-type Instance struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
- InstanceID string `bson:"instance_id" json:"instance_id"`
- Name string `bson:"name" json:"name"`
- Slug string `bson:"slug" json:"slug"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
-
- // LicenseBlob is the authoritative licence. LicenseTier and LicenseExpiry
- // are a denormalised cache for listing and for the admin service's queries,
- // rewritten from the verified payload every time a blob is accepted.
- // Nothing reads them for enforcement.
- //
- // The blob is json:"-" because there is no reason to spray it through API
- // responses. It is signed public data, not a secret.
- LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
- LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
- LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
-}
-```
-
-No migration is needed: `omitempty` means existing documents simply have no licence, which resolves to `no_license`.
-
-- [ ] **Step 2: Build**
-
-```bash
-cd c:/Work/Repos/vantage
-(cd shared && go build ./...) && (cd server && go build ./...) && (cd sitesvc && go build ./...)
-```
-
-Expected: no output.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add shared/models/instance.go
-git commit -m "feat(shared): add licence fields to Instance"
-```
-
----
-
-### Task 3: Runtime licence state
-
-**Files:**
-
-- Create: `server/internal/services/licence.go`
-
-**Interfaces:**
-
-- Consumes: `license.Verify`, `license.Result`, `services.GetInstance`
-- Produces:
- - `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }`
- - `func (s LicenseState) Active() bool`
- - `func (s LicenseState) Feature(name string) bool`
- - `func DeploymentMode() string`
- - `func GetLicenseState(instanceID string) LicenseState`
- - `func StoreLicense(instanceID, blob string) (LicenseState, error)`
- - `func InvalidateLicenseCache(instanceID string)`
-
-- [ ] **Step 1: Write it**
-
-Create `server/internal/services/licence.go`:
-
-```go
-package services
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "sync"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// LicenseState is the resolved licence for one instance.
-type LicenseState struct {
- Status license.State `json:"state"`
- Reason string `json:"reason,omitempty"`
- Tier string `json:"tier,omitempty"`
- ExpiresAt *time.Time `json:"expires_at,omitempty"`
- Limits license.Limits `json:"limits"`
- Features map[string]bool `json:"features"`
- // Source is "stored", "env" or "none" — useful when a self-hosted operator
- // asks why the licence they pasted is not the one in effect.
- Source string `json:"source"`
-}
-
-// Active reports whether mutations are allowed.
-func (s LicenseState) Active() bool { return s.Status == license.StateValid }
-
-// Feature reports whether a named feature is granted.
-func (s LicenseState) Feature(name string) bool { return s.Features[name] }
-
-// DeploymentMode is how this install describes itself to the verifier.
-//
-// It defaults to self_hosted, the stricter mode. An operator who removes the
-// variable gets the tighter behaviour, not the looser one.
-func DeploymentMode() string {
- if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud {
- return license.DeploymentCloud
- }
- return license.DeploymentSelfHosted
-}
-
-type cachedLicense struct {
- state LicenseState
- at time.Time
-}
-
-var (
- licenseCacheMu sync.Mutex
- licenseCache = map[string]cachedLicense{}
-)
-
-const licenseCacheTTL = 60 * time.Second
-
-// InvalidateLicenseCache drops the cached state for one instance, so a pasted
-// licence takes effect immediately rather than within the TTL.
-func InvalidateLicenseCache(instanceID string) {
- licenseCacheMu.Lock()
- delete(licenseCache, instanceID)
- licenseCacheMu.Unlock()
-}
-
-// GetLicenseState resolves the licence for an instance, cached for 60 seconds.
-//
-// Resolution order:
-//
-// 1. the blob stored on the instance document
-// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an
-// automated self-hosted deployment can ship a licence without a human
-// pasting one
-// 3. neither -> invalid / no_license
-//
-// A blob stored through the UI always wins afterwards, so an operator is never
-// locked out by a stale environment value.
-func GetLicenseState(instanceID string) LicenseState {
- licenseCacheMu.Lock()
- if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL {
- licenseCacheMu.Unlock()
- return e.state
- }
- licenseCacheMu.Unlock()
-
- state := resolveLicenseState(instanceID)
-
- licenseCacheMu.Lock()
- licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()}
- licenseCacheMu.Unlock()
- return state
-}
-
-func resolveLicenseState(instanceID string) LicenseState {
- inst, err := GetInstance(instanceID)
- if err != nil {
- return LicenseState{
- Status: license.StateInvalid,
- Reason: license.ReasonNoLicense,
- Features: map[string]bool{},
- Source: "none",
- }
- }
-
- blob, source := inst.LicenseBlob, "stored"
- if blob == "" {
- blob, source = os.Getenv("VANTAGE_LICENSE"), "env"
- }
- if blob == "" {
- return LicenseState{
- Status: license.StateInvalid,
- Reason: license.ReasonNoLicense,
- Features: map[string]bool{},
- Source: "none",
- }
- }
-
- res := license.Verify(blob, license.VerifyOpts{
- InstanceID: instanceID,
- Deployment: DeploymentMode(),
- })
- return stateFromResult(res, source)
-}
-
-func stateFromResult(res license.Result, source string) LicenseState {
- feats := map[string]bool{}
- for _, f := range res.License.Features {
- feats[f] = true
- }
- s := LicenseState{
- Status: res.State,
- Reason: res.Reason,
- Tier: res.License.Tier,
- Limits: res.License.Limits,
- Features: feats,
- Source: source,
- }
- if !res.License.ExpiresAt.IsZero() {
- exp := res.License.ExpiresAt
- s.ExpiresAt = &exp
- }
- return s
-}
-
-// StoreLicense verifies a blob against this instance and stores it.
-//
-// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired
-// and when. An invalid blob is rejected and the previous one kept.
-func StoreLicense(instanceID, blob string) (LicenseState, error) {
- blob = strings.TrimSpace(blob)
-
- res := license.Verify(blob, license.VerifyOpts{
- InstanceID: instanceID,
- Deployment: DeploymentMode(),
- })
- if res.State == license.StateInvalid {
- return LicenseState{}, fmt.Errorf("%s", res.Reason)
- }
-
- set := bson.M{
- "license_blob": blob,
- "license_tier": res.License.Tier,
- "license_expiry": res.License.ExpiresAt,
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if _, err := db.Col("instances").UpdateOne(ctx,
- bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
- return LicenseState{}, err
- }
-
- InvalidateLicenseCache(instanceID)
- return stateFromResult(res, "stored"), nil
-}
-```
-
-- [ ] **Step 2: Build**
-
-Run: `cd server && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add server/internal/services/licence.go
-git commit -m "feat(server): resolve licence state per instance"
-```
-
----
-
-### Task 4: Limits in the service layer
-
-Limits need a count, which middleware does not have.
-
-**Files:**
-
-- Create: `server/internal/services/licence_limits.go`
-- Modify: `server/internal/services/servers.go` (`CreateServer`), `secrets.go` (`UpsertSecrets`), `channels.go` (`CreateChannel`)
-
-**Interfaces:**
-
-- Consumes: `GetLicenseState`, `license.WithinLimit`
-- Produces:
- - `type LimitError struct { Limit string; Current, Max int }` with `Error() string`
- - `func CheckServerLimit(instanceID string) error`
- - `func CheckSecretGroupLimit(instanceID, group string) error`
- - `func CheckChannelLimit(instanceID string) error`
- - `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)`
-
-- [ ] **Step 1: Write the limit checks**
-
-Create `server/internal/services/licence_limits.go`:
-
-```go
-package services
-
-import (
- "context"
- "fmt"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// LimitError is returned when a licence cap would be exceeded. The API maps it
-// to 403 with a machine-readable body.
-type LimitError struct {
- Limit string
- Current int
- Max int
-}
-
-func (e *LimitError) Error() string {
- return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max)
-}
-
-func limitCtx() (context.Context, context.CancelFunc) {
- return context.WithTimeout(context.Background(), 5*time.Second)
-}
-
-// CheckServerLimit refuses a new server when the instance is at its cap.
-//
-// Counts live rows only. An instance already over its cap keeps every server it
-// has — nothing is truncated — it simply cannot add another.
-func CheckServerLimit(instanceID string) error {
- st := GetLicenseState(instanceID)
- ctx, cancel := limitCtx()
- defer cancel()
-
- n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID})
- if err != nil {
- return err
- }
- if !license.WithinLimit(int(n), st.Limits.MaxServers) {
- return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers}
- }
- return nil
-}
-
-// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that
-// already exists is always allowed, so a capped customer can still rotate the
-// secrets they have.
-func CheckSecretGroupLimit(instanceID, group string) error {
- st := GetLicenseState(instanceID)
- ctx, cancel := limitCtx()
- defer cancel()
-
- existing, err := db.Col("secrets").CountDocuments(ctx,
- bson.M{"instance_id": instanceID, "group": group})
- if err != nil {
- return err
- }
- if existing > 0 {
- return nil
- }
-
- var groups []string
- if err := db.Col("secrets").Distinct(ctx, "group",
- bson.M{"instance_id": instanceID}).Decode(&groups); err != nil {
- return err
- }
- if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) {
- return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups}
- }
- return nil
-}
-
-// CheckChannelLimit refuses a new notification channel at the cap.
-func CheckChannelLimit(instanceID string) error {
- st := GetLicenseState(instanceID)
- ctx, cancel := limitCtx()
- defer cancel()
-
- n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID})
- if err != nil {
- return err
- }
- if !license.WithinLimit(int(n), st.Limits.MaxChannels) {
- return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels}
- }
- return nil
-}
-
-// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
-// honestly when an instance is over its cap rather than pretending.
-func LicenseUsage(instanceID string) (servers, secretGroups, channels int) {
- ctx, cancel := limitCtx()
- defer cancel()
-
- if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
- servers = int(n)
- }
- var groups []string
- if err := db.Col("secrets").Distinct(ctx, "group",
- bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
- secretGroups = len(groups)
- }
- if n, err := db.Col("notification_channels").CountDocuments(ctx,
- bson.M{"instance_id": instanceID}); err == nil {
- channels = int(n)
- }
- return
-}
-```
-
-- [ ] **Step 2: Enforce in CreateServer**
-
-In `server/internal/services/servers.go`, at the top of `CreateServer`:
-
-```go
-func CreateServer(instanceID string) (*models.Server, string, error) {
- if err := CheckServerLimit(instanceID); err != nil {
- return nil, "", err
- }
- // ... existing body unchanged
-```
-
-- [ ] **Step 3: Enforce in UpsertSecrets**
-
-In `server/internal/services/secrets.go`, at the top of `UpsertSecrets`:
-
-```go
-func UpsertSecrets(instanceID, group string, values map[string]string) error {
- if err := CheckSecretGroupLimit(instanceID, group); err != nil {
- return err
- }
- // ... existing body unchanged
-```
-
-- [ ] **Step 4: Enforce in CreateChannel**
-
-In `server/internal/services/channels.go`, at the top of `CreateChannel`:
-
-```go
-func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
- if err := CheckChannelLimit(instanceID); err != nil {
- return nil, err
- }
- // ... existing body unchanged
-```
-
-- [ ] **Step 5: Build**
-
-Run: `cd server && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add server/internal/services/
-git commit -m "feat(server): enforce licence limits on servers, secret groups and channels"
-```
-
----
-
-### Task 5: The API and the two middlewares
-
-**Files:**
-
-- Create: `server/internal/api/licence.go`
-- Modify: `server/internal/api/handlers.go`
-
-**Interfaces:**
-
-- Consumes: `GetLicenseState`, `StoreLicense`, `LicenseUsage`, `LimitError`
-- Produces:
- - `func RequireActiveLicense() gin.HandlerFunc`
- - `func RequireFeature(name string) gin.HandlerFunc`
- - `GET /api/license`, `POST /api/license`
-
-- [ ] **Step 1: Write the handlers and middlewares**
-
-Create `server/internal/api/licence.go`:
-
-```go
-package api
-
-import (
- "errors"
- "net/http"
- "time"
-
- "github.com/gin-gonic/gin"
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
-)
-
-// licenceExemptPaths are routes that must work while a licence is expired or
-// missing, because they are how a customer recovers or stays safe.
-//
-// /api/license pasting a valid licence is the way out of degraded mode
-// apply-updates security patching is never paywalled
-//
-// All DELETE requests are exempt separately (see RequireActiveLicense): a
-// customer downgraded below their current usage must be able to delete their
-// way back under the cap.
-var licenceExemptPaths = map[string]bool{
- "/api/license": true,
-}
-
-func licenceExempt(c *gin.Context) bool {
- if c.Request.Method == http.MethodDelete {
- return true
- }
- if licenceExemptPaths[c.FullPath()] {
- return true
- }
- if c.FullPath() == "/api/servers/:id/apply-updates" {
- return true
- }
- return false
-}
-
-// RequireActiveLicense blocks mutating requests when the licence is not valid.
-//
-// Mounted on the /api group, so a route added tomorrow is gated because of where
-// it lives rather than because someone remembered. GET and HEAD always pass —
-// reading is never blocked.
-func RequireActiveLicense() gin.HandlerFunc {
- return func(c *gin.Context) {
- if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead {
- c.Next()
- return
- }
- if licenceExempt(c) {
- c.Next()
- return
- }
-
- st := services.GetLicenseState(auth.InstanceID(c))
- if st.Active() {
- c.Next()
- return
- }
-
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
- "error": "license_required",
- "state": st.Status,
- "reason": st.Reason,
- })
- }
-}
-
-// RequireFeature blocks a route when the licence does not grant a feature.
-func RequireFeature(name string) gin.HandlerFunc {
- return func(c *gin.Context) {
- st := services.GetLicenseState(auth.InstanceID(c))
- if st.Feature(name) {
- c.Next()
- return
- }
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
- "error": "feature_unavailable",
- "feature": name,
- })
- }
-}
-
-type licenceResponse struct {
- InstanceID string `json:"instance_id"`
- State license.State `json:"state"`
- Reason string `json:"reason,omitempty"`
- Tier string `json:"tier,omitempty"`
- ExpiresAt *time.Time `json:"expires_at,omitempty"`
- DaysRemaining *int `json:"days_remaining,omitempty"`
- Limits license.Limits `json:"limits"`
- Features map[string]bool `json:"features"`
- Usage licenceUsageResponse `json:"usage"`
- Source string `json:"source"`
-}
-
-type licenceUsageResponse struct {
- Servers int `json:"servers"`
- SecretGroups int `json:"secret_groups"`
- Channels int `json:"channels"`
-}
-
-func getLicence(c *gin.Context) {
- instanceID := auth.InstanceID(c)
- st := services.GetLicenseState(instanceID)
- servers, groups, channels := services.LicenseUsage(instanceID)
-
- resp := licenceResponse{
- InstanceID: instanceID,
- State: st.Status,
- Reason: st.Reason,
- Tier: st.Tier,
- ExpiresAt: st.ExpiresAt,
- Limits: st.Limits,
- Features: st.Features,
- Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels},
- Source: st.Source,
- }
- if st.ExpiresAt != nil {
- d := int(time.Until(*st.ExpiresAt).Hours() / 24)
- resp.DaysRemaining = &d
- }
- c.JSON(http.StatusOK, resp)
-}
-
-func postLicence(c *gin.Context) {
- var body struct {
- Blob string `json:"blob"`
- }
- if err := c.ShouldBindJSON(&body); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
- return
- }
-
- instanceID := auth.InstanceID(c)
- st, err := services.StoreLicense(instanceID, body.Blob)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": licenceRejectionMessage(err.Error(), instanceID),
- "reason": err.Error(),
- })
- return
- }
-
- services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
- "licence accepted (tier "+st.Tier+")")
- c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
-}
-
-// licenceRejectionMessage turns a machine reason into something a person can act
-// on. The instance ID is included in the mismatch case because that is the one
-// piece of information the customer needs and cannot guess.
-func licenceRejectionMessage(reason, instanceID string) string {
- switch reason {
- case license.ReasonBadSignature:
- return "This licence key is not valid. Check it was copied in full."
- case license.ReasonDeploymentMismatch:
- return "This licence is for Vantage Cloud and cannot be used on a self-hosted install."
- case license.ReasonInstanceMismatch:
- return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
- case license.ReasonNoLicense:
- return "No licence key was provided."
- default:
- return "This licence could not be accepted."
- }
-}
-
-// limitStatus maps a LimitError to a 403 body. Handlers that create countable
-// resources call this so the UI gets a machine-readable limit name.
-func limitStatus(c *gin.Context, err error) bool {
- var le *services.LimitError
- if !errors.As(err, &le) {
- return false
- }
- c.JSON(http.StatusForbidden, gin.H{
- "error": "limit_exceeded",
- "limit": le.Limit,
- "current": le.Current,
- "max": le.Max,
- })
- return true
-}
-
-var _ = models.RoleOwner // keep the models import honest if unused elsewhere
-```
-
-Remove the trailing `var _ = models.RoleOwner` line and the `models` import if `go build` reports them unused.
-
-- [ ] **Step 2: Mount the middlewares**
-
-In `server/internal/api/handlers.go`, change the `/api` group and add the two routes:
-
-```go
- apiGroup := r.Group("/api")
- apiGroup.Use(auth.Middleware())
- // Deny by default: every non-GET route under /api is gated unless it is on
- // the exemption list in licence.go. A route added later is covered because
- // of where it is mounted, not because someone remembered.
- apiGroup.Use(RequireActiveLicense())
- {
- apiGroup.GET("/license", getLicence)
- apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
-```
-
-Then gate the console:
-
-```go
- apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
- apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
-```
-
-And OIDC settings:
-
-```go
- instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC)
- instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC)
-```
-
-- [ ] **Step 3: Return limit errors properly**
-
-In `server/internal/api/handlers.go`, in `createServer` and `newServer`, replace the error branch:
-
-```go
- s, token, err := services.CreateServer(auth.InstanceID(c))
- if err != nil {
- if limitStatus(c, err) {
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-```
-
-Apply the same `if limitStatus(c, err) { return }` guard in the secrets handler that calls `services.UpsertSecrets` (`createSecretGroup` and `putSecretGroup` in `server/internal/api/secrets.go`) and in the channel handler that calls `services.CreateChannel` (`server/internal/api/channels.go`).
-
-- [ ] **Step 4: Gate the unauthenticated OIDC entry points**
-
-`/auth/oidc/start` and `/auth/oidc/callback` sit outside `/api` and have no session, so they check the feature directly against the instance resolved from the host.
-
-In `server/internal/auth/oidc.go`, at the top of `HandleOIDCStart`, after the instance is resolved:
-
-```go
- if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
- c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
- return
- }
-```
-
-**Do not add this to the session-validation path.** Losing the feature stops new SSO logins; it must not evict people mid-session.
-
-- [ ] **Step 5: Build**
-
-Run: `cd server && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 6: Rate-limit the licence endpoint**
-
-There is no oracle here worth protecting — the blob is signed public data — but an
-unbounded verify endpoint is an unbounded CPU endpoint, and signature verification
-is not free.
-
-Add to `server/internal/api/licence.go`:
-
-```go
-var (
- licencePostMu sync.Mutex
- licencePostCounts = map[string][]time.Time{}
-)
-
-const licencePostLimit = 10
-
-// licencePostAllowed permits 10 attempts per instance per hour.
-func licencePostAllowed(instanceID string) bool {
- cutoff := time.Now().Add(-time.Hour)
-
- licencePostMu.Lock()
- defer licencePostMu.Unlock()
-
- kept := licencePostCounts[instanceID][:0]
- for _, t := range licencePostCounts[instanceID] {
- if t.After(cutoff) {
- kept = append(kept, t)
- }
- }
- if len(kept) >= licencePostLimit {
- licencePostCounts[instanceID] = kept
- return false
- }
- licencePostCounts[instanceID] = append(kept, time.Now())
- return true
-}
-```
-
-Add `"sync"` to the imports, and guard the handler at the top of `postLicence`:
-
-```go
- instanceID := auth.InstanceID(c)
- if !licencePostAllowed(instanceID) {
- c.JSON(http.StatusTooManyRequests, gin.H{
- "error": "Too many licence attempts. Try again later.",
- })
- return
- }
-```
-
-Move the existing `instanceID := auth.InstanceID(c)` line so it is not declared twice.
-
-- [ ] **Step 7: Audit the route coverage by hand**
-
-With no test suite, this is the check that stops layer 1 rotting.
-
-```bash
-cd c:/Work/Repos/vantage
-grep -n "apiGroup\.\(POST\|PUT\|DELETE\|PATCH\)\|Group(\"" server/internal/api/handlers.go
-grep -rn "\.\(POST\|PUT\|DELETE\|PATCH\)(" server/internal/api/workflows.go server/internal/api/monitors.go server/internal/api/channels.go
-```
-
-For every non-`GET` route listed, confirm it is either under `apiGroup` (and therefore gated) or deliberately exempt. Record the exempt list in a comment above `licenceExemptPaths` if it grows.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add server/internal/api/ server/internal/auth/oidc.go
-git commit -m "feat(server): gate mutations and features on the licence"
-```
-
----
-
-### Task 6: Degraded background behaviour
-
-The paths that do not go through gin. This is where "read-only" has to be specific.
-
-**Files:**
-
-- Modify: `server/internal/monitorsched/scheduler.go` (comment only)
-- Modify: `server/internal/grpc/server.go` (`Register`)
-
-**Interfaces:**
-
-- Consumes: `services.GetLicenseState`, `services.CheckServerLimit`
-- Produces: no new API
-
-- [ ] **Step 1: Leave the monitor scheduler alone, and say why**
-
-Add above the loop body in `server/internal/monitorsched/scheduler.go`:
-
-```go
-// Monitors run regardless of licence state, deliberately.
-//
-// A customer whose card failed must not lose the ability to know their
-// infrastructure is on fire. Creating and editing monitors is blocked by the
-// API gate; executing the ones that already exist is not.
-```
-
-Make no functional change here. The absence of a check is the feature.
-
-- [ ] **Step 2: Leave the workflow runner alone too, and say why**
-
-Add above the step-dispatch loop in `server/internal/services/workflow_runner.go`:
-
-```go
-// A run already in flight when the licence expires finishes its remaining
-// steps. New runs are blocked at the API, but killing a workflow midway leaves
-// a server in a half-configured state, which is worse than letting it complete.
-```
-
-Again, no functional change — the runner must not gain a licence check.
-
-- [ ] **Step 3: Refuse NEW agent registrations against an over-limit instance**
-
-In `server/internal/grpc/server.go`, inside `Register`, after the pre-registration token is validated and the instance is known, before the server is marked active:
-
-```go
- // An existing agent re-registering is always allowed — its server row already
- // exists, so the limit check passes. This only stops a NEW server being
- // added past the cap by going around the API.
- if err := services.CheckServerLimit(instanceID); err != nil {
- return nil, status.Errorf(codes.FailedPrecondition,
- "this Vantage instance has reached its licenced server limit; "+
- "remove a server or upgrade, then retry")
- }
-```
-
-Add `"google.golang.org/grpc/codes"` and `"google.golang.org/grpc/status"` to the imports if not already present.
-
-- [ ] **Step 4: Build**
-
-Run: `cd server && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add server/internal/monitorsched/ server/internal/grpc/
-git commit -m "feat(server): keep monitors running when a licence lapses
-
-Blocks new agent registration past the server cap. Monitor execution is
-deliberately unguarded: billing state must not take away a customer's
-ability to know their infrastructure is on fire."
-```
-
----
-
-### Task 7: Show the instance ID at setup
-
-A self-hosted operator needs the UUID to activate a licence, and `/setup` is where they first need it.
-
-**Files:**
-
-- Modify: `server/internal/auth/local.go` (`HandleBootstrap` response)
-- Modify: `web/app/setup/page.tsx`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces: `POST /auth/bootstrap` response gains `instance_id`
-
-- [ ] **Step 1: Return the instance ID from bootstrap**
-
-In `server/internal/auth/local.go`, in the bootstrap success response, change:
-
-```go
- c.JSON(http.StatusCreated, gin.H{
- "instance": inst,
- "slug": inst.Slug,
- "instance_id": inst.InstanceID,
- })
-```
-
-- [ ] **Step 2: Show it on the setup confirmation**
-
-In `web/app/setup/page.tsx`, in the "Instance created" block, add below the slug line:
-
-```tsx
-
-
Instance ID — needed to activate a licence
-
- {created.instance_id}
-
-
-
-```
-
-Add `instance_id: string;` to the bootstrap response type in `web/lib/api.ts`.
-
-- [ ] **Step 3: Build**
-
-```bash
-cd c:/Work/Repos/vantage/server && go build ./...
-cd ../web && npx tsc --noEmit
-```
-
-Expected: no output from either.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add server/internal/auth/local.go web/app/setup/page.tsx web/lib/api.ts
-git commit -m "feat: show the instance ID after setup"
-```
-
----
-
-### Task 8: Frontend licence state
-
-**Files:**
-
-- Create: `web/lib/useLicense.ts`, `web/components/LicenseBanner.tsx`, `web/app/(app)/settings/license/page.tsx`
-- Modify: `web/lib/api.ts`, `web/app/(app)/layout.tsx`, `web/components/Sidebar.tsx`
-
-**Interfaces:**
-
-- Consumes: `GET /api/license`, `POST /api/license`
-- Produces: `useLicense()`, ``
-
-- [ ] **Step 1: Add the API client methods and types**
-
-In `web/lib/api.ts`:
-
-```ts
-export type LicenseState = "valid" | "expired" | "invalid";
-
-export interface LicenseInfo {
- instance_id: string;
- state: LicenseState;
- reason?: string;
- tier?: string;
- expires_at?: string;
- days_remaining?: number;
- limits: { max_servers: number; max_secret_groups: number; max_channels: number };
- features: Record;
- usage: { servers: number; secret_groups: number; channels: number };
- source: string;
-}
-
-export const licence = {
- get(): Promise {
- return request("/api/license");
- },
- put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> {
- return request("/api/license", { method: "POST", body: JSON.stringify({ blob }) });
- },
-};
-```
-
-Match `request` to whatever the existing client helper is called in that file; the other methods there show the pattern.
-
-- [ ] **Step 2: Write the hook**
-
-Create `web/lib/useLicense.ts`:
-
-```ts
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { licence, type LicenseInfo } from "@/lib/api";
-
-export function useLicense() {
- const { data, isLoading } = useQuery({
- queryKey: ["license"],
- queryFn: licence.get,
- staleTime: 60_000,
- });
-
- return {
- license: data,
- isLoading,
- isActive: data?.state === "valid",
- // Features render disabled rather than hidden, so treat "unknown while
- // loading" as available to avoid a flash of disabled controls.
- hasFeature: (name: string) => (data ? Boolean(data.features?.[name]) : true),
- };
-}
-```
-
-- [ ] **Step 3: Write the banner**
-
-Create `web/components/LicenseBanner.tsx`:
-
-```tsx
-"use client";
-
-import Link from "next/link";
-import { useLicense } from "@/lib/useLicense";
-
-export function LicenseBanner() {
- const { license } = useLicense();
- if (!license) return null;
-
- if (license.state === "expired") {
- const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently";
- return (
-
- Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "}
-
- Add a licence
-
-
Single sign-on: {license.features.oidc ? "Included" : "Not included"}
-
-
-
-
-
Add or replace a licence
-
-
- );
-}
-```
-
-- [ ] **Step 6: Add the sidebar entry and gate the console link**
-
-In `web/components/Sidebar.tsx`, add a `/settings/license` entry labelled "Licence". Where the console link is rendered on the server detail page, render it disabled with `title="Upgrade to use the browser console"` when `hasFeature("console")` is false — **disabled, not hidden.** A customer cannot buy what they cannot see, and a feature that vanishes reads as a bug.
-
-- [ ] **Step 7: Build**
-
-```bash
-cd c:/Work/Repos/vantage/web
-rm -rf .next && npm run build
-```
-
-Expected: build succeeds, `/settings/license` appears in the route list.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add web/
-git commit -m "feat(web): licence banner, settings page and feature gating"
-```
-
----
-
-### Task 9: Grandfather existing cloud instances
-
-Migration `0005`, cloud only. It cannot sign — the server holds no private key and, per plan 1, no signing code — so the blobs are cut ahead of time with `lkctl` and handed in through the environment.
-
-Clumsy, and correct. The alternative is putting a signing key in the control plane, which is the thing this design most wants to avoid.
-
-**Files:**
-
-- Create: `server/internal/services/migrate_licence.go`
-- Modify: `server/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: `license.Verify`, `DeploymentMode`
-- Produces: `func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error`
-
-- [ ] **Step 1: Write the migration**
-
-Create `server/internal/services/migrate_licence.go`:
-
-```go
-package services
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log"
- "os"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
-)
-
-// MigrateGrandfatherLicences stores pre-issued licences on instances that have
-// none. Cloud only.
-//
-// The blobs are supplied through VANTAGE_GRANDFATHER_BLOBS, a JSON object
-// mapping instance_id to licence blob, because this process cannot sign: it
-// holds no private key and the signing code is compiled out. Cut the blobs
-// beforehand with lkctl.
-//
-// The variable is single-use. Unset it on the next deploy.
-func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error {
- const marker = "0005_grandfather_licences"
-
- if n, _ := db.Collection("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
- return nil
- }
- if DeploymentMode() != license.DeploymentCloud {
- log.Printf("0005: not a cloud deployment, skipping")
- return nil
- }
-
- raw := os.Getenv("VANTAGE_GRANDFATHER_BLOBS")
- if raw == "" {
- log.Printf("0005: VANTAGE_GRANDFATHER_BLOBS not set, skipping (no marker recorded)")
- return nil
- }
-
- var blobs map[string]string
- if err := json.Unmarshal([]byte(raw), &blobs); err != nil {
- return fmt.Errorf("0005: VANTAGE_GRANDFATHER_BLOBS is not valid JSON: %w", err)
- }
-
- cur, err := db.Collection("instances").Find(ctx, bson.M{
- "$or": []bson.M{
- {"license_blob": bson.M{"$exists": false}},
- {"license_blob": ""},
- },
- })
- if err != nil {
- return fmt.Errorf("0005: list instances: %w", err)
- }
- var instances []models.Instance
- if err := cur.All(ctx, &instances); err != nil {
- return fmt.Errorf("0005: decode instances: %w", err)
- }
-
- var stored, missing int
- for _, inst := range instances {
- blob, ok := blobs[inst.InstanceID]
- if !ok || blob == "" {
- log.Printf("0005: no blob supplied for instance %s (%s)", inst.InstanceID, inst.Slug)
- missing++
- continue
- }
-
- res := license.Verify(blob, license.VerifyOpts{
- InstanceID: inst.InstanceID,
- Deployment: license.DeploymentCloud,
- })
- if res.State == license.StateInvalid {
- return fmt.Errorf("0005: blob for instance %s is rejected: %s", inst.InstanceID, res.Reason)
- }
-
- if _, err := db.Collection("instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID},
- bson.M{"$set": bson.M{
- "license_blob": blob,
- "license_tier": res.License.Tier,
- "license_expiry": res.License.ExpiresAt,
- }}); err != nil {
- return fmt.Errorf("0005: store blob for %s: %w", inst.InstanceID, err)
- }
- log.Printf("0005: stored %s licence for instance %s (%s), expires %s",
- res.License.Tier, inst.InstanceID, inst.Slug,
- res.License.ExpiresAt.Format(time.RFC3339))
- stored++
- }
-
- if missing > 0 {
- return fmt.Errorf("0005: %d instance(s) had no blob supplied; issue them with lkctl and rerun", missing)
- }
-
- _, err = db.Collection("migrations").InsertOne(ctx,
- bson.M{"_id": marker, "applied_at": time.Now()})
- log.Printf("0005: grandfathered %d instance(s)", stored)
- return err
-}
-```
-
-Refusing to record the marker when a blob is missing is deliberate: a half-grandfathered fleet must be fixed and rerun, not silently accepted.
-
-- [ ] **Step 2: Call it at boot**
-
-In `server/cmd/main.go`, after the `AssertNoScopedCollectionMissed` block and before `EnsureAuthIndexes`:
-
-```go
- gfCtx, gfCancel := context.WithTimeout(context.Background(), 2*time.Minute)
- gfErr := services.MigrateGrandfatherLicences(gfCtx, db.Database)
- gfCancel()
- if gfErr != nil {
- log.Fatalf("licence grandfather migration failed: %v", gfErr)
- }
-```
-
-- [ ] **Step 3: Build**
-
-Run: `cd server && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add server/internal/services/migrate_licence.go server/cmd/main.go
-git commit -m "feat(server): grandfather existing cloud instances onto Professional"
-```
-
----
-
-### Task 10: Full verification
-
-Everything runs in containers. With no test suite this is the only evidence.
-
-**Files:** none
-
-- [ ] **Step 1: Build everything**
-
-```bash
-cd c:/Work/Repos/vantage
-(cd shared && go build ./... && go vet ./...)
-(cd server && go build ./... && go vet ./...)
-(cd sitesvc && go build ./... && go vet ./...)
-(cd agent && GOWORK=off go build ./... && GOWORK=off go vet ./...)
-(cd web && rm -rf .next && npm run build)
-docker build -q -f server/Dockerfile -t vantage-server:lic .
-```
-
-Expected: all succeed.
-
-- [ ] **Step 2: Boot against a scratch database and bootstrap**
-
-```bash
-cd c:/Work/Repos/vantage
-docker run --rm -d --name vlic -p 8080:8080 \
- -e MONGO_URI=mongodb://host.docker.internal:27021 -e MONGO_DB=vantage_lic \
- -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6379 \
- -e VANTAGE_DEPLOYMENT=self_hosted \
- -e KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 \
- --add-host host.docker.internal:host-gateway vantage-server:lic
-
-sleep 12
-curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json' \
- -d '{"instance_name":"Licence Test","email":"owner@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: JSON including `instance_id`. **Record that UUID** — every step below uses it.
-
-- [ ] **Step 3: Confirm the unlicensed instance is read-only**
-
-```bash
-curl -s -X POST localhost:8080/auth/login -H 'Content-Type: application/json' \
- -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/lic.txt
-curl -s localhost:8080/api/license -b /tmp/lic.txt
-curl -s -o /dev/null -w "create server: %{http_code}\n" \
- -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
-curl -s -o /dev/null -w "list servers: %{http_code}\n" \
- localhost:8080/api/servers -b /tmp/lic.txt
-```
-
-Expected: `/api/license` reports `"state":"invalid"`, `"reason":"no_license"`. Creating a server returns **403**. Listing servers returns **200** — reading is never blocked.
-
-- [ ] **Step 4: Issue a Self Hosted licence and paste it**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-export LICENSE_SIGNING_KEY=''
-go run ./cmd/lkctl issue --instance-id= \
- --instance-name="Licence Test" --tier=self_hosted --term=1y > /tmp/sh.lic
-
-BLOB=$(cat /tmp/sh.lic)
-curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
- -H 'Content-Type: application/json' -d "{\"blob\":\"$BLOB\"}"
-```
-
-Expected: `{"state":"valid","tier":"self_hosted",...}`.
-
-- [ ] **Step 5: Confirm mutations now work, within a minute and without a restart**
-
-```bash
-curl -s -o /dev/null -w "create server: %{http_code}\n" \
- -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
-curl -s localhost:8080/api/license -b /tmp/lic.txt
-```
-
-Expected: **201**, and the licence reports `valid`, tier `self_hosted`, `max_servers: -1`, both features true.
-
-- [ ] **Step 6: Confirm each rejection message**
-
-```bash
-# Wrong instance
-cd c:/Work/Repos/vantage/shared
-go run ./cmd/lkctl issue --instance-id=99999999-9999-9999-9999-999999999999 \
- --tier=self_hosted --term=1y > /tmp/other.lic
-curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
- -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/other.lic)\"}"
-
-# Cloud-only Free licence on a self-hosted install
-go run ./cmd/lkctl issue --instance-id= --tier=free --term=1m > /tmp/free.lic
-curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
- -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/free.lic)\"}"
-
-# Garbage
-curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
- -H 'Content-Type: application/json' -d '{"blob":"NOTALICENCE"}'
-```
-
-Expected, in order:
-
-1. `"This licence was issued for a different instance. Your instance ID is ."`
-2. `"This licence is for Vantage Cloud and cannot be used on a self-hosted install."` — **this is the check that makes Free cloud-only**
-3. `"This licence key is not valid. Check it was copied in full."`
-
-And after all three, `GET /api/license` still reports `valid` — a rejected blob never replaces a good one.
-
-- [ ] **Step 7: Confirm expiry degrades correctly, and that monitors keep running**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go run ./cmd/lkctl issue --instance-id= --tier=self_hosted \
- --expires=$(date -u -d '+70 seconds' +%Y-%m-%dT%H:%M:%SZ) > /tmp/soon.lic
-curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
- -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/soon.lic)\"}"
-```
-
-Create a monitor while it is still valid, then wait past expiry plus the 60-second cache and check:
-
-```bash
-sleep 140
-curl -s localhost:8080/api/license -b /tmp/lic.txt
-curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
-curl -s -o /dev/null -w "list servers: %{http_code}\n" localhost:8080/api/servers -b /tmp/lic.txt
-curl -s -o /dev/null -w "delete a server: %{http_code}\n" -X DELETE localhost:8080/api/servers/ -b /tmp/lic.txt
-docker logs vlic 2>&1 | grep -ci "monitor" || true
-```
-
-Expected: state `expired`; create **403**; list **200**; delete **2xx** (deletes are always allowed); and the monitor scheduler still logging activity. **Monitoring must not have gone dark.**
-
-- [ ] **Step 8: Confirm recovery without a restart**
-
-```bash
-curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
- -H 'Content-Type: application/json' -d "{\"blob\":\"$(cat /tmp/sh.lic)\"}"
-curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/api/servers/new -b /tmp/lic.txt
-```
-
-Expected: **201**. Pasting a valid licence restores normal operation immediately.
-
-- [ ] **Step 9: Confirm the Free tier caps, on a cloud instance**
-
-Restart the container with `-e VANTAGE_DEPLOYMENT=cloud` against a fresh database, bootstrap, issue a Free licence for the new instance ID, paste it, then create servers until refused.
-
-Expected: servers 1, 2 and 3 succeed; the 4th returns **403** with
-`{"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
-Then delete one and confirm creating again succeeds — the customer is never trapped.
-
-Also confirm `POST /api/console/connect` returns **403** with
-`{"error":"feature_unavailable","feature":"console"}`.
-
-- [ ] **Step 10: Confirm apply-updates is never blocked**
-
-With the licence expired, call `POST /api/servers//apply-updates`.
-
-Expected: **not** a `license_required` 403. Security patching is exempt by design.
-
-- [ ] **Step 11: Clean up**
-
-```bash
-docker rm -f vlic
-docker exec mongo-h mongosh --quiet --eval 'db.getSiblingDB("vantage_lic").dropDatabase()'
-unset LICENSE_SIGNING_KEY
-rm -f /tmp/sh.lic /tmp/other.lic /tmp/free.lic /tmp/soon.lic /tmp/lic.txt
-```
-
-- [ ] **Step 12: Commit**
-
-```bash
-git add -A
-git commit -m "chore: verify instance licensing end to end" --allow-empty
-```
-
-## Rollout
-
-**Cloud:**
-
-1. Issue a Professional licence for every existing instance with `lkctl`, one year out.
-2. Build the JSON map and deploy once with `VANTAGE_GRANDFATHER_BLOBS` set and `VANTAGE_DEPLOYMENT=cloud`.
-3. Confirm every instance reports `valid` before the traffic switch.
-4. Unset the variable on the next deploy — it is single-use.
-
-**Self-hosted:** the release notes must lead with the fact that upgrading now requires a licence key, where to get one, and that the instance ID is shown at `Settings → Licence`. Without a licence the instance is read-only on first boot — monitors keep running, but nothing can be changed.
-
-## Risks
-
-| Risk | Mitigation |
-| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
-| A mutating route added later without a gate | Mounted on the `/api` group, so new routes are covered by default; Task 5 Step 6 audits the exceptions |
-| Customer locked out and unable to recover | `POST /api/license` and every `DELETE` are exempt |
-| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch; it refuses to record its marker if any instance was missed |
-| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
-| Monitoring lost on billing failure | Designed out — the scheduler has no licence check, and Task 10 Step 7 verifies it |
-| Clock wrong on a self-hosted host | `Verify` reports `ClockSkewed`; surface it in the settings page if it becomes a support theme |
diff --git a/docs/superpowers/plans/2026-07-24-instance-rename.md b/docs/superpowers/plans/2026-07-24-instance-rename.md
deleted file mode 100644
index 9716dfb..0000000
--- a/docs/superpowers/plans/2026-07-24-instance-rename.md
+++ /dev/null
@@ -1,1473 +0,0 @@
-# Org to Instance Rename 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:** Rename the tenant entity from `Org` to `Instance` everywhere — Go types, collections, the `org_id` field on every document, REST routes and UI copy — with a migration that only ever renames and never deletes.
-
-**Architecture:** A code rename driven from the `shared` module outward, plus migration `0004_org_to_instance` that renames two collections and `$rename`s one field across every scoped collection. The migration verifies document counts before recording its marker, so a partial run retries rather than half-completing. sitesvc refuses to boot against an unmigrated database.
-
-**Tech Stack:** Go 1.26, MongoDB driver v2, Next.js 16, Docker.
-
-**No automated tests.** This moves the tenant isolation key across 17 collections. With no test suite, the production-snapshot rehearsal in Task 8 is not optional and not a formality — it is the only evidence that no tenant's data was orphaned. Do not deploy without completing every step of it, including the rollback rehearsal.
-
-## Global Constraints
-
-- **Depends on plan 0a being merged and deployed.** Do not start otherwise; this plan assumes one definition of `Org`, `User` and `Settings` in `shared`.
-- Take a database backup before deploying. The inverse rename is the first recovery path; the backup is the second.
-- **The migration renames. It never deletes, drops or unsets** — the one exception is dropping indexes keyed on the old field name, which touches no documents.
-- No behaviour changes. Same permissions, same responses, same data.
-- The `agent` module and `agent-release.yml` are not touched by any task.
-- REST route renames are breaking and ship in the same release as the frontend. No compatibility aliases.
-- After Task 4, `grep -rn '"org_id"' shared/ server/ sitesvc/` must return hits **only** in `migrate_instance.go`.
-
-## The naming map
-
-Apply exactly. Every task references this table.
-
-| Today | After |
-| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
-| collection `orgs` | `instances` |
-| collection `org_oidc` | `instance_oidc` |
-| field `org_id` | `instance_id` |
-| `models.Org` | `models.Instance` |
-| `Org.OrgID` | `Instance.InstanceID` |
-| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
-| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
-| `services/orgs.go` | `services/instances.go` |
-| `GetOrg`, `GetOrgBySlug`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg` | `GetInstance`, `GetInstanceBySlug`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance` |
-| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` |
-| `services/org_oidc.go` | `services/instance_oidc.go` |
-| `auth/orghost.go` | `auth/instancehost.go` |
-| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
-| session field `org_id` | `instance_id` |
-| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` |
-| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` |
-| UI copy "Organisation"/"Organization" (tenant) | "Instance" |
-| UI copy "Organisation" (customer, marketing site) | "Account" |
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| ---------------------------------------------- | -------------------------------------------------------- |
-| `shared/models/instance.go` | `Instance` (replaces `org.go`) |
-| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) |
-| `server/internal/services/instances.go` | replaces `orgs.go` |
-| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` |
-| `server/internal/auth/instancehost.go` | replaces `orghost.go` |
-| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion |
-| `server/cmd/rename-rollback/main.go` | one-shot inverse rename |
-
-**Modified:** `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`, every file in `server/internal/` referencing a renamed symbol, `server/cmd/main.go`, `sitesvc/internal/{models,store,api}`, `sitesvc/cmd/main.go`, `web/` and `site/` sources.
-
----
-
-### Task 1: Rename in the shared module
-
-Everything downstream depends on these names, so they change first.
-
-**Files:**
-
-- Rename: `shared/models/org.go` → `shared/models/instance.go`
-- Rename: `shared/provision/org.go` → `shared/provision/instance.go`
-- Modify: `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`
-
-**Interfaces:**
-
-- Consumes: the plan 0a API
-- Produces:
- - `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"`
- - `models.User.InstanceID`, `models.Settings.InstanceID`
- - `provision.CreateInstance(ctx, db, name) (*models.Instance, error)`
- - `provision.RollbackInstance(ctx, db, instanceID) error`
- - `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged
- - `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)`
- - `indexes.EnsureCoreIndexes` — now indexes `instances.slug`
-
-- [ ] **Step 1: Rename the Instance model**
-
-```bash
-cd c:/Work/Repos/vantage
-git mv shared/models/org.go shared/models/instance.go
-```
-
-Replace its contents with:
-
-```go
-// Package models holds the MongoDB documents written by more than one Vantage
-// service.
-package models
-
-import (
- "time"
-
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// Instance is one deployment of Vantage: its own subdomain, users, servers,
-// keys, workflows, monitors and secrets. It is the unit a licence attaches to.
-//
-// A paying customer may hold several. That grouping is called an Account and
-// lives only in the admin control plane — this service never sees it.
-type Instance struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
- InstanceID string `bson:"instance_id" json:"instance_id"`
- Name string `bson:"name" json:"name"`
- Slug string `bson:"slug" json:"slug"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
-}
-```
-
-- [ ] **Step 2: Rename the fields on User and Settings**
-
-In `shared/models/user.go`, change the one field:
-
-```go
- InstanceID string `bson:"instance_id" json:"instance_id"`
-```
-
-In `shared/models/settings.go`, change the one field:
-
-```go
- InstanceID string `bson:"instance_id" json:"instance_id"`
-```
-
-- [ ] **Step 3: Rename the provisioning functions**
-
-```bash
-cd c:/Work/Repos/vantage
-git mv shared/provision/org.go shared/provision/instance.go
-```
-
-Replace its contents with:
-
-```go
-package provision
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/google/uuid"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
-)
-
-// ErrNameRejected wraps every reason a name cannot become an instance.
-var ErrNameRejected = errors.New("organisation name rejected")
-
-const maxSlugAttempts = 50
-
-// CreateInstance inserts an instance under the first free slug derived from name.
-//
-// The count-then-insert loop is racy on its own. It is safe only because
-// instances.slug carries a unique index: a lost race surfaces as a duplicate-key
-// error, which we treat as "that slug is taken" and retry. Do not remove the
-// duplicate-key branch, and do not remove the index.
-func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) {
- base, err := BaseSlug(name)
- if err != nil {
- return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
- }
-
- for attempt := 1; attempt <= maxSlugAttempts; attempt++ {
- slug := NextSlug(base, attempt)
-
- n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug})
- if err != nil {
- return nil, err
- }
- if n > 0 {
- continue
- }
-
- inst := models.Instance{
- InstanceID: uuid.NewString(),
- Name: name,
- Slug: slug,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil {
- if mongo.IsDuplicateKeyError(err) {
- continue // lost the race; try the next slug
- }
- return nil, err
- }
- return &inst, nil
- }
- return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
-}
-
-// RollbackInstance deletes an instance that has no users.
-//
-// It refuses an instance that has users. Rollback exists to clean up a
-// half-finished signup, and an instance with users is not half-finished.
-func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error {
- n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID})
- if err != nil {
- return err
- }
- if n > 0 {
- return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n)
- }
- _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID})
- return err
-}
-```
-
-The `ErrNameRejected` text keeps saying "organisation name" — that message is about the _customer's_ organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it.
-
-- [ ] **Step 4: Rename the user provisioning parameter**
-
-In `shared/provision/user.go`, change both signatures and the struct literal:
-
-```go
-func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) {
- var hash string
- if password != "" {
- b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
- if err != nil {
- return nil, err
- }
- hash = string(b)
- }
- return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource)
-}
-
-func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) {
- email = strings.ToLower(strings.TrimSpace(email))
- if email == "" {
- return nil, fmt.Errorf("email required")
- }
- if !models.ValidRole(role) {
- return nil, fmt.Errorf("invalid role %q", role)
- }
-
- u := &models.User{
- UserID: uuid.NewString(),
- InstanceID: instanceID,
- Email: email,
- PasswordHash: passwordHash,
- Role: role,
- AuthSource: authSource,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Collection("users").InsertOne(ctx, u); err != nil {
- if mongo.IsDuplicateKeyError(err) {
- return nil, ErrEmailTaken
- }
- return nil, err
- }
- return u, nil
-}
-```
-
-- [ ] **Step 5: Point the index at the renamed collection**
-
-In `shared/indexes/indexes.go`, change the second index block:
-
-```go
- if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "slug", Value: 1}},
- Options: options.Index().SetUnique(true),
- }); err != nil {
- return fmt.Errorf("instances.slug index: %w", err)
- }
-```
-
-- [ ] **Step 6: Build and confirm no stale references**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go build ./... && go vet ./...
-cd ..
-grep -rn "org_id\|OrgID\|\"orgs\"\|CreateOrg\|RollbackOrg" shared/
-```
-
-Expected: no output from any of them.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add shared/
-git commit -m "refactor(shared): rename Org to Instance"
-```
-
----
-
-### Task 2: The scoped-collection list and the boot assertion
-
-A code constant, not a runbook list. A collection missing from it is a collection whose tenant key never gets renamed — and with no test suite, the boot assertion is what catches that.
-
-**Files:**
-
-- Create: `server/internal/services/migrate_instance.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces:
- - `var ScopedCollections []string`
- - `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error`
-
-- [ ] **Step 1: Write the list and the assertion**
-
-Create `server/internal/services/migrate_instance.go`:
-
-```go
-package services
-
-import (
- "context"
- "fmt"
-
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
-)
-
-// ScopedCollections lists every collection carrying the tenant key.
-//
-// Migration 0004 renames org_id to instance_id in each. A collection missing
-// from this list keeps the old field name and becomes invisible to every scoped
-// query — so this list is load-bearing, not documentation.
-//
-// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
-// holds an org_id.
-//
-// The migrations collection is deliberately absent: it is not tenant-scoped.
-// The two renamed collections appear under their post-rename names, because the
-// migration renames the collections before it renames the field.
-var ScopedCollections = []string{
- "instances",
- "servers",
- "keys",
- "assignments",
- "users",
- "instance_oidc",
- "settings",
- "secrets",
- "workflows",
- "workflow_steps",
- "workflow_runs",
- "monitors",
- "incidents",
- "monitor_rollups",
- "notification_channels",
- "console_sessions",
- "audit_logs",
-}
-
-// AssertNoScopedCollectionMissed reports any collection holding an org_id that
-// ScopedCollections does not know about. A hit means a collection was added
-// without being added to the list, and its tenant key was never renamed.
-func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error {
- known := map[string]bool{}
- for _, c := range ScopedCollections {
- known[c] = true
- }
-
- names, err := db.ListCollectionNames(ctx, bson.M{})
- if err != nil {
- return fmt.Errorf("list collections: %w", err)
- }
-
- for _, n := range names {
- if known[n] {
- continue
- }
- count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
- if err != nil {
- return fmt.Errorf("count %s: %w", n, err)
- }
- if count > 0 {
- return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count)
- }
- }
- return nil
-}
-```
-
-- [ ] **Step 2: Cross-check the list against the documented collections**
-
-```bash
-cd c:/Work/Repos/vantage
-grep -n "console_sessions\|audit_logs\|monitor_rollups\|notification_channels" claude.md
-```
-
-Expected: the collection list in `claude.md` matches `ScopedCollections`, allowing for `orgs`→`instances` and `org_oidc`→`instance_oidc`. Any collection in the docs but not in the list must be added, and vice versa. `migrations` is the only intentional omission.
-
-- [ ] **Step 3: Cross-check against a live database**
-
-Against a restored production snapshot, in `mongosh`:
-
-```javascript
-use vantage_snapshot
-db.getCollectionNames().sort().forEach(function (c) {
- var n = db.getCollection(c).countDocuments({org_id: {$exists: true}});
- if (n > 0) print(c + " " + n);
-})
-```
-
-Expected: exactly the 17 collections in `ScopedCollections`, with `orgs` and `org_oidc` in place of their renamed forms. **Anything else in that output must be added to the list before continuing.**
-
-- [ ] **Step 4: Build and commit**
-
-```bash
-cd c:/Work/Repos/vantage/server && go build ./...
-cd ..
-git add server/internal/services/migrate_instance.go
-git commit -m "chore(server): add ScopedCollections and the boot assertion"
-```
-
----
-
-### Task 3: Migration 0004
-
-The risky part of the plan.
-
-**Files:**
-
-- Modify: `server/internal/services/migrate_instance.go`
-
-**Interfaces:**
-
-- Consumes: `ScopedCollections`
-- Produces: `func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error`
-
-Takes an explicit `*mongo.Database` rather than using the package-level `db.Database`, so it can be pointed at a snapshot database during rehearsal. `server/cmd/main.go` passes `db.Database`.
-
-- [ ] **Step 1: Write the migration**
-
-Append to `server/internal/services/migrate_instance.go`:
-
-```go
-// collectionRenames maps the two collections whose names change. Ordered so the
-// migration is deterministic.
-var collectionRenames = []struct{ from, to string }{
- {"orgs", "instances"},
- {"org_oidc", "instance_oidc"},
-}
-
-// MigrateOrgToInstance renames the tenant key from org_id to instance_id.
-//
-// It only ever renames documents. It never deletes, drops or unsets one, so a
-// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
-// rather than by restoring a backup.
-//
-// The steps are not atomic across collections — multi-document transactions
-// would require a replica set, which self-hosted installs do not guarantee.
-// Instead every step is safely repeatable: a collection rename is skipped when
-// the source is already gone, and $rename matches nothing on a document that
-// has already been renamed. A run that fails partway is fixed by running it
-// again.
-func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error {
- names, err := db.ListCollectionNames(ctx, bson.M{})
- if err != nil {
- return fmt.Errorf("list collections: %w", err)
- }
- exists := map[string]bool{}
- for _, n := range names {
- exists[n] = true
- }
-
- // Step 1: rename the collections.
- for _, r := range collectionRenames {
- switch {
- case !exists[r.from]:
- // Nothing to rename: either already done or never existed.
- continue
- case exists[r.to]:
- return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to)
- }
- cmd := bson.D{
- {Key: "renameCollection", Value: db.Name() + "." + r.from},
- {Key: "to", Value: db.Name() + "." + r.to},
- }
- if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
- return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err)
- }
- log.Printf("0004: renamed collection %s to %s", r.from, r.to)
- }
-
- // Step 2: rename the field.
- for _, c := range ScopedCollections {
- res, err := db.Collection(c).UpdateMany(ctx,
- bson.M{"org_id": bson.M{"$exists": true}},
- bson.M{"$rename": bson.M{"org_id": "instance_id"}},
- )
- if err != nil {
- return fmt.Errorf("rename org_id in %s: %w", c, err)
- }
- if res.ModifiedCount > 0 {
- log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount)
- }
- }
-
- // Step 3: verify before anyone records a marker. Any mismatch aborts, and
- // the migration is re-run rather than marked done.
- for _, c := range ScopedCollections {
- total, err := db.Collection(c).CountDocuments(ctx, bson.M{})
- if err != nil {
- return fmt.Errorf("count %s: %w", c, err)
- }
- if total == 0 {
- continue
- }
-
- stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
- if err != nil {
- return fmt.Errorf("count stale in %s: %w", c, err)
- }
- if stale != 0 {
- return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale)
- }
-
- scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}})
- if err != nil {
- return fmt.Errorf("count scoped in %s: %w", c, err)
- }
- if scoped != total {
- return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped)
- }
- }
-
- // Step 4: indexes keyed on the old field name now point at a field that no
- // longer exists. Drop them; the boot-time index builders recreate the
- // current ones. Dropping an index touches no documents.
- for _, c := range ScopedCollections {
- cur, err := db.Collection(c).Indexes().List(ctx)
- if err != nil {
- return fmt.Errorf("list indexes on %s: %w", c, err)
- }
- var specs []bson.M
- if err := cur.All(ctx, &specs); err != nil {
- return fmt.Errorf("decode indexes on %s: %w", c, err)
- }
- for _, s := range specs {
- name, _ := s["name"].(string)
- if name == "_id_" {
- continue
- }
- keys, ok := s["key"].(bson.M)
- if !ok {
- continue
- }
- if _, keyed := keys["org_id"]; !keyed {
- continue
- }
- if _, err := db.Collection(c).Indexes().DropOne(ctx, name); err != nil {
- return fmt.Errorf("drop index %s on %s: %w", name, c, err)
- }
- log.Printf("0004: dropped stale index %s on %s", name, c)
- }
- }
-
- log.Printf("0004: verified %d collection(s)", len(ScopedCollections))
- return nil
-}
-```
-
-Add `"log"` to the import block.
-
-- [ ] **Step 2: Build**
-
-Run: `cd server && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 3: Dry-run against a disposable copy**
-
-Do not wait for Task 8 to find out whether this works. Make a scratch copy now:
-
-```bash
-mongodump --uri mongodb://localhost:27017 --db vantage --out /tmp/dump
-mongorestore --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dump/vantage
-```
-
-Write a throwaway runner at `server/cmd/migratecheck/main.go`:
-
-```go
-package main
-
-import (
- "context"
- "log"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
-)
-
-func main() {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
- defer cancel()
-
- c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017"))
- if err != nil {
- log.Fatal(err)
- }
- db := c.Database("vantage_dryrun")
-
- if err := services.MigrateOrgToInstance(ctx, db); err != nil {
- log.Fatalf("migration: %v", err)
- }
- if err := services.AssertNoScopedCollectionMissed(ctx, db); err != nil {
- log.Fatalf("assertion: %v", err)
- }
- log.Println("dry run complete")
-}
-```
-
-Run: `cd server && go run ./cmd/migratecheck`
-
-Expected: per-collection rename counts, `0004: verified 17 collection(s)`, `dry run complete`, no error.
-
-- [ ] **Step 4: Confirm idempotency by hand**
-
-Run it a second time: `cd server && go run ./cmd/migratecheck`
-
-Expected: completes with no error and no rename counts (nothing left to rename). If it errors, the migration is not repeatable and a partial production run would be unrecoverable — fix before continuing.
-
-- [ ] **Step 5: Confirm it resumes from a partial run**
-
-Restore a fresh copy, then simulate a crash after the collection rename but before the field rename:
-
-```bash
-mongorestore --drop --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dump/vantage
-```
-
-```javascript
-db.adminCommand({ renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances" });
-```
-
-Then: `cd server && go run ./cmd/migratecheck`
-
-Expected: completes cleanly and every document in `instances` carries `instance_id`.
-
-Confirm:
-
-```javascript
-use vantage_dryrun
-db.instances.countDocuments({instance_id: {$exists: true}})
-db.instances.countDocuments({org_id: {$exists: true}})
-```
-
-Expected: the first equals the total document count; the second is `0`.
-
-- [ ] **Step 6: Delete the throwaway runner and commit**
-
-```bash
-rm -rf server/cmd/migratecheck
-git add server/internal/services/migrate_instance.go
-git commit -m "feat(server): add migration 0004 org_id to instance_id"
-```
-
-Keep `/tmp/dump` — Task 8 needs it.
-
----
-
-### Task 4: Rename the control plane
-
-Mechanical, wide, and guarded by the compiler at every step. Work file by file and let `go build` drive.
-
-**Files:**
-
-- Rename: `server/internal/services/orgs.go` → `instances.go`
-- Rename: `server/internal/services/org_oidc.go` → `instance_oidc.go`
-- Rename: `server/internal/auth/orghost.go` → `instancehost.go`
-- Rename: `server/internal/models/org.go` → `instance.go`
-- Modify: every file under `server/internal/` referencing a renamed symbol
-- Modify: `server/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: Task 1's shared API, Tasks 2–3's migration
-- Produces: `services.GetInstance`, `GetInstanceBySlug`, `CreateInstance`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance`, `CountInstanceUsers`, `GetUserInInstance` — all with the same signatures as their `Org` predecessors
-
-- [ ] **Step 1: Rename the files**
-
-```bash
-cd c:/Work/Repos/vantage
-git mv server/internal/services/orgs.go server/internal/services/instances.go
-git mv server/internal/services/org_oidc.go server/internal/services/instance_oidc.go
-git mv server/internal/auth/orghost.go server/internal/auth/instancehost.go
-git mv server/internal/models/org.go server/internal/models/instance.go
-```
-
-- [ ] **Step 2: Apply the identifier renames**
-
-Order matters — longest identifiers first, so shorter rules do not corrupt them.
-
-```bash
-cd c:/Work/Repos/vantage/server
-FILES=$(find . -name '*.go' -not -path './vendor/*')
-
-sed -i 's/CountOrgUsers/CountInstanceUsers/g' $FILES
-sed -i 's/GetUserInOrg/GetUserInInstance/g' $FILES
-sed -i 's/GetOrgBySlug/GetInstanceBySlug/g' $FILES
-sed -i 's/ListOrgIDs/ListInstanceIDs/g' $FILES
-sed -i 's/CountOrgs/CountInstances/g' $FILES
-sed -i 's/FirstOrg/FirstInstance/g' $FILES
-sed -i 's/AdoptOrg/AdoptInstance/g' $FILES
-sed -i 's/CreateOrg/CreateInstance/g' $FILES
-sed -i 's/GetOrg/GetInstance/g' $FILES
-sed -i 's/OrgID/InstanceID/g' $FILES
-sed -i 's/orgID/instanceID/g' $FILES
-sed -i 's/models\.Org\b/models.Instance/g' $FILES
-sed -i 's/"org_id"/"instance_id"/g' $FILES
-sed -i 's/"orgs"/"instances"/g' $FILES
-sed -i 's/"org_oidc"/"instance_oidc"/g' $FILES
-```
-
-- [ ] **Step 3: Restore the migration file**
-
-`sed` will have rewritten the migration's deliberate references to the old names, which would make it a no-op that silently does nothing.
-
-```bash
-cd c:/Work/Repos/vantage
-git checkout server/internal/services/migrate_instance.go
-git diff server/internal/services/migrate_instance.go
-```
-
-Expected: no diff. The migration must keep reading `org_id`, `"orgs"` and `"org_oidc"` — that is its entire job.
-
-- [ ] **Step 4: Read the rest of the diff**
-
-`sed` is a blunt instrument. Run `git diff` and check for:
-
-- Log and error strings now reading "instance" where "organisation" was the correct customer-facing word.
-- Comments that no longer parse as English.
-- Any `instanceID` variable that was previously an unrelated `orgID` in a different sense.
-
-Fix anything that reads wrong before building.
-
-- [ ] **Step 5: Alias the model**
-
-Replace `server/internal/models/instance.go` with:
-
-```go
-package models
-
-import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
-
-// Instance is defined in the shared module because sitesvc and the admin
-// control plane write the same documents.
-type Instance = shared.Instance
-```
-
-- [ ] **Step 6: Build and fix what falls out**
-
-Run: `cd server && go build ./... 2>&1 | head -40`
-
-Expected initially: a list of errors. Work through them. Common ones:
-
-- A server-only model still declaring `OrgID` — rename the field there too.
-- `reservedSlugs` references in `AdoptInstance` — should already point at `provision.ReservedSlugs` from plan 0a.
-
-Repeat until the build is clean.
-
-- [ ] **Step 7: Rename the REST routes**
-
-In the router setup under `server/internal/api/`, change the route group path:
-
-```go
- instanceGroup := api.Group("/instance", auth.RequireRole(models.RoleOwner, models.RoleAdmin))
- instanceGroup.GET("/users", listUsers)
- instanceGroup.POST("/users", createUser)
- instanceGroup.PUT("/users/:id/role", updateUserRole)
- instanceGroup.DELETE("/users/:id", deleteUser)
- instanceGroup.GET("/oidc", getOIDC)
- instanceGroup.PUT("/oidc", putOIDC)
-```
-
-- [ ] **Step 8: Rename the session and /auth/me fields**
-
-In the session struct and the `/auth/me` handler, rename the JSON keys `org_id` to `instance_id` and `org` to `instance`.
-
-```bash
-cd c:/Work/Repos/vantage
-grep -rn '"org' server/internal/auth/ server/internal/api/
-```
-
-Expected: no output.
-
-- [ ] **Step 9: Wire the migration and assertion into boot**
-
-In `server/cmd/main.go`, after the existing migrations (`MigrateMissedOrgScopes`, itself renamed by Step 2 to `MigrateMissedInstanceScopes`) and before the index builders:
-
-```go
- migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
- err = services.MigrateOrgToInstance(migCtx, db.Database)
- migCancel()
- if err != nil {
- log.Fatalf("instance rename migration failed: %v", err)
- }
-
- assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
- err = services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
- assertCancel()
- if err != nil {
- log.Fatalf("scoped collection check failed: %v", err)
- }
-```
-
-Both are fatal. A half-renamed database must not serve traffic.
-
-- [ ] **Step 10: Build, vet, and confirm the rename is total**
-
-```bash
-cd c:/Work/Repos/vantage/server
-go build ./... && go vet ./...
-cd ..
-grep -rn "org_id\|OrgID\|\"orgs\"\|/api/org" server/ | grep -v migrate_instance.go
-```
-
-Expected: no output from any of them.
-
-- [ ] **Step 11: Commit**
-
-```bash
-git add server/
-git commit -m "refactor(server): rename Org to Instance"
-```
-
----
-
-### Task 5: The inverse rename
-
-The recovery path. Deliberately a separate one-shot command rather than a migration — the only reason to run it is a decision to revert the release, which is a human decision.
-
-**Files:**
-
-- Create: `server/cmd/rename-rollback/main.go`
-
-**Interfaces:**
-
-- Consumes: `services.ScopedCollections`
-- Produces: a binary, not an API
-
-- [ ] **Step 1: Write it**
-
-Create `server/cmd/rename-rollback/main.go`:
-
-```go
-// Command rename-rollback reverses migration 0004.
-//
-// Run it only as part of a decision to revert the release that introduced the
-// instance rename. It renames instance_id back to org_id and restores the two
-// collection names. Like the migration, it only renames — it deletes no
-// documents.
-//
-// rename-rollback -uri mongodb://host:27017 -db vantage -confirm
-package main
-
-import (
- "context"
- "flag"
- "log"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
-)
-
-func main() {
- uri := flag.String("uri", "mongodb://localhost:27017", "MongoDB URI")
- dbName := flag.String("db", "vantage", "database name")
- confirm := flag.Bool("confirm", false, "required; refuses to run without it")
- flag.Parse()
-
- if !*confirm {
- log.Fatal("refusing to run without -confirm")
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
- defer cancel()
-
- client, err := mongo.Connect(options.Client().ApplyURI(*uri))
- if err != nil {
- log.Fatalf("connect: %v", err)
- }
- defer client.Disconnect(ctx)
-
- db := client.Database(*dbName)
-
- for _, c := range services.ScopedCollections {
- res, err := db.Collection(c).UpdateMany(ctx,
- bson.M{"instance_id": bson.M{"$exists": true}},
- bson.M{"$rename": bson.M{"instance_id": "org_id"}},
- )
- if err != nil {
- log.Fatalf("rename instance_id in %s: %v", c, err)
- }
- if res.ModifiedCount > 0 {
- log.Printf("%s: reverted %d document(s)", c, res.ModifiedCount)
- }
- }
-
- for _, r := range []struct{ from, to string }{
- {"instances", "orgs"},
- {"instance_oidc", "org_oidc"},
- } {
- cmd := bson.D{
- {Key: "renameCollection", Value: *dbName + "." + r.from},
- {Key: "to", Value: *dbName + "." + r.to},
- }
- if err := client.Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
- log.Printf("rename %s to %s: %v (continuing)", r.from, r.to, err)
- continue
- }
- log.Printf("renamed collection %s to %s", r.from, r.to)
- }
-
- // Remove the marker so a redeployed new binary re-runs the migration.
- if _, err := db.Collection("migrations").DeleteOne(ctx, bson.M{"_id": "0004_org_to_instance"}); err != nil {
- log.Printf("clear migration marker: %v", err)
- }
-
- log.Println("rollback complete")
-}
-```
-
-- [ ] **Step 2: Build and confirm the guard**
-
-```bash
-cd c:/Work/Repos/vantage/server
-go build -o /tmp/rename-rollback ./cmd/rename-rollback
-/tmp/rename-rollback
-```
-
-Expected: exits with `refusing to run without -confirm`.
-
-- [ ] **Step 3: Rehearse the round trip on the dry-run database**
-
-The dry-run database from Task 3 is already migrated. Revert it:
-
-```bash
-/tmp/rename-rollback -uri mongodb://localhost:27017 -db vantage_dryrun -confirm
-```
-
-Then in `mongosh`:
-
-```javascript
-use vantage_dryrun
-db.getCollectionNames().sort()
-db.orgs.countDocuments({org_id: {$exists: true}})
-db.orgs.countDocuments({instance_id: {$exists: true}})
-```
-
-Expected: `orgs` and `org_oidc` are back, the first count equals the total document count, and the second is `0`.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add server/cmd/rename-rollback
-git commit -m "feat(server): add rename-rollback command for migration 0004"
-```
-
----
-
-### Task 6: Rename sitesvc and add its boot guard
-
-sitesvc writes the same documents. A version skew where it writes `org_id` while the control plane reads `instance_id` creates tenants the application cannot see — so it refuses to start against an unmigrated database.
-
-**Files:**
-
-- Modify: `sitesvc/internal/models/models.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/api/*.go`, `sitesvc/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: Task 1's shared API
-- Produces: `store.RequireMigratedDatabase(ctx context.Context) error`
-
-- [ ] **Step 1: Rename the pending-signup field**
-
-In `sitesvc/internal/models/models.go`, replace the `OrgName` field with:
-
-```go
- InstanceName string `bson:"instance_name"`
-```
-
-This collection is sitesvc-private and its records live at most 24 hours, so no migration is needed — an in-flight signup written under the old field name simply expires. That has a consequence for rollout; see the Rollout section.
-
-- [ ] **Step 2: Apply the identifier renames**
-
-```bash
-cd c:/Work/Repos/vantage/sitesvc
-FILES=$(find . -name '*.go')
-
-sed -i 's/CreateOrg/CreateInstance/g' $FILES
-sed -i 's/RollbackOrg/RollbackInstance/g' $FILES
-sed -i 's/OrgName/InstanceName/g' $FILES
-sed -i 's/orgName/instanceName/g' $FILES
-sed -i 's/OrgID/InstanceID/g' $FILES
-sed -i 's/sharedmodels\.Org\b/sharedmodels.Instance/g' $FILES
-sed -i 's/\borg\b/instance/g' $FILES
-sed -i 's/"org_id"/"instance_id"/g' $FILES
-sed -i 's/"orgs"/"instances"/g' $FILES
-sed -i 's/"org_name"/"instance_name"/g' $FILES
-```
-
-Read the diff. `\borg\b` is the aggressive rule — check it did not rename an unrelated local variable or mangle a comment. Also check the JSON request field in `internal/api`: the signup form posts `org_name`, so the marketing site's field name and the handler's binding tag must change together (Task 8 handles the site side).
-
-- [ ] **Step 3: Write the boot guard**
-
-Add to `sitesvc/internal/store/store.go`:
-
-```go
-// RequireMigratedDatabase refuses to start against a control-plane database
-// that has not run migration 0004.
-//
-// Provisioning into `orgs` while the control plane reads `instances` would
-// create tenants nobody can see — the exact skew failure the shared module was
-// built to prevent. Failing to start is strictly better.
-func RequireMigratedDatabase(ctx context.Context) error {
- names, err := database.ListCollectionNames(ctx, bson.M{})
- if err != nil {
- return fmt.Errorf("list collections: %w", err)
- }
-
- var hasInstances, hasOrgs bool
- for _, n := range names {
- switch n {
- case "instances":
- hasInstances = true
- case "orgs":
- hasOrgs = true
- }
- }
-
- // A brand-new database has neither. That is fine — whichever service starts
- // first creates `instances`.
- if !hasInstances && !hasOrgs {
- return nil
- }
- if !hasInstances {
- return errors.New("instances collection not found; deploy the control plane first")
- }
- return nil
-}
-```
-
-- [ ] **Step 4: Call it at boot**
-
-In `sitesvc/cmd/main.go`, immediately after the successful `store.Connect` and before `store.EnsureIndexes`:
-
-```go
- guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second)
- err := store.RequireMigratedDatabase(guardCtx)
- guardCancel()
- if err != nil {
- log.Fatalf("database check failed: %v", err)
- }
-```
-
-Note `main` currently uses `:=` for its first `err`; adjust to `=` or `:=` as the surrounding code requires — `go build` will tell you.
-
-- [ ] **Step 5: Build and confirm**
-
-```bash
-cd c:/Work/Repos/vantage/sitesvc
-go build ./... && go vet ./...
-cd ..
-grep -rn "org_id\|OrgID\|OrgName\|\"orgs\"\|org_name" sitesvc/
-```
-
-Expected: no output.
-
-- [ ] **Step 6: Exercise the guard by hand**
-
-Against the un-migrated copy — restore one:
-
-```bash
-mongorestore --drop --uri mongodb://localhost:27017 --db vantage_guardtest /tmp/dump/vantage
-```
-
-```bash
-cd c:/Work/Repos/vantage/sitesvc
-MONGO_URI=mongodb://localhost:27017/vantage_guardtest \
- PUBLIC_URL=http://localhost:8082 SITE_ORIGIN=http://localhost:3001 \
- SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \
- go run ./cmd
-```
-
-Expected: exits immediately with
-`database check failed: instances collection not found; deploy the control plane first`
-
-Then migrate that database (boot the server against it) and run sitesvc again.
-
-Expected: starts normally and logs `sitesvc listening on :8082`.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add sitesvc/
-git commit -m "refactor(sitesvc): rename Org to Instance, refuse an unmigrated database"
-```
-
----
-
-### Task 7: Rename the control-plane frontend
-
-Breaking route changes ship here, in the same release as Task 4.
-
-**Files:**
-
-- Modify: `web/lib/` API client, every route under `web/app/(app)/`, `web/components/`
-- Rename: `web/app/(app)/settings/org/` → `web/app/(app)/settings/instance/`
-
-**Interfaces:**
-
-- Consumes: the renamed REST API from Task 4
-- Produces: no API
-
-- [ ] **Step 1: Find every reference and save the list**
-
-```bash
-cd c:/Work/Repos/vantage/web
-grep -rn "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization\|settings/org" \
- --include=*.ts --include=*.tsx . | tee /tmp/web-org-refs.txt
-wc -l /tmp/web-org-refs.txt
-```
-
-Every line must be resolved by Step 5.
-
-- [ ] **Step 2: Rename the settings route**
-
-```bash
-cd c:/Work/Repos/vantage
-git mv "web/app/(app)/settings/org" "web/app/(app)/settings/instance"
-```
-
-- [ ] **Step 3: Apply the renames**
-
-```bash
-cd c:/Work/Repos/vantage/web
-FILES=$(grep -rl "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization\|settings/org" \
- --include=*.ts --include=*.tsx .)
-
-sed -i 's|/api/org/|/api/instance/|g' $FILES
-sed -i 's/org_id/instance_id/g' $FILES
-sed -i 's/orgId/instanceId/g' $FILES
-sed -i 's|settings/org|settings/instance|g' $FILES
-sed -i 's/Organisation/Instance/g' $FILES
-sed -i 's/Organization/Instance/g' $FILES
-sed -i 's/organisation/instance/g' $FILES
-sed -i 's/organization/instance/g' $FILES
-```
-
-- [ ] **Step 4: Read every copy change**
-
-The copy substitutions are the ones most likely to produce nonsense. Run `git diff` and read **every changed user-facing string**. "Instance name too short" is fine; "Create your instance" is fine; a sentence that only worked with the old word needs rewriting rather than substituting. Fix them now — nobody else will.
-
-- [ ] **Step 5: Confirm nothing was missed**
-
-```bash
-cd c:/Work/Repos/vantage/web
-grep -rn "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization" \
- --include=*.ts --include=*.tsx .
-```
-
-Expected: no output.
-
-- [ ] **Step 6: Build**
-
-Run: `cd web && npm run build`
-Expected: build succeeds with no type errors.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add web/
-git commit -m "refactor(web): rename Organisation to Instance"
-```
-
----
-
-### Task 8: Marketing site copy
-
-The marketing site uses "organisation" for two different things, and only one becomes "Instance". Where it means the customer, it becomes **Account** — a word that now has a specific meaning in this system, and the marketing site is where a customer meets it first.
-
-**Files:**
-
-- Modify: `site/app/`, `site/components/`
-
-- [ ] **Step 1: Find every reference**
-
-```bash
-cd c:/Work/Repos/vantage/site
-grep -rn "organisation\|Organisation\|organization\|Organization\|org_name" \
- --include=*.ts --include=*.tsx --include=*.md .
-```
-
-- [ ] **Step 2: Decide each one individually**
-
-**Do not bulk-substitute here.** For each hit, decide:
-
-- The thing that gets a subdomain, holds servers and carries a licence → **Instance**
-- The customer who pays and may hold several → **Account**
-
-- [ ] **Step 3: Update the signup form**
-
-This is the case that matters most and the only functional change in this task. The form currently asks for an "organisation name" and posts `org_name`; that name becomes the slug, so it is creating an **Instance**.
-
-- Field label becomes "Instance name"
-- Helper text explains it becomes the subdomain
-- The posted JSON field becomes `instance_name`, matching the sitesvc handler renamed in Task 6 Step 2
-
-- [ ] **Step 4: Build**
-
-Run: `cd site && npm run build`
-Expected: succeeds.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add site/
-git commit -m "refactor(site): distinguish Instance from Account in copy"
-```
-
----
-
-### Task 9: Full verification against a production snapshot
-
-The gate. With no automated tests, this is the entire safety net. Nothing ships until every step passes against **restored production data**.
-
-**Files:** none
-
-- [ ] **Step 1: Build everything, including the agent**
-
-```bash
-cd c:/Work/Repos/vantage
-(cd shared && go build ./... && go vet ./...)
-(cd server && go build ./... && go vet ./...)
-(cd sitesvc && go build ./... && go vet ./...)
-(cd agent && go build ./... && go vet ./...)
-(cd web && npm run build)
-(cd site && npm run build)
-```
-
-Expected: all succeed.
-
-- [ ] **Step 2: Restore a production snapshot and record the baseline**
-
-```bash
-mongorestore --drop --uri mongodb://localhost:27017 --db vantage_snapshot /tmp/dump/vantage
-```
-
-In `mongosh`:
-
-```javascript
-use vantage_snapshot
-db.getCollectionNames().sort().forEach(function (c) {
- print(c + " " + db.getCollection(c).countDocuments({}))
-})
-```
-
-Save the output to `/tmp/baseline.txt`. Everything below compares against it.
-
-- [ ] **Step 3: Record the per-tenant baseline**
-
-Pick three real tenants from `db.orgs.find({}, {org_id: 1})`. For each:
-
-```javascript
-["", "", ""].forEach(function (t) {
- print(
- t +
- " servers=" +
- db.servers.countDocuments({ org_id: t }) +
- " keys=" +
- db.keys.countDocuments({ org_id: t }) +
- " workflows=" +
- db.workflows.countDocuments({ org_id: t }) +
- " monitors=" +
- db.monitors.countDocuments({ org_id: t }) +
- " secrets=" +
- db.secrets.countDocuments({ org_id: t }) +
- " audit=" +
- db.audit_logs.countDocuments({ org_id: t }),
- );
-});
-```
-
-Save to `/tmp/tenants-before.txt`.
-
-- [ ] **Step 4: Run the migration**
-
-Boot the server against the snapshot:
-
-```bash
-cd c:/Work/Repos/vantage/server
-MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_snapshot \
- GRPC_HOST=localhost:9090 go run ./cmd
-```
-
-Expected in the logs: `0004: renamed collection orgs to instances`, `0004: renamed collection org_oidc to instance_oidc`, per-collection rename counts, and `0004: verified 17 collection(s)`. No fatal error, and no `scoped collection check failed`.
-
-- [ ] **Step 5: Compare document counts**
-
-Re-run the Step 2 snippet and diff against the baseline:
-
-```javascript
-use vantage_snapshot
-db.getCollectionNames().sort().forEach(function (c) {
- print(c + " " + db.getCollection(c).countDocuments({}))
-})
-```
-
-Expected: identical counts, with exactly two names changed — `orgs` now `instances`, `org_oidc` now `instance_oidc`. **Any count difference is a stop-the-release defect.**
-
-- [ ] **Step 6: The tenant isolation test**
-
-For the same three tenants:
-
-```javascript
-["", "", ""].forEach(function (t) {
- print(
- t +
- " servers=" +
- db.servers.countDocuments({ instance_id: t }) +
- " keys=" +
- db.keys.countDocuments({ instance_id: t }) +
- " workflows=" +
- db.workflows.countDocuments({ instance_id: t }) +
- " monitors=" +
- db.monitors.countDocuments({ instance_id: t }) +
- " secrets=" +
- db.secrets.countDocuments({ instance_id: t }) +
- " audit=" +
- db.audit_logs.countDocuments({ instance_id: t }),
- );
-});
-```
-
-Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name in the query. **This is the step that proves tenant isolation survived.** Anything else stops the release.
-
-- [ ] **Step 7: Confirm no document kept the old field**
-
-```javascript
-db.getCollectionNames().forEach(function (c) {
- var n = db.getCollection(c).countDocuments({ org_id: { $exists: true } });
- if (n > 0) print("STALE " + c + " " + n);
-});
-```
-
-Expected: no output.
-
-- [ ] **Step 8: Application smoke test**
-
-Against the migrated snapshot, with `web` running:
-
-1. Log in as a real user.
-2. Confirm the servers list, keys, workflows, monitors, secrets and audit log all populate with the expected number of rows.
-3. Open a server detail page; confirm inventory and assigned keys render.
-4. Open `/settings/instance`; confirm members and OIDC settings load.
-5. Confirm the sidebar and page copy say "Instance", never "Organisation".
-6. Create something — a monitor is cheapest — and confirm it saves and appears.
-
-- [ ] **Step 9: sitesvc against the migrated database**
-
-Boot sitesvc against `vantage_snapshot` and complete a signup end to end: form, verification email via MailHog, link, then log into the control plane with the new credentials.
-
-Expected: succeeds, and the new instance appears in `db.instances`.
-
-- [ ] **Step 10: sitesvc against an unmigrated database**
-
-```bash
-mongorestore --drop --uri mongodb://localhost:27017 --db vantage_unmigrated /tmp/dump/vantage
-```
-
-Boot sitesvc against it.
-
-Expected: exits immediately with
-`database check failed: instances collection not found; deploy the control plane first`
-
-- [ ] **Step 11: Rollback rehearsal**
-
-**Mandatory.** Do not deploy without having done this.
-
-```bash
-mongorestore --drop --uri mongodb://localhost:27017 --db vantage_rollback /tmp/dump/vantage
-```
-
-Migrate it (boot the server against it), then revert:
-
-```bash
-/tmp/rename-rollback -uri mongodb://localhost:27017 -db vantage_rollback -confirm
-```
-
-Then:
-
-1. Re-run the Step 2 count snippet against `vantage_rollback` and diff against `/tmp/baseline.txt`. Expected: identical, including the original collection names.
-2. Re-run the Step 3 per-tenant snippet with `org_id`. Expected: identical to `/tmp/tenants-before.txt`.
-3. Check out the **pre-release** commit, build the old server, and boot it against `vantage_rollback`. Expected: starts and serves normally.
-
-- [ ] **Step 12: Confirm the agent is untouched**
-
-```bash
-cd c:/Work/Repos/vantage
-git diff --name-only main -- agent/ .gitea/workflows/agent-release.yml
-```
-
-Expected: no output.
-
-- [ ] **Step 13: Clean up and commit**
-
-```javascript
-["vantage_snapshot", "vantage_dryrun", "vantage_guardtest", "vantage_unmigrated", "vantage_rollback"].forEach(function (d) {
- db.getSiblingDB(d).dropDatabase();
-});
-```
-
-```bash
-git add -A
-git commit -m "chore: verify instance rename against a production snapshot"
-```
-
----
-
-## Rollout
-
-**Order matters. Read this before deploying.**
-
-1. **Take a database backup.** Not optional.
-2. Confirm the Task 9 rollback rehearsal was actually performed, not just read.
-3. Deploy `server`, `web`, `site` and `sitesvc` from **one commit, together**.
-4. The server boots first, runs migration `0004`, records the marker.
-5. sitesvc may start before the server and exit with the guard message. It restarts and succeeds once the migration has run. **This is expected, not an incident.**
-
-```bash
-cd /opt/vantage && \
- docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
- docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
-```
-
-Expect a short API outage during the server restart while the migration runs. Agents are unaffected — they reconnect, and no gRPC message carries a tenant ID.
-
-**In-flight signups are lost.** Any unverified signup recorded before the deploy carries `org_name` and will fail verification. There are at most 24 hours' worth. Either accept it, or wait for the collection to drain:
-
-```javascript
-db.site_pending_signups.countDocuments({});
-```
-
-Post-deploy checks:
-
-1. Control-plane login works and the fleet dashboard populates.
-2. A fresh signup completes end to end.
-3. Server logs show `0004: verified 17 collection(s)` and no scoped-collection failure.
-4. `db.servers.countDocuments({status: "active"})` matches the pre-deploy figure — agents are still reporting.
-
-**If something is wrong:** deploy the previous images, run `rename-rollback -confirm`, and confirm the old binaries boot. Do not attempt a partial fix against a live half-renamed database.
-
-## What this unblocks
-
-Plan 1 (`licensing-core`) can define a licence payload bound to `instance_id`
-without inventing a word the codebase does not use.
diff --git a/docs/superpowers/plans/2026-07-24-licensing-core.md b/docs/superpowers/plans/2026-07-24-licensing-core.md
deleted file mode 100644
index 09cf2b7..0000000
--- a/docs/superpowers/plans/2026-07-24-licensing-core.md
+++ /dev/null
@@ -1,1044 +0,0 @@
-# Licensing Core Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add `shared/license`, the signed licence payload with offline verification, plus `lkctl` to issue licences by hand.
-
-**Architecture:** A new package inside the existing `shared` module, so the control plane (verifier) and the future admin service (signer) read one struct definition. Signing is excluded from the server binary by a build tag; the trusted public keys are compiled in as a slice. No network calls anywhere — verification checks a signature, an expiry, a deployment mode and an instance ID, and asks nobody's permission.
-
-**Tech Stack:** Go 1.26, `github.com/hyperboloide/lk` (ECDSA P-384 + SHA-256, base32), the existing `shared` module.
-
-**No automated tests.** Verification is by compiler, a probe command, and `lkctl` round trips at the command line. Every task ends with observable output.
-
-## Global Constraints
-
-- Package path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`
-- `shared/go.mod` gains **one** dependency: `github.com/hyperboloide/lk`. This is a deliberate exception to plan 0a's three-dependency limit, and the only one. `shared` must still not import gin, redis, guac or the mongo-driver from this package.
-- Tier and deployment values are exact strings: `free`, `professional`, `self_hosted`; `cloud`, `self_hosted`.
-- Feature keys are exact strings: `console`, `oidc`.
-- `-1` means unlimited in every `Limits` field.
-- **The verifier never branches on `Tier`.** It reads `Limits` and `Features` only. `Tier` is for display, support and analytics.
-- **Every licence is bound to an instance.** There is no unbound licence and no claim protocol.
-- The private signing key never appears in the repo, in an image, or in the control plane's environment.
-- Do not run the app with `go run` — use the Docker images. `lkctl` is a local CLI, not the app, and may be run directly.
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| --------------------------- | ------------------------------------------------------ |
-| `shared/license/license.go` | `License`, `Limits`, tier/deployment/feature constants |
-| `shared/license/keys.go` | `trustedPublicKeys`, key lookup |
-| `shared/license/sign.go` | `Sign`, build-tagged out of the server |
-| `shared/license/verify.go` | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State` |
-| `shared/license/plans.go` | The tier seed table used by `lkctl` |
-| `shared/cmd/lkctl/main.go` | `keypair`, `issue`, `inspect` |
-
-**Modified:** `shared/go.mod`, `shared/go.sum`.
-
----
-
-### Task 1: Add the dependency and confirm the library's API
-
-The rest of the plan assumes specific `lk` function names. Confirm them first rather than discovering a mismatch three tasks later.
-
-**Files:**
-
-- Modify: `shared/go.mod`, `shared/go.sum`
-- Create (temporary): `shared/cmd/lkprobe/main.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces: a pinned `lk` version and a confirmed API surface
-
-- [ ] **Step 1: Add the dependency**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go get github.com/hyperboloide/lk@latest
-```
-
-- [ ] **Step 2: Write a probe that exercises the whole surface this plan needs**
-
-Create `shared/cmd/lkprobe/main.go`:
-
-```go
-package main
-
-import (
- "fmt"
-
- "github.com/hyperboloide/lk"
-)
-
-func main() {
- priv, err := lk.NewPrivateKey()
- if err != nil {
- panic(err)
- }
- privStr, err := priv.ToB32String()
- if err != nil {
- panic(err)
- }
- fmt.Println("private b32 len:", len(privStr))
-
- pub := priv.GetPublicKey()
- pubStr, err := pub.ToB32String()
- if err != nil {
- panic(err)
- }
- fmt.Println("public b32 len:", len(pubStr))
-
- l, err := lk.NewLicense(priv, []byte(`{"hello":"world"}`))
- if err != nil {
- panic(err)
- }
- blob, err := l.ToB32String()
- if err != nil {
- panic(err)
- }
- fmt.Println("licence b32 len:", len(blob))
-
- parsed, err := lk.LicenseFromB32String(blob)
- if err != nil {
- panic(err)
- }
- pub2, err := lk.PublicKeyFromB32String(pubStr)
- if err != nil {
- panic(err)
- }
- ok, err := parsed.Verify(pub2)
- fmt.Println("verify:", ok, err)
- fmt.Println("data:", string(parsed.Data))
-
- // Tamper: a licence signed by a different key must not verify.
- other, _ := lk.NewPrivateKey()
- bad, _ := lk.NewLicense(other, []byte(`{"hello":"world"}`))
- badBlob, _ := bad.ToB32String()
- badParsed, _ := lk.LicenseFromB32String(badBlob)
- ok2, _ := badParsed.Verify(pub2)
- fmt.Println("foreign key verify (must be false):", ok2)
-}
-```
-
-- [ ] **Step 3: Run the probe**
-
-Run: `cd shared && go run ./cmd/lkprobe`
-
-Expected: non-zero lengths for all three, `verify: true `, the JSON echoed back, and `foreign key verify (must be false): false`.
-
-**If any function name does not compile, fix the probe against the installed version and carry the corrected names through every later task in this plan.** The names used below are `lk.NewPrivateKey`, `PrivateKey.ToB32String`, `PrivateKey.GetPublicKey`, `PublicKey.ToB32String`, `lk.PrivateKeyFromB32String`, `lk.PublicKeyFromB32String`, `lk.NewLicense`, `License.ToB32String`, `lk.LicenseFromB32String`, `License.Verify`, `License.Data`.
-
-- [ ] **Step 4: Delete the probe and commit**
-
-```bash
-cd c:/Work/Repos/vantage
-rm -rf shared/cmd/lkprobe
-git add shared/go.mod shared/go.sum
-git commit -m "chore(shared): add hyperboloide/lk for licence signing"
-```
-
----
-
-### Task 2: The payload and the tier table
-
-**Files:**
-
-- Create: `shared/license/license.go`
-- Create: `shared/license/plans.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces:
- - `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
- - `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
- - `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
- - `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
- - `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
- - `func (l License) HasFeature(name string) bool`
- - `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
- - `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`
-
-- [ ] **Step 1: Write the payload**
-
-Create `shared/license/license.go`:
-
-```go
-// Package license defines the Vantage licence payload and its offline
-// verification.
-//
-// A licence is a signed blob (ECDSA P-384 with SHA-256). The server checks a signature, an
-// expiry, a deployment mode and an instance ID, and asks nobody's permission.
-// That buys air-gapped self-hosting and means no instance depends on the
-// licensing service being reachable.
-//
-// It costs revocation: once issued, a licence is valid until it expires
-// whatever the billing system later says. Self Hosted is sold annually only so
-// that window is bounded.
-package license
-
-import "time"
-
-const (
- TierFree = "free"
- TierProfessional = "professional"
- TierSelfHosted = "self_hosted"
-
- DeploymentCloud = "cloud"
- DeploymentSelfHosted = "self_hosted"
-
- FeatureConsole = "console" // browser SSH/RDP/VNC
- FeatureOIDC = "oidc" // per-instance single sign-on
-)
-
-// Unlimited is the sentinel for "no cap" in every Limits field.
-const Unlimited = -1
-
-// Limits are the countable caps a licence grants.
-type Limits struct {
- MaxServers int `json:"max_servers"`
- MaxSecretGroups int `json:"max_secret_groups"`
- MaxChannels int `json:"max_channels"`
-}
-
-// License is the signed payload.
-//
-// InstanceID is always populated: the self-hosted purchase flow links the
-// instance UUID before the licence is signed, so there is no unbound licence
-// and no claim protocol.
-type License struct {
- ID string `json:"id"` // uuid, for support and audit
- InstanceID string `json:"instance_id"` // the instance this licence is bound to
- AccountID string `json:"account_id"` // admin-side customer, informational
- InstanceName string `json:"instance_name"` // display only
- Tier string `json:"tier"`
- Deployment string `json:"deployment"`
- IssuedAt time.Time `json:"issued_at"`
- ExpiresAt time.Time `json:"expires_at"`
- Limits Limits `json:"limits"`
- Features []string `json:"features"`
-}
-
-// HasFeature reports whether the licence grants a named feature.
-//
-// Callers must use this rather than switching on Tier. Adding a tier, or
-// changing what a tier includes, must never require a server release.
-func (l License) HasFeature(name string) bool {
- for _, f := range l.Features {
- if f == name {
- return true
- }
- }
- return false
-}
-
-// WithinLimit reports whether one more of something is allowed.
-// A max of Unlimited always allows.
-func WithinLimit(current, max int) bool {
- if max == Unlimited {
- return true
- }
- return current < max
-}
-```
-
-- [ ] **Step 2: Write the tier seed table**
-
-Create `shared/license/plans.go`:
-
-```go
-package license
-
-// Plan is the contents of a tier at issue time.
-//
-// This table is the seed. Once the admin service exists (spec 3) it owns the
-// authoritative copy in its `plans` collection, and every issued licence
-// snapshots the plan it was cut from — so editing a plan never rewrites an
-// existing licence, the same rule as workflow_runs.steps_snapshot.
-//
-// lkctl uses this table to issue by hand until then.
-type Plan struct {
- Tier string
- Name string
- Deployment string
- Limits Limits
- Features []string
-}
-
-var plans = map[string]Plan{
- TierFree: {
- Tier: TierFree,
- Name: "Free",
- Deployment: DeploymentCloud, // cloud only, by construction
- Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
- Features: nil,
- },
- TierProfessional: {
- Tier: TierProfessional,
- Name: "Professional",
- Deployment: DeploymentCloud,
- Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
- Features: []string{FeatureConsole, FeatureOIDC},
- },
- TierSelfHosted: {
- Tier: TierSelfHosted,
- Name: "Self Hosted",
- Deployment: DeploymentSelfHosted,
- Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
- Features: []string{FeatureConsole, FeatureOIDC},
- },
-}
-
-// PlanFor returns the seed plan for a tier.
-func PlanFor(tier string) (Plan, bool) {
- p, ok := plans[tier]
- return p, ok
-}
-```
-
-Note Free's `Deployment` is `cloud`. That single value is what makes Free cloud-only: verification rejects a deployment mismatch, so a self-hosted install can never hold a valid Free licence, and there is no server-side flag to edit.
-
-- [ ] **Step 3: Build**
-
-Run: `cd shared && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add shared/license/license.go shared/license/plans.go
-git commit -m "feat(license): add the licence payload and tier seed table"
-```
-
----
-
-### Task 3: Signing, and keeping it out of the server
-
-**Files:**
-
-- Create: `shared/license/keys.go`
-- Create: `shared/license/sign.go`
-
-**Interfaces:**
-
-- Consumes: `License`
-- Produces:
- - `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
- - `var trustedPublicKeys []string`
- - `func publicKeys() ([]*lk.PublicKey, error)`
-
-- [ ] **Step 1: Write the trusted key list**
-
-Create `shared/license/keys.go`:
-
-```go
-package license
-
-import (
- "fmt"
-
- "github.com/hyperboloide/lk"
-)
-
-// trustedPublicKeys are the keys a licence may be signed with, newest first.
-//
-// To rotate: prepend the new key, ship a server release that trusts both, then
-// reissue. Remove a retired key only once every licence signed with it has
-// expired.
-//
-// This is a slice from day one even though it holds one entry, because
-// retrofitting a single-key verifier into a multi-key one during an incident is
-// not a thing to plan for.
-//
-// These are compiled in and deliberately not configurable. A configurable trust
-// root is a licensing bypass: a self-hosted operator could point it at a keypair
-// they generated themselves.
-var trustedPublicKeys = []string{
- // Populated in Task 6 with the real production key.
- // Until then this slice is empty and every licence fails to verify,
- // which is the correct default for a build with no trust root.
-}
-
-func publicKeys() ([]*lk.PublicKey, error) {
- out := make([]*lk.PublicKey, 0, len(trustedPublicKeys))
- for i, s := range trustedPublicKeys {
- k, err := lk.PublicKeyFromB32String(s)
- if err != nil {
- return nil, fmt.Errorf("trusted public key %d is malformed: %w", i, err)
- }
- out = append(out, k)
- }
- return out, nil
-}
-```
-
-- [ ] **Step 2: Write the signer, excluded from the server build**
-
-Create `shared/license/sign.go`:
-
-```go
-//go:build !noSign
-
-package license
-
-import (
- "encoding/json"
- "fmt"
-
- "github.com/hyperboloide/lk"
-)
-
-// Sign marshals a licence and signs it, returning the base32 blob.
-//
-// This file carries the !noSign build tag so the signing path can be compiled
-// out of the control plane. The server has no reason to hold signing code and
-// no reason to ship it into a customer's data centre.
-//
-// privateKeyB32 comes from LICENSE_SIGNING_KEY on the issuing side only.
-func Sign(l License, privateKeyB32 string) (string, error) {
- if privateKeyB32 == "" {
- return "", fmt.Errorf("no signing key provided")
- }
- priv, err := lk.PrivateKeyFromB32String(privateKeyB32)
- if err != nil {
- return "", fmt.Errorf("parse signing key: %w", err)
- }
-
- data, err := json.Marshal(l)
- if err != nil {
- return "", fmt.Errorf("marshal licence: %w", err)
- }
-
- signed, err := lk.NewLicense(priv, data)
- if err != nil {
- return "", fmt.Errorf("sign licence: %w", err)
- }
-
- blob, err := signed.ToB32String()
- if err != nil {
- return "", fmt.Errorf("encode licence: %w", err)
- }
- return blob, nil
-}
-```
-
-- [ ] **Step 3: Build both ways**
-
-Run:
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go build ./... && go vet ./...
-go build -tags noSign ./...
-```
-
-Expected: no output from any of them.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add shared/license/keys.go shared/license/sign.go
-git commit -m "feat(license): add signing and the trusted key list"
-```
-
----
-
-### Task 4: Verification
-
-The heart of the system. Check order is part of the contract, because the reason drives the message a customer sees.
-
-**Files:**
-
-- Create: `shared/license/verify.go`
-
-**Interfaces:**
-
-- Consumes: `License`, `publicKeys()`
-- Produces:
- - `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
- - `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
- - `type Result struct { License License; State State; Reason string }`
- - `func Verify(blob string, opts VerifyOpts) Result`
- - `func Parse(blob string) (License, error)`
- - Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`
-
-- [ ] **Step 1: Write the verifier**
-
-Create `shared/license/verify.go`:
-
-```go
-package license
-
-import (
- "encoding/json"
- "fmt"
- "time"
-
- "github.com/hyperboloide/lk"
-)
-
-type State string
-
-const (
- StateValid State = "valid"
- StateExpired State = "expired"
- StateInvalid State = "invalid"
-)
-
-// Reasons a licence is not valid. These are stable identifiers: the API returns
-// them and the UI maps them to messages, so do not reword them casually.
-const (
- ReasonNoLicense = "no_license"
- ReasonBadSignature = "bad_signature"
- ReasonDeploymentMismatch = "deployment_mismatch"
- ReasonInstanceMismatch = "instance_mismatch"
- ReasonExpired = "expired"
-)
-
-// VerifyOpts is what the verifier knows about itself.
-type VerifyOpts struct {
- InstanceID string // this instance's own ID; required
- Deployment string // "cloud" or "self_hosted"; required
- Now time.Time // zero means time.Now()
-}
-
-type Result struct {
- License License
- State State
- Reason string
- // ClockSkewed is set when IssuedAt is in the future, which usually means
- // the host clock is wrong. It does not by itself invalidate the licence.
- ClockSkewed bool
-}
-
-// Verify checks a licence blob against this instance.
-//
-// The checks run in a fixed order and stop at the first failure:
-//
-// 1. signature against a trusted public key -> bad_signature
-// 2. deployment matches this install -> deployment_mismatch
-// 3. instance ID matches this instance -> instance_mismatch
-// 4. not past ExpiresAt -> expired
-//
-// The order matters. A blob that is both expired and bound to another instance
-// reports instance_mismatch, not expired, because that is the more useful thing
-// to tell the person holding it.
-//
-// No clock tolerance is applied. Terms are a month or a year; a host whose clock
-// is wrong by enough to matter has larger problems, and a tolerance window is a
-// thing to get wrong.
-func Verify(blob string, opts VerifyOpts) Result {
- if blob == "" {
- return Result{State: StateInvalid, Reason: ReasonNoLicense}
- }
-
- l, err := Parse(blob)
- if err != nil {
- return Result{State: StateInvalid, Reason: ReasonBadSignature}
- }
-
- res := Result{License: l}
-
- if l.Deployment != opts.Deployment {
- res.State, res.Reason = StateInvalid, ReasonDeploymentMismatch
- return res
- }
- if l.InstanceID != opts.InstanceID {
- res.State, res.Reason = StateInvalid, ReasonInstanceMismatch
- return res
- }
-
- now := opts.Now
- if now.IsZero() {
- now = time.Now()
- }
- res.ClockSkewed = l.IssuedAt.After(now)
-
- if !now.Before(l.ExpiresAt) {
- res.State, res.Reason = StateExpired, ReasonExpired
- return res
- }
-
- res.State = StateValid
- return res
-}
-
-// Parse verifies the signature only, ignoring binding and expiry.
-//
-// Used to display a licence and to inspect a blob a customer has emailed in.
-// Never use it for enforcement — it does not check who the licence is for.
-func Parse(blob string) (License, error) {
- parsed, err := lk.LicenseFromB32String(blob)
- if err != nil {
- return License{}, fmt.Errorf("licence is not readable: %w", err)
- }
-
- keys, err := publicKeys()
- if err != nil {
- return License{}, err
- }
- if len(keys) == 0 {
- return License{}, fmt.Errorf("this build trusts no licence signing keys")
- }
-
- verified := false
- for _, k := range keys {
- ok, err := parsed.Verify(k)
- if err == nil && ok {
- verified = true
- break
- }
- }
- if !verified {
- return License{}, fmt.Errorf("licence signature does not match any trusted key")
- }
-
- var l License
- if err := json.Unmarshal(parsed.Data, &l); err != nil {
- return License{}, fmt.Errorf("licence contents are not readable: %w", err)
- }
- return l, nil
-}
-```
-
-- [ ] **Step 2: Build both ways**
-
-Run:
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go build ./... && go vet ./...
-go build -tags noSign ./...
-```
-
-Expected: no output. The `noSign` build must succeed — `verify.go` must not reference anything in `sign.go`.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add shared/license/verify.go
-git commit -m "feat(license): add offline verification"
-```
-
----
-
-### Task 5: lkctl
-
-The production issuance path until the admin service exists, and the disaster-recovery path forever after — if admin is down and a customer's licence expires, a blob can still be cut by hand.
-
-**Files:**
-
-- Create: `shared/cmd/lkctl/main.go`
-
-**Interfaces:**
-
-- Consumes: `Sign`, `Parse`, `PlanFor`, `License`
-- Produces: the `lkctl` binary
-
-- [ ] **Step 1: Write it**
-
-Create `shared/cmd/lkctl/main.go`:
-
-```go
-// Command lkctl issues and inspects Vantage licences by hand.
-//
-// lkctl keypair
-// lkctl issue --instance-id= --instance-name="Acme" --tier=professional --term=1y
-// lkctl inspect
-//
-// issue reads the signing key from LICENSE_SIGNING_KEY.
-package main
-
-import (
- "encoding/json"
- "flag"
- "fmt"
- "os"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/hyperboloide/lk"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
-)
-
-func main() {
- if len(os.Args) < 2 {
- usage()
- }
- switch os.Args[1] {
- case "keypair":
- keypair()
- case "issue":
- issue(os.Args[2:])
- case "inspect":
- inspect(os.Args[2:])
- default:
- usage()
- }
-}
-
-func usage() {
- fmt.Fprintln(os.Stderr, "usage: lkctl keypair | issue | inspect")
- os.Exit(2)
-}
-
-func keypair() {
- priv, err := lk.NewPrivateKey()
- if err != nil {
- fatal("generate key: %v", err)
- }
- privStr, err := priv.ToB32String()
- if err != nil {
- fatal("encode private key: %v", err)
- }
- pubStr, err := priv.GetPublicKey().ToB32String()
- if err != nil {
- fatal("encode public key: %v", err)
- }
-
- fmt.Println("PRIVATE KEY (store in a password manager and in the admin service's")
- fmt.Println("LICENSE_SIGNING_KEY; back it up in two places, it cannot be recovered):")
- fmt.Println()
- fmt.Println(privStr)
- fmt.Println()
- fmt.Println("PUBLIC KEY (paste into trustedPublicKeys in shared/license/keys.go):")
- fmt.Println()
- fmt.Println(pubStr)
-}
-
-func issue(args []string) {
- fs := flag.NewFlagSet("issue", flag.ExitOnError)
- instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
- instanceName := fs.String("instance-name", "", "display name")
- accountID := fs.String("account-id", "", "admin-side account id, optional")
- tier := fs.String("tier", "", "free | professional | self_hosted (required)")
- term := fs.String("term", "1y", "1m or 1y")
- expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
- out := fs.String("out", "", "write the blob to this file instead of stdout")
- fs.Parse(args)
-
- if *instanceID == "" || *tier == "" {
- fatal("--instance-id and --tier are required")
- }
-
- plan, ok := license.PlanFor(*tier)
- if !ok {
- fatal("unknown tier %q", *tier)
- }
-
- key := os.Getenv("LICENSE_SIGNING_KEY")
- if key == "" {
- fatal("LICENSE_SIGNING_KEY is not set")
- }
-
- now := time.Now().UTC()
- var exp time.Time
- switch {
- case *expires != "":
- t, err := time.Parse(time.RFC3339, *expires)
- if err != nil {
- fatal("parse --expires: %v", err)
- }
- exp = t.UTC()
- case *term == "1m":
- exp = now.AddDate(0, 1, 0)
- case *term == "1y":
- exp = now.AddDate(1, 0, 0)
- default:
- fatal("--term must be 1m or 1y")
- }
-
- // Self Hosted is sold annually only, so the window in which a cancelled
- // licence keeps working is bounded at a year.
- if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
- fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
- }
-
- name := *instanceName
- if name == "" {
- name = *instanceID
- }
-
- l := license.License{
- ID: uuid.NewString(),
- InstanceID: *instanceID,
- AccountID: *accountID,
- InstanceName: name,
- Tier: plan.Tier,
- Deployment: plan.Deployment,
- IssuedAt: now,
- ExpiresAt: exp,
- Limits: plan.Limits,
- Features: plan.Features,
- }
-
- blob, err := license.Sign(l, key)
- if err != nil {
- fatal("%v", err)
- }
-
- if *out != "" {
- if err := os.WriteFile(*out, []byte(blob+"\n"), 0o600); err != nil {
- fatal("write %s: %v", *out, err)
- }
- fmt.Fprintf(os.Stderr, "wrote %s (tier=%s deployment=%s expires=%s)\n",
- *out, l.Tier, l.Deployment, l.ExpiresAt.Format(time.RFC3339))
- return
- }
- fmt.Println(blob)
-}
-
-func inspect(args []string) {
- if len(args) < 1 {
- fatal("usage: lkctl inspect ")
- }
- blob := args[0]
- if b, err := os.ReadFile(blob); err == nil {
- blob = strings.TrimSpace(string(b))
- }
-
- l, err := license.Parse(blob)
- if err != nil {
- fatal("%v", err)
- }
-
- enc := json.NewEncoder(os.Stdout)
- enc.SetIndent("", " ")
- if err := enc.Encode(l); err != nil {
- fatal("%v", err)
- }
-
- if time.Now().After(l.ExpiresAt) {
- fmt.Fprintf(os.Stderr, "\nNOTE: expired %s\n", l.ExpiresAt.Format(time.RFC3339))
- }
-}
-
-func fatal(format string, args ...any) {
- fmt.Fprintf(os.Stderr, format+"\n", args...)
- os.Exit(1)
-}
-```
-
-- [ ] **Step 2: Build**
-
-Run: `cd shared && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add shared/cmd/lkctl
-git commit -m "feat(license): add lkctl for issuing licences by hand"
-```
-
----
-
-### Task 6: Generate the production keypair and wire it in
-
-This is the step that makes the system real. Do it once, carefully.
-
-**Files:**
-
-- Modify: `shared/license/keys.go`
-
-**Interfaces:**
-
-- Consumes: `lkctl keypair`
-- Produces: a populated `trustedPublicKeys`
-
-- [ ] **Step 1: Generate the keypair**
-
-Run: `cd shared && go run ./cmd/lkctl keypair`
-
-- [ ] **Step 2: Store the private key**
-
-Put the private key in **two** places before continuing:
-
-1. A password manager entry named "Vantage licence signing key (production)".
-2. The admin service's secret store, ready for `LICENSE_SIGNING_KEY` in spec 3.
-
-**If this key is lost, no new licence can be issued for any existing customer without shipping a server release.** There is no recovery. Confirm both copies exist and are readable before moving on.
-
-- [ ] **Step 3: Paste the public key in**
-
-In `shared/license/keys.go`, replace the empty slice:
-
-```go
-var trustedPublicKeys = []string{
- // Production signing key, generated 2026-07-24. Index 0 is current.
- "",
-}
-```
-
-- [ ] **Step 4: Confirm a full round trip**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-export LICENSE_SIGNING_KEY=''
-go run ./cmd/lkctl issue \
- --instance-id=11111111-2222-3333-4444-555555555555 \
- --instance-name="Round Trip Ltd" \
- --tier=professional --term=1y > /tmp/rt.lic
-go run ./cmd/lkctl inspect /tmp/rt.lic
-```
-
-Expected: JSON showing `"tier": "professional"`, `"deployment": "cloud"`, the instance ID you passed, `max_servers: -1`, and `features` containing `console` and `oidc`.
-
-- [ ] **Step 5: Confirm the rejections**
-
-Create a temporary `shared/cmd/vercheck/main.go`:
-
-```go
-package main
-
-import (
- "fmt"
- "os"
- "strings"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
-)
-
-func main() {
- b, err := os.ReadFile(os.Args[1])
- if err != nil {
- panic(err)
- }
- blob := strings.TrimSpace(string(b))
-
- const good = "11111111-2222-3333-4444-555555555555"
-
- show := func(label string, r license.Result) {
- fmt.Printf("%-22s state=%-8s reason=%s\n", label, r.State, r.Reason)
- }
-
- show("correct", license.Verify(blob, license.VerifyOpts{
- InstanceID: good, Deployment: license.DeploymentCloud}))
-
- show("wrong instance", license.Verify(blob, license.VerifyOpts{
- InstanceID: "99999999-9999-9999-9999-999999999999", Deployment: license.DeploymentCloud}))
-
- show("wrong deployment", license.Verify(blob, license.VerifyOpts{
- InstanceID: good, Deployment: license.DeploymentSelfHosted}))
-
- show("expired", license.Verify(blob, license.VerifyOpts{
- InstanceID: good, Deployment: license.DeploymentCloud,
- Now: time.Now().AddDate(2, 0, 0)}))
-
- show("tampered", license.Verify(blob[:len(blob)-2]+"AA", license.VerifyOpts{
- InstanceID: good, Deployment: license.DeploymentCloud}))
-
- show("empty", license.Verify("", license.VerifyOpts{
- InstanceID: good, Deployment: license.DeploymentCloud}))
-}
-```
-
-Run: `cd shared && go run ./cmd/vercheck /tmp/rt.lic`
-
-Expected, exactly:
-
-```
-correct state=valid reason=
-wrong instance state=invalid reason=instance_mismatch
-wrong deployment state=invalid reason=deployment_mismatch
-expired state=expired reason=expired
-tampered state=invalid reason=bad_signature
-empty state=invalid reason=no_license
-```
-
-**`wrong deployment` reporting `deployment_mismatch` rather than `instance_mismatch` proves the check order.** That order is what makes Free cloud-only.
-
-- [ ] **Step 6: Confirm a Free licence cannot be issued for self-hosted use**
-
-```bash
-cd shared
-go run ./cmd/lkctl issue --instance-id=$(uuidgen 2>/dev/null || echo 11111111-2222-3333-4444-555555555555) \
- --tier=free --term=1m > /tmp/free.lic
-go run ./cmd/vercheck /tmp/free.lic
-```
-
-Expected: the `correct` line reports `valid` only when the instance matches; the `wrong deployment` line — which asks for `self_hosted` — reports `deployment_mismatch`. A Free licence is signed `deployment: cloud` and can never satisfy a self-hosted install.
-
-- [ ] **Step 7: Confirm Self Hosted refuses a monthly term**
-
-Run: `cd shared && go run ./cmd/lkctl issue --instance-id=x --tier=self_hosted --term=1m`
-Expected: `self_hosted is annual only; use --term=1y or an explicit --expires`, exit 1.
-
-- [ ] **Step 8: Confirm the signer is absent from a noSign build**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go build -tags noSign -o /tmp/nosign-probe ./cmd/lkctl 2>&1 | head -3
-```
-
-Expected: a **compile error** naming `license.Sign` as undefined. That failure is the proof the build tag works — `lkctl` needs the signer, so a `noSign` build of it must not link.
-
-- [ ] **Step 9: Clean up and commit**
-
-```bash
-cd c:/Work/Repos/vantage
-rm -rf shared/cmd/vercheck /tmp/rt.lic /tmp/free.lic
-unset LICENSE_SIGNING_KEY
-git add shared/license/keys.go
-git commit -m "feat(license): trust the production signing key"
-```
-
-**Do not commit the private key. Check the diff before committing.**
-
----
-
-### Task 7: Final verification
-
-**Files:** none
-
-- [ ] **Step 1: Build every module**
-
-```bash
-cd c:/Work/Repos/vantage
-(cd shared && go build ./... && go vet ./...)
-(cd server && go build ./... && go vet ./...)
-(cd sitesvc && go build ./... && go vet ./...)
-(cd agent && GOWORK=off go build ./... && GOWORK=off go vet ./...)
-```
-
-Expected: no output.
-
-- [ ] **Step 2: Confirm the images still build**
-
-```bash
-cd c:/Work/Repos/vantage
-docker build -q -f server/Dockerfile -t vantage-server:lic .
-docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:lic .
-```
-
-Expected: both print an image digest.
-
-- [ ] **Step 3: Confirm the signing key is nowhere in the repo**
-
-```bash
-cd c:/Work/Repos/vantage
-git log -p --all | grep -c "LICENSE_SIGNING_KEY='" || true
-grep -rn "$(echo YOUR_PRIVATE_KEY_PREFIX)" --include=* . 2>/dev/null | head -3
-```
-
-Expected: no commit contains the private key. Check the first 12 characters of the real private key against the working tree and the log before considering this done.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add -A
-git commit -m "chore: verify licensing core end to end" --allow-empty
-```
-
-## What this unblocks
-
-Plan 2 (`instance-licensing`) can store a blob on the instance document, resolve
-it into a runtime state, and gate the API on it — with real licences to test
-against, cut by `lkctl`, before any admin service exists.
diff --git a/docs/superpowers/plans/2026-07-24-shared-module.md b/docs/superpowers/plans/2026-07-24-shared-module.md
deleted file mode 100644
index da90ab9..0000000
--- a/docs/superpowers/plans/2026-07-24-shared-module.md
+++ /dev/null
@@ -1,1356 +0,0 @@
-# Shared Module Extraction 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:** Extract a `shared` Go module holding the document shapes and provisioning rules that `server` and `sitesvc` both write, deleting the hand-copied duplicates in sitesvc.
-
-**Architecture:** A new Go module `gitea.hostxtra.co.uk/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve.
-
-**Tech Stack:** Go 1.26, MongoDB driver v2, `golang.org/x/crypto/bcrypt`, `github.com/google/uuid`, Docker, Gitea Actions.
-
-**No automated tests.** Verification is by compiler, `grep`, and running the two services end to end against a scratch database. Every task ends with checks that produce observable output.
-
-## Global Constraints
-
-- Go version in every `go.mod`: `go 1.26`
-- Module path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared`
-- `shared/go.mod` may require **only** these three: `go.mongodb.org/mongo-driver/v2`, `golang.org/x/crypto`, `github.com/google/uuid`. Any addition needs review — this constraint is what keeps sitesvc small.
-- `MinSlugLength = 3`, `MaxSlugLength = 40`, `BcryptCost = 12` — exact values, no literals elsewhere.
-- Reserved slugs, exactly: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, `default`
-- User-facing error text uses **"organisation"** (British spelling) everywhere. The control plane currently says "organization"; that changes in this plan.
-- **No renaming.** `Org`, `OrgID`, `org_id`, `orgs` stay exactly as they are. Renaming is plan 0b.
-- **No behaviour changes** other than the two explicitly listed in Task 3.
-- The `agent` module is not touched by any task in this plan.
-- A local MongoDB is needed for the end-to-end checks: `docker run -d -p 27017:27017 --name vantage-dev-mongo mongo:7`
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| -------------------------------- | -------------------------------------------------------------------- |
-| `go.work` | Workspace over shared, server, sitesvc |
-| `shared/go.mod`, `shared/go.sum` | Module definition |
-| `shared/models/org.go` | `Org` document |
-| `shared/models/user.go` | `User` document, role constants, `ValidRole` |
-| `shared/models/settings.go` | `Settings` and sub-structs |
-| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants |
-| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` |
-| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` |
-| `shared/indexes/indexes.go` | `EnsureCoreIndexes` |
-
-**Modified:**
-
-| Path | Change |
-| --------------------------------------------------------- | --------------------------------------- |
-| `server/go.mod` | require + replace shared |
-| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared |
-| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared |
-| `server/internal/services/users.go` | `CreateUser` delegates to shared |
-| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared |
-| `server/Dockerfile` | Build from repo root |
-| `sitesvc/go.mod` | require + replace shared |
-| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` |
-| `sitesvc/internal/store/store.go` | Uses shared provision |
-| `sitesvc/Dockerfile` | Build from repo root |
-| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images |
-
-**Deleted:**
-
-| Path |
-| ----------------------------------------- |
-| `sitesvc/internal/provision/provision.go` |
-
----
-
-### Task 1: Scaffold the shared module
-
-**Files:**
-
-- Create: `shared/go.mod`
-- Create: `go.work`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces: an importable but empty module
-
-- [ ] **Step 1: Create the module**
-
-```bash
-cd c:/Work/Repos/vantage
-mkdir -p shared
-cd shared
-go mod init gitea.hostxtra.co.uk/mrhid6/vantage/shared
-go get go.mongodb.org/mongo-driver/v2@latest
-go get golang.org/x/crypto@latest
-go get github.com/google/uuid@latest
-```
-
-- [ ] **Step 2: Create the workspace**
-
-Create `go.work` at the repo root:
-
-```
-go 1.26
-
-use (
- ./shared
- ./server
- ./sitesvc
-)
-```
-
-`./agent` is deliberately absent. The agent stays standalone with its own release pipeline.
-
-- [ ] **Step 3: Verify the workspace resolves**
-
-Run: `cd c:/Work/Repos/vantage && go work sync && go list -m all | head -5`
-Expected: output includes `gitea.hostxtra.co.uk/mrhid6/vantage/shared`, `gitea.hostxtra.co.uk/mrhid6/vantage/server` and `gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc`. No error.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add go.work shared/go.mod shared/go.sum
-git commit -m "chore: scaffold shared module"
-```
-
----
-
-### Task 2: Slug rules and models
-
-Pure definitions with no database access. Grouped into one task because neither is independently reviewable — a struct with no consumer and a regex with no caller are the same review.
-
-**Files:**
-
-- Create: `shared/provision/slug.go`
-- Create: `shared/models/org.go`
-- Create: `shared/models/user.go`
-- Create: `shared/models/settings.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces:
- - `const MinSlugLength = 3`, `MaxSlugLength = 40`
- - `var ReservedSlugs map[string]bool`
- - `func Slugify(name string) string`
- - `func BaseSlug(name string) (string, error)`
- - `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2`
- - `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings`
- - `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool`
-
-- [ ] **Step 1: Write the slug rules**
-
-Create `shared/provision/slug.go`:
-
-```go
-// Package provision holds the tenant creation rules shared by the control
-// plane and sitesvc.
-//
-// These rules used to be duplicated: the control plane owned one copy and
-// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried
-// on a lost slug race while the control plane returned an error. This package
-// is the single definition; neither service may reimplement any of it.
-package provision
-
-import (
- "fmt"
- "regexp"
- "strings"
-)
-
-const (
- MinSlugLength = 3
- MaxSlugLength = 40
-)
-
-var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
-
-// ReservedSlugs are subdomain labels the platform needs for itself.
-var ReservedSlugs = map[string]bool{
- "www": true, "api": true, "app": true, "admin": true, "auth": true,
- "install": true, "static": true, "_next": true, "default": true,
-}
-
-// Slugify lowercases a name and collapses every run of non-alphanumeric
-// characters into a single hyphen, trimming hyphens from both ends.
-func Slugify(name string) string {
- s := strings.ToLower(name)
- s = slugStrip.ReplaceAllString(s, "-")
- return strings.Trim(s, "-")
-}
-
-// BaseSlug turns a name into a validated slug stem, or explains why it cannot.
-func BaseSlug(name string) (string, error) {
- base := Slugify(name)
- if len(base) < MinSlugLength {
- return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength)
- }
- if len(base) > MaxSlugLength {
- base = base[:MaxSlugLength]
- }
- if ReservedSlugs[base] {
- return "", fmt.Errorf("that organisation name is reserved")
- }
- return base, nil
-}
-
-// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the
-// base itself; later attempts append a counter.
-func NextSlug(base string, attempt int) string {
- if attempt < 2 {
- return base
- }
- return fmt.Sprintf("%s-%d", base, attempt)
-}
-```
-
-- [ ] **Step 2: Create the Org model**
-
-Create `shared/models/org.go`:
-
-```go
-// Package models holds the MongoDB documents written by more than one Vantage
-// service. Documents only the control plane touches stay in
-// server/internal/models.
-package models
-
-import (
- "time"
-
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-type Org struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
- OrgID string `bson:"org_id" json:"org_id"`
- Name string `bson:"name" json:"name"`
- Slug string `bson:"slug" json:"slug"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
-}
-```
-
-- [ ] **Step 3: Create the User model**
-
-Create `shared/models/user.go`:
-
-```go
-package models
-
-import (
- "time"
-
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-const (
- RoleOwner = "owner"
- RoleAdmin = "admin"
- RoleMember = "member"
-)
-
-func ValidRole(role string) bool {
- switch role {
- case RoleOwner, RoleAdmin, RoleMember:
- return true
- }
- return false
-}
-
-type User struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
- UserID string `bson:"user_id" json:"user_id"`
- OrgID string `bson:"org_id" json:"org_id"`
- Email string `bson:"email" json:"email"`
- PasswordHash string `bson:"password_hash,omitempty" json:"-"`
- Role string `bson:"role" json:"role"`
- AuthSource string `bson:"auth_source" json:"auth_source"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
- LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
-}
-```
-
-- [ ] **Step 4: Create the Settings model**
-
-Create `shared/models/settings.go`:
-
-```go
-package models
-
-import (
- "time"
-
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-type AlertSettings struct {
- Enabled bool `bson:"enabled" json:"enabled"`
- WebhookURL string `bson:"webhook_url" json:"webhook_url"`
- OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
-}
-
-type EmailSettings struct {
- Enabled bool `bson:"enabled" json:"enabled"`
- SMTPHost string `bson:"smtp_host" json:"smtp_host"`
- SMTPPort int `bson:"smtp_port" json:"smtp_port"`
- Username string `bson:"username" json:"username"`
- Password string `bson:"password" json:"password"`
- FromAddr string `bson:"from_addr" json:"from_addr"`
- ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
- UseTLS bool `bson:"use_tls" json:"use_tls"`
-}
-
-type SecretsSettings struct {
- ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
- ReadTokenSet bool `bson:"-" json:"read_token_set"`
- RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
-}
-
-type Settings struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
- OrgID string `bson:"org_id" json:"org_id"`
- Alerts AlertSettings `bson:"alerts" json:"alerts"`
- Email EmailSettings `bson:"email" json:"email"`
- Secrets SecretsSettings `bson:"secrets" json:"secrets"`
-
- WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
-}
-```
-
-- [ ] **Step 5: Verify the bson tags match production exactly**
-
-Without tests, this diff is the only thing standing between you and orphaned production data. A changed bson tag means every existing document silently stops matching.
-
-```bash
-cd c:/Work/Repos/vantage
-diff <(grep -o 'bson:"[^"]*"' server/internal/models/org.go) \
- <(grep -o 'bson:"[^"]*"' shared/models/org.go)
-diff <(grep -o 'bson:"[^"]*"' server/internal/models/user.go) \
- <(grep -o 'bson:"[^"]*"' shared/models/user.go)
-diff <(grep -o 'bson:"[^"]*"' server/internal/models/settings.go) \
- <(grep -o 'bson:"[^"]*"' shared/models/settings.go)
-```
-
-Expected: **no output from any of the three.** Any output is a defect — stop and fix it before continuing.
-
-- [ ] **Step 6: Sanity-check the slug rules by hand**
-
-Create a throwaway `shared/cmd/slugcheck/main.go`:
-
-```go
-package main
-
-import (
- "fmt"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
-)
-
-func main() {
- for _, n := range []string{"Acme", "Acme Corp", "ACME CORP", "Acme & Co.", " Acme ", "---Acme---", "!!!", "ab", "admin"} {
- base, err := provision.BaseSlug(n)
- fmt.Printf("%-14q slugify=%-12q base=%-12q err=%v\n", n, provision.Slugify(n), base, err)
- }
- fmt.Println(provision.NextSlug("acme", 1), provision.NextSlug("acme", 2), provision.NextSlug("acme", 3))
-}
-```
-
-Run: `cd shared && go run ./cmd/slugcheck`
-
-Expected output:
-
-```
-"Acme" slugify="acme" base="acme" err=
-"Acme Corp" slugify="acme-corp" base="acme-corp" err=
-"ACME CORP" slugify="acme-corp" base="acme-corp" err=
-"Acme & Co." slugify="acme-co" base="acme-co" err=
-" Acme " slugify="acme" base="acme" err=
-"---Acme---" slugify="acme" base="acme" err=
-"!!!" slugify="" base="" err=organisation name too short (slug must be at least 3 characters)
-"ab" slugify="ab" base="" err=organisation name too short (slug must be at least 3 characters)
-"admin" slugify="admin" base="" err=that organisation name is reserved
-acme acme-2 acme-3
-```
-
-Then delete it: `rm -rf shared/cmd/slugcheck`
-
-- [ ] **Step 7: Build and commit**
-
-```bash
-cd c:/Work/Repos/vantage/shared && go build ./... && go vet ./...
-cd ..
-git add shared/provision/slug.go shared/models
-git commit -m "feat(shared): add slug rules and shared document models"
-```
-
----
-
-### Task 3: Provisioning
-
-The single `CreateOrg`, `RollbackOrg` and `CreateUser`. **Two deliberate behaviour changes**, both adopting sitesvc's version because it is the correct one:
-
-1. On a duplicate-key race the slug loop **retries the next slug** instead of returning an error. The control plane previously failed the request.
-2. Error text uses "organisation", not "organization".
-
-**Files:**
-
-- Create: `shared/provision/org.go`
-- Create: `shared/provision/user.go`
-
-**Interfaces:**
-
-- Consumes: `models.Org`, `models.User`, `models.ValidRole`, `provision.BaseSlug`, `provision.NextSlug`
-- Produces:
- - `var ErrNameRejected error`, `var ErrEmailTaken error`
- - `const BcryptCost = 12`
- - `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)`
- - `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error`
- - `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)`
- - `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)`
-
-`CreateUserWithHash` exists because sitesvc hashes the password at signup time and stores the hash in the pending record; by verification time it holds a hash, not a password. Without it sitesvc would have to insert the document by hand, which is the duplication this plan removes.
-
-- [ ] **Step 1: Write org provisioning**
-
-Create `shared/provision/org.go`:
-
-```go
-package provision
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/google/uuid"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
-)
-
-// ErrNameRejected wraps every reason a name cannot become an organisation.
-var ErrNameRejected = errors.New("organisation name rejected")
-
-const maxSlugAttempts = 50
-
-// CreateOrg inserts an organisation under the first free slug derived from name.
-//
-// The count-then-insert loop is racy on its own. It is safe only because
-// orgs.slug carries a unique index: a lost race surfaces as a duplicate-key
-// error, which we treat as "that slug is taken" and retry. Do not remove the
-// duplicate-key branch, and do not remove the index.
-func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) {
- base, err := BaseSlug(name)
- if err != nil {
- return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
- }
-
- for attempt := 1; attempt <= maxSlugAttempts; attempt++ {
- slug := NextSlug(base, attempt)
-
- n, err := db.Collection("orgs").CountDocuments(ctx, bson.M{"slug": slug})
- if err != nil {
- return nil, err
- }
- if n > 0 {
- continue
- }
-
- org := models.Org{
- OrgID: uuid.NewString(),
- Name: name,
- Slug: slug,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Collection("orgs").InsertOne(ctx, org); err != nil {
- if mongo.IsDuplicateKeyError(err) {
- continue // lost the race; try the next slug
- }
- return nil, err
- }
- return &org, nil
- }
- return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
-}
-
-// RollbackOrg deletes an organisation that has no users.
-//
-// It refuses an organisation that has users. Rollback exists to clean up a
-// half-finished signup, and an organisation with users is not half-finished.
-func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error {
- n, err := db.Collection("users").CountDocuments(ctx, bson.M{"org_id": orgID})
- if err != nil {
- return err
- }
- if n > 0 {
- return fmt.Errorf("refusing to roll back organisation %s: it has %d user(s)", orgID, n)
- }
- _, err = db.Collection("orgs").DeleteOne(ctx, bson.M{"org_id": orgID})
- return err
-}
-```
-
-- [ ] **Step 2: Write user provisioning**
-
-Create `shared/provision/user.go`:
-
-```go
-package provision
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "golang.org/x/crypto/bcrypt"
-)
-
-// BcryptCost is the work factor for every password hash Vantage writes.
-// Changing it changes nothing about existing hashes, which carry their own cost.
-const BcryptCost = 12
-
-// ErrEmailTaken is returned when the unique index on users.email rejects an insert.
-var ErrEmailTaken = errors.New("email already registered")
-
-// CreateUser hashes password and inserts the user. An empty password leaves the
-// hash empty, which is how OIDC users are stored.
-func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error) {
- var hash string
- if password != "" {
- b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
- if err != nil {
- return nil, err
- }
- hash = string(b)
- }
- return CreateUserWithHash(ctx, db, orgID, email, hash, role, authSource)
-}
-
-// CreateUserWithHash inserts a user whose password was already hashed
-// elsewhere. sitesvc hashes at signup and only holds the hash by the time the
-// verification link is opened.
-func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error) {
- email = strings.ToLower(strings.TrimSpace(email))
- if email == "" {
- return nil, fmt.Errorf("email required")
- }
- if !models.ValidRole(role) {
- return nil, fmt.Errorf("invalid role %q", role)
- }
-
- u := &models.User{
- UserID: uuid.NewString(),
- OrgID: orgID,
- Email: email,
- PasswordHash: passwordHash,
- Role: role,
- AuthSource: authSource,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Collection("users").InsertOne(ctx, u); err != nil {
- if mongo.IsDuplicateKeyError(err) {
- return nil, ErrEmailTaken
- }
- return nil, err
- }
- return u, nil
-}
-```
-
-- [ ] **Step 3: Build**
-
-Run: `cd shared && go build ./... && go vet ./...`
-Expected: no output.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add shared/provision/org.go shared/provision/user.go
-git commit -m "feat(shared): add CreateOrg, RollbackOrg and CreateUser
-
-Adopts sitesvc's retry-on-duplicate-key slug loop. The control plane
-previously returned an error when it lost the slug race."
-```
-
----
-
-### Task 4: Core indexes
-
-`users.email` and `orgs.slug` unique indexes are a tenant-isolation property, not an optimisation — `GetUserByEmail` does an unscoped `FindOne`, so a duplicate email would let the OIDC cross-org guard compare against an arbitrary user. Both services declare them, and both treat failure as fatal.
-
-**Files:**
-
-- Create: `shared/indexes/indexes.go`
-
-**Interfaces:**
-
-- Consumes: nothing
-- Produces: `func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error`
-
-- [ ] **Step 1: Write it**
-
-Create `shared/indexes/indexes.go`:
-
-```go
-// Package indexes declares the MongoDB indexes more than one Vantage service
-// depends on.
-package indexes
-
-import (
- "context"
- "fmt"
-
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
-)
-
-// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug.
-//
-// These are a security property, not an optimisation. GetUserByEmail does an
-// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard
-// compare against an arbitrary user. Every caller must treat a failure here as
-// fatal.
-//
-// Creating an index that already exists with the same specification is a no-op,
-// so this is safe to call at every boot from every service.
-func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
- if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "email", Value: 1}},
- Options: options.Index().SetUnique(true),
- }); err != nil {
- return fmt.Errorf("users.email index: %w", err)
- }
-
- if _, err := db.Collection("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "slug", Value: 1}},
- Options: options.Index().SetUnique(true),
- }); err != nil {
- return fmt.Errorf("orgs.slug index: %w", err)
- }
-
- return nil
-}
-```
-
-- [ ] **Step 2: Build and confirm the dependency list is still clean**
-
-```bash
-cd c:/Work/Repos/vantage/shared
-go build ./... && go vet ./...
-go list -m all | grep -Ev "^gitea.hostxtra.co.uk/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org"
-```
-
-Expected: no output from the build, and no unexpected module from the list. Gin, redis or guac appearing means something was moved into `shared` that should not have been.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add shared/indexes
-git commit -m "feat(shared): add EnsureCoreIndexes"
-```
-
----
-
-### Task 5: Wire the control plane to shared
-
-Server keeps its own package paths so no call site outside these files changes. `server/internal/models` re-exports the shared types as aliases — a type alias is identical to the aliased type, so `models.Org` in existing server code keeps working untouched.
-
-**Files:**
-
-- Modify: `server/go.mod`
-- Modify: `server/internal/models/org.go`, `user.go`, `settings.go`
-- Modify: `server/internal/services/orgs.go` (`CreateOrg`, `AdoptOrg`, delete `reservedSlugs`)
-- Modify: `server/internal/services/users.go` (`CreateUser`)
-- Modify: `server/internal/services/migrate.go` (`EnsureAuthIndexes`)
-
-**Interfaces:**
-
-- Consumes: everything from Tasks 2–4
-- Produces: no new exported API. `services.CreateOrg(name string) (*models.Org, error)` and `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` keep their exact signatures.
-
-- [ ] **Step 1: Add the dependency**
-
-```bash
-cd c:/Work/Repos/vantage/server
-go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
-go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
-go mod tidy
-```
-
-- [ ] **Step 2: Alias the models**
-
-Replace the entire contents of `server/internal/models/org.go` with:
-
-```go
-package models
-
-import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
-
-// Org is defined in the shared module because sitesvc writes the same
-// documents. Aliased rather than re-declared so existing call sites are
-// unchanged and the two services cannot drift.
-type Org = shared.Org
-```
-
-Replace the entire contents of `server/internal/models/user.go` with:
-
-```go
-package models
-
-import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
-
-type User = shared.User
-
-const (
- RoleOwner = shared.RoleOwner
- RoleAdmin = shared.RoleAdmin
- RoleMember = shared.RoleMember
-)
-
-func ValidRole(role string) bool { return shared.ValidRole(role) }
-```
-
-Replace the entire contents of `server/internal/models/settings.go` with:
-
-```go
-package models
-
-import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
-
-type (
- Settings = shared.Settings
- AlertSettings = shared.AlertSettings
- EmailSettings = shared.EmailSettings
- SecretsSettings = shared.SecretsSettings
-)
-```
-
-- [ ] **Step 3: Verify the build still passes**
-
-Run: `cd server && go build ./...`
-Expected: no output. If anything fails, an alias is missing — add it rather than reverting.
-
-- [ ] **Step 4: Delegate CreateOrg**
-
-In `server/internal/services/orgs.go`, delete the `reservedSlugs` package variable and replace the whole `CreateOrg` function with:
-
-```go
-// CreateOrg creates an organisation and seeds its default workflow steps.
-//
-// The creation rules live in shared/provision because sitesvc creates
-// organisations too. Seeding stays here: shared must not know about workflow
-// steps.
-func CreateOrg(name string) (*models.Org, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- o, err := provision.CreateOrg(ctx, db.Database, name)
- if err != nil {
- return nil, err
- }
-
- if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil {
- log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err)
- } else {
- log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated)
- }
- return o, nil
-}
-```
-
-`AdoptOrg` also references `reservedSlugs` and `Slugify` and hard-codes the lengths `3` and `40`. Change its references to `provision.ReservedSlugs`, `provision.Slugify`, `provision.MinSlugLength` and `provision.MaxSlugLength`.
-
-Add to the import block:
-
-```go
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
-```
-
-Remove `"github.com/google/uuid"` and `"go.mongodb.org/mongo-driver/v2/mongo"` **only if** nothing else in the file still uses them — `go build` will tell you.
-
-- [ ] **Step 5: Delegate CreateUser**
-
-In `server/internal/services/users.go`, replace the whole `CreateUser` function with:
-
-```go
-func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- u, err := provision.CreateUser(ctx, db.Database, orgID, email, password, role, authSource)
- if errors.Is(err, provision.ErrEmailTaken) {
- return nil, fmt.Errorf("email already registered")
- }
- return u, err
-}
-```
-
-The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"` to the imports.
-
-- [ ] **Step 6: Delegate EnsureAuthIndexes**
-
-In `server/internal/services/migrate.go`, replace the body of `EnsureAuthIndexes` with:
-
-```go
-func EnsureAuthIndexes() error {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- return indexes.EnsureCoreIndexes(ctx, db.Database)
-}
-```
-
-Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"` to the imports.
-
-- [ ] **Step 7: Build and vet**
-
-```bash
-cd c:/Work/Repos/vantage/server
-go build ./... && go vet ./...
-```
-
-Expected: no output from either.
-
-- [ ] **Step 8: Confirm no duplicated rules remain in the server**
-
-```bash
-cd c:/Work/Repos/vantage
-grep -rn "reservedSlugs\|bcrypt.GenerateFromPassword\|func Slugify" server/internal/
-```
-
-Expected: no hits in `services/orgs.go` or `services/users.go`. Hits elsewhere — a password-change handler, for example — are fine **only** if they use `provision.BcryptCost` rather than a literal `12`. Fix any that do not.
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add server/go.mod server/go.sum server/internal/models server/internal/services
-git commit -m "refactor(server): use shared models, provision and indexes"
-```
-
----
-
-### Task 6: Wire sitesvc to shared and delete the duplicates
-
-**Files:**
-
-- Modify: `sitesvc/go.mod`
-- Modify: `sitesvc/internal/models/models.go`
-- Modify: `sitesvc/internal/store/store.go`
-- Delete: `sitesvc/internal/provision/provision.go`
-
-**Interfaces:**
-
-- Consumes: everything from Tasks 2–4
-- Produces: `store.Verify(ctx, rawToken) (*sharedmodels.Org, error)`. `store.CreatePending`, `store.EmailTaken`, `store.Connect`, `store.EnsureIndexes` keep their signatures.
-
-- [ ] **Step 1: Add the dependency**
-
-```bash
-cd c:/Work/Repos/vantage/sitesvc
-go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
-go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
-go mod tidy
-```
-
-- [ ] **Step 2: Reduce the models file**
-
-Replace the entire contents of `sitesvc/internal/models/models.go` with:
-
-```go
-package models
-
-import (
- "time"
-
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// PendingSignup lives here rather than in the shared module because only
-// sitesvc writes site_pending_signups. The control plane does not know the
-// collection exists.
-//
-// Org and User used to be mirrored here by hand. They now come from
-// gitea.hostxtra.co.uk/mrhid6/vantage/shared/models, which is the only copy.
-type PendingSignup struct {
- ID bson.ObjectID `bson:"_id,omitempty"`
- PendingID string `bson:"pending_id"`
- OrgName string `bson:"org_name"`
- Email string `bson:"email"`
- PasswordHash string `bson:"password_hash"`
- TokenHash string `bson:"token_hash"`
- CreatedAt time.Time `bson:"created_at"`
- ExpiresAt time.Time `bson:"expires_at"`
-}
-```
-
-- [ ] **Step 3: Delete the duplicated rules**
-
-```bash
-cd c:/Work/Repos/vantage
-rm -rf sitesvc/internal/provision
-```
-
-- [ ] **Step 4: Rewrite the store's provisioning paths**
-
-In `sitesvc/internal/store/store.go`:
-
-Replace the two local imports
-
-```go
- "gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
- "gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/provision"
-```
-
-with
-
-```go
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"
- sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
- "gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
-```
-
-Replace the error variables so `errors.Is` keeps working for existing callers in `internal/api`:
-
-```go
-var (
- ErrEmailTaken = provision.ErrEmailTaken
- ErrBadToken = errors.New("verification link is invalid or has expired")
- ErrNameRejected = provision.ErrNameRejected
-)
-```
-
-Replace `EnsureIndexes` with:
-
-```go
-func EnsureIndexes() error {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- // users.email and orgs.slug are declared in the shared module so both
- // services agree. Re-declaring at boot means sitesvc does not depend on the
- // control plane having started first.
- if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
- return err
- }
-
- _, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{{Key: "token_hash", Value: 1}},
- Options: options.Index().SetUnique(true),
- },
- {Keys: bson.D{{Key: "email", Value: 1}}},
- {
- Keys: bson.D{{Key: "expires_at", Value: 1}},
- Options: options.Index().SetExpireAfterSeconds(0),
- },
- })
- if err != nil {
- return fmt.Errorf("pending signup indexes: %w", err)
- }
- return nil
-}
-```
-
-Delete the local `createOrg` and `rollbackOrg` functions entirely.
-
-Replace `Verify` with:
-
-```go
-func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) {
- var pending models.PendingSignup
- err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
- "token_hash": hashToken(rawToken),
- "expires_at": bson.M{"$gt": time.Now().UTC()},
- }).Decode(&pending)
- if errors.Is(err, mongo.ErrNoDocuments) {
- return nil, ErrBadToken
- }
- if err != nil {
- return nil, err
- }
-
- org, err := provision.CreateOrg(ctx, database, pending.OrgName)
- if err != nil {
- return nil, err
- }
-
- // The password was hashed when the signup was recorded; only the hash
- // survives to this point.
- _, err = provision.CreateUserWithHash(ctx, database, org.OrgID, pending.Email,
- pending.PasswordHash, sharedmodels.RoleOwner, "local")
- if err != nil {
- // Leaving an org behind would permanently occupy a slug nobody owns.
- if rbErr := provision.RollbackOrg(ctx, database, org.OrgID); rbErr != nil {
- log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
- }
- return nil, err
- }
-
- return org, nil
-}
-```
-
-`CreatePending` already calls `provision.BaseSlug` and `provision.BcryptCost`; those now resolve to the shared package with no line changes beyond the import swap.
-
-- [ ] **Step 5: Build and vet**
-
-```bash
-cd c:/Work/Repos/vantage/sitesvc
-go build ./... && go vet ./...
-```
-
-Expected: no output. If `internal/api` fails because `Verify` now returns a different `*models.Org`, update its import to `sharedmodels` there too.
-
-- [ ] **Step 6: Confirm the duplication is gone**
-
-```bash
-cd c:/Work/Repos/vantage
-grep -rn "func Slugify\|ReservedSlugs =\|BcryptCost =\|bson:\"org_id\"" sitesvc/
-```
-
-Expected: no output. Any `provision.`-qualified _references_ are fine; what must be gone are local _definitions_.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add sitesvc/
-git commit -m "refactor(sitesvc): use shared models and provision
-
-Deletes internal/provision and the hand-mirrored Org and User structs. The
-control plane and sitesvc now share one definition of both."
-```
-
----
-
-### Task 7: Docker and CI
-
-`replace => ../shared` cannot resolve when the build context is the module directory. Both Go images build from the repo root instead.
-
-**Files:**
-
-- Modify: `server/Dockerfile`, `sitesvc/Dockerfile`
-- Modify: `.gitea/workflows/server-deploy.yml`
-
-**Interfaces:**
-
-- Consumes: the module layout from Tasks 1–6
-- Produces: images identical in content to today's, built from a different context
-
-- [ ] **Step 1: Rewrite the server Dockerfile build stage**
-
-Replace the build stage of `server/Dockerfile` with:
-
-```dockerfile
-# Build stage
-#
-# Context is the repository root, not server/, because server depends on the
-# shared module through a replace directive.
-FROM golang:1.26 AS builder
-
-WORKDIR /src
-
-# Manifests first so the dependency layer caches independently of source edits.
-COPY shared/go.mod shared/go.sum ./shared/
-COPY server/go.mod server/go.sum ./server/
-RUN cd server && go mod download
-
-COPY shared/ ./shared/
-COPY server/ ./server/
-
-ARG VERSION=dev
-RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
- -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
-```
-
-Leave the runtime stage exactly as it is.
-
-- [ ] **Step 2: Rewrite the sitesvc Dockerfile build stage**
-
-Replace the build stage of `sitesvc/Dockerfile` with:
-
-```dockerfile
-# Context is the repository root; sitesvc depends on the shared module.
-FROM golang:1.26-alpine AS builder
-
-WORKDIR /src
-
-COPY shared/go.mod shared/go.sum ./shared/
-COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/
-RUN cd sitesvc && go mod download
-
-COPY shared/ ./shared/
-COPY sitesvc/ ./sitesvc/
-
-RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
-```
-
-Leave the runtime stage exactly as it is.
-
-- [ ] **Step 3: Build both images locally**
-
-```bash
-cd c:/Work/Repos/vantage
-docker build -f server/Dockerfile -t vantage-server:test .
-docker build -f sitesvc/Dockerfile -t vantage-sitesvc:test .
-```
-
-Expected: both succeed, each ending with `naming to docker.io/library/vantage-...:test`.
-
-- [ ] **Step 4: Update the CI workflow**
-
-In `.gitea/workflows/server-deploy.yml`, for the `server` and `sitesvc` image build steps only:
-
-```yaml
-context: .
-file: server/Dockerfile
-```
-
-and
-
-```yaml
-context: .
-file: sitesvc/Dockerfile
-```
-
-Leave the `web` and `site` build steps untouched — they are Node images and do not use the shared module.
-
-- [ ] **Step 5: Confirm the agent pipeline is untouched**
-
-```bash
-cd c:/Work/Repos/vantage
-git diff --name-only HEAD -- .gitea/workflows/agent-release.yml agent/
-```
-
-Expected: no output. If either appears, revert those changes — the agent is explicitly out of scope.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add server/Dockerfile sitesvc/Dockerfile .gitea/workflows/server-deploy.yml
-git commit -m "build: build Go images from the repo root for the shared module"
-```
-
----
-
-### Task 8: End-to-end verification
-
-No new code. This is the gate, and with no automated tests it is the **only** evidence the refactor worked. Every step must be performed and its output observed — not assumed.
-
-**Files:** none
-
-- [ ] **Step 1: Build everything, including the agent**
-
-```bash
-cd c:/Work/Repos/vantage
-(cd shared && go build ./... && go vet ./...)
-(cd server && go build ./... && go vet ./...)
-(cd sitesvc && go build ./... && go vet ./...)
-(cd agent && go build ./... && go vet ./...)
-```
-
-Expected: no output from any of the eight commands.
-
-- [ ] **Step 2: Cross-compile the agent**
-
-```bash
-cd c:/Work/Repos/vantage/agent
-GOOS=linux GOARCH=amd64 go build -o /tmp/a1 ./cmd
-GOOS=linux GOARCH=arm64 go build -o /tmp/a2 ./cmd
-GOOS=windows GOARCH=amd64 go build -o /tmp/a3.exe ./cmd
-```
-
-Expected: all three succeed. This proves the workspace did not leak into the agent's build.
-
-- [ ] **Step 3: Boot the control plane against a scratch database**
-
-```bash
-cd c:/Work/Repos/vantage/server
-MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_scratch \
- GRPC_HOST=localhost:9090 go run ./cmd
-```
-
-Expected in the logs: `connected to MongoDB`, no index error, no migration error, and the HTTP listener starting.
-
-Confirm the indexes were created — in `mongosh`:
-
-```javascript
-use vantage_scratch
-db.users.getIndexes()
-db.orgs.getIndexes()
-```
-
-Expected: a unique index on `email` and a unique index on `slug` respectively.
-
-- [ ] **Step 4: Bootstrap through the control plane**
-
-With the server still running:
-
-```bash
-curl -s localhost:8080/auth/bootstrap-status
-curl -s -X POST localhost:8080/auth/bootstrap \
- -H 'Content-Type: application/json' \
- -d '{"org_name":"Acme Corp","email":"owner@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: bootstrap succeeds. Then in `mongosh`:
-
-```javascript
-db.orgs.findOne({}, { org_id: 1, name: 1, slug: 1 });
-```
-
-Expected: `slug: "acme-corp"`, a non-empty `org_id`, `name: "Acme Corp"`.
-
-This exercises `shared.CreateOrg` and `shared.CreateUser` through the control plane.
-
-- [ ] **Step 5: Confirm slug collision handling**
-
-```bash
-curl -s -X POST localhost:8080/auth/login \
- -H 'Content-Type: application/json' \
- -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/c.txt
-```
-
-Then create two more organisations named `Acme Corp` — through the UI, or directly in `mongosh` by calling the server again if a bootstrap-only path is not available. Confirm:
-
-```javascript
-db.orgs.find({}, { slug: 1 }).sort({ slug: 1 });
-```
-
-Expected: `acme-corp`, `acme-corp-2`, `acme-corp-3`.
-
-- [ ] **Step 6: The end-to-end agreement test**
-
-**This is the step that proves the refactor worked.** It confirms the two services still agree about the documents they share.
-
-Start sitesvc against the same scratch database:
-
-```bash
-cd c:/Work/Repos/vantage/sitesvc
-MONGO_URI=mongodb://localhost:27017/vantage_scratch \
- PUBLIC_URL=http://localhost:8082 \
- SITE_ORIGIN=http://localhost:3001 \
- SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \
- go run ./cmd
-```
-
-Use a local mail catcher for SMTP: `docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog`
-
-Then:
-
-1. `curl -s -X POST localhost:8082/api/signup -H 'Content-Type: application/json' -d '{"org_name":"Globex Ltd","email":"new@example.com","password":"hunter2hunter2"}'`
-2. Open MailHog at `http://localhost:8025`, copy the verification link.
-3. Open the verification link.
-4. Log into the control plane with the new credentials:
-
-```bash
-curl -s -X POST localhost:8080/auth/login \
- -H 'Content-Type: application/json' \
- -d '{"email":"new@example.com","password":"hunter2hunter2"}' -c /tmp/c2.txt
-curl -s localhost:8080/auth/me -b /tmp/c2.txt
-```
-
-Expected: login succeeds and `/auth/me` returns the org sitesvc created, with slug `globex-ltd`.
-
-**If login fails, the two services disagree about a document shape. The cause is in Task 5 or 6.** Do not proceed.
-
-- [ ] **Step 7: Confirm rollback still refuses**
-
-Create a throwaway `shared/cmd/rbcheck/main.go`:
-
-```go
-package main
-
-import (
- "context"
- "fmt"
- "os"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
-)
-
-func main() {
- c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017"))
- if err != nil {
- panic(err)
- }
- err = provision.RollbackOrg(context.Background(), c.Database("vantage_scratch"), os.Args[1])
- fmt.Println("err =", err)
-}
-```
-
-Run it with the `org_id` of an org that has a user:
-
-```bash
-cd shared && go run ./cmd/rbcheck
-```
-
-Expected: `err = refusing to roll back organisation : it has 1 user(s)`
-
-Then run it with a manually inserted org that has no users. Expected: `err = `, and the org is gone.
-
-Delete the throwaway: `rm -rf shared/cmd/rbcheck`
-
-- [ ] **Step 8: Confirm the duplication is gone repo-wide**
-
-```bash
-cd c:/Work/Repos/vantage
-grep -rn "func Slugify" --include=*.go .
-grep -rn "ReservedSlugs = map" --include=*.go .
-grep -rn "bson:\"org_id\"" --include=*.go .
-```
-
-Expected:
-
-- `func Slugify` — exactly one hit, in `shared/provision/slug.go`
-- `ReservedSlugs = map` — exactly one hit, in `shared/provision/slug.go`
-- `bson:"org_id"` — hits only in `shared/models/` and `server/internal/models/` for server-only documents. **No hit anywhere under `sitesvc/`.**
-
-- [ ] **Step 9: Drop the scratch database and commit**
-
-```javascript
-use vantage_scratch
-db.dropDatabase()
-```
-
-```bash
-git add -A
-git commit -m "chore: verify shared module extraction end to end"
-```
-
----
-
-## Rollout
-
-Single release, no database migration, no downtime beyond the normal restart.
-
-`server` and `sitesvc` images are built from one commit and deployed together. A version skew is harmless here because no document changed — but there is no reason to split it.
-
-```bash
-cd /opt/vantage && \
- docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
- docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
-```
-
-Post-deploy checks:
-
-1. Control plane login works.
-2. A signup through the marketing site completes and the new owner can log in.
-3. Server logs show no index errors at boot.
-
-## What this unblocks
-
-Plan 0b (`instance-rename`) becomes a rename inside one module plus its two
-consumers, rather than a rename across three independent copies of the same
-structs. That is the reason this plan goes first.
diff --git a/docs/superpowers/plans/2026-07-25-admin-site.md b/docs/superpowers/plans/2026-07-25-admin-site.md
deleted file mode 100644
index d143bbf..0000000
--- a/docs/superpowers/plans/2026-07-25-admin-site.md
+++ /dev/null
@@ -1,4263 +0,0 @@
-# Admin Site 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 `adminsite/`, a fifth Next.js app serving staff and customers from one codebase, so a customer can buy, link, download and renew a licence without contacting anyone, and staff can answer "why did this stop working" on one screen.
-
-**Architecture:** Route groups do the guarding — `(customer)` and `(staff)/staff` share auth plumbing and a component library but no screens. The browser talks to admin directly (build-time `NEXT_PUBLIC_ADMIN_API_URL`, CORS, `credentials: "include"`), the way `site/` talks to sitesvc, not through a Next rewrite proxy the way `web/` talks to the control plane. Tasks 1 and 2 add the admin endpoints spec 4 needs and spec 3 did not build; every frontend task after that consumes a real endpoint.
-
-**Tech Stack:** Next.js 16.2.9 App Router, React 18.3.1, Tailwind 3.4, TanStack Query 5, TypeScript 5.5, Vitest + React Testing Library (new to this repo), Go 1.26 for tasks 1–2.
-
-## Amendment, 2026-07-25: no automated frontend tests
-
-**The test suite was removed at the user's direction after tasks 1–12 were
-built.** `vitest`, React Testing Library, `vitest.config.ts`, `test/setup.ts` and
-all eleven `*.test.tsx` files are gone, along with the `test` scripts and dev
-dependencies. This matches the rest of the repo, which has no automated tests in
-any language.
-
-The TDD steps below (write the failing test, watch it fail, implement, watch it
-pass) are kept as written because they record *what each component is required to
-do* — the assertions are the specification, and they were all observed passing
-before removal. Treat them as acceptance criteria to check by hand, not as files
-to create.
-
-**What this costs:** spec 4's tests 1–10 no longer run anywhere, so **Task 16 is
-now the only verification that exists**. Do not skip it or shorten it, and be
-especially careful with the four things the deleted tests covered that are easy
-to get subtly wrong and invisible when broken: the 404-not-403 scoping, the
-expired-card copy naming what still works, the relink allowance disabling at
-zero, and the licence-blob fallback when a download fails.
-
-## Global Constraints
-
-- **The token set is `site/`'s, copied verbatim.** `adminsite/app/globals.css` carries the same custom properties, with the same names and the same hex values, as `site/app/globals.css` — brand navy accent `#0b2a58` light / `#5b9be8` dark, `--up #2f8a60` / `--down #c6462f` / `--pend #b0801f`, the same `--shadow`, the same `--s--1`…`--s-4` clamp scale, `--rail: 1200px`. **When `site/`'s tokens change, change these in the same commit** — they are one visual system in two apps, and there is nothing that enforces the match automatically.
-- **Tailwind maps those variables, it does not redefine them.** Colours in `tailwind.config.ts` are `var(--…)` references only. Never write a hex value in a component or in the Tailwind config.
-- **Licence state uses `site/`'s semantic tokens.** Tailwind exposes them as `valid`/`warn`/`expired`, aliased onto `--up`/`--pend`/`--down` so this app names them for what it means while staying the same three colours the marketing site uses for up, pending and down. Semantic hues are **never** reused as an accent.
-- **Type roles:** `--sans` for everything, `--mono` with `tabular-nums` for every UUID, timestamp, blob and limit. **There is no display face** — matching `site/` means headings are the sans at `font-weight: 800`, `letter-spacing: -0.03em`, `line-height: 1.03`.
-- **Distinctness from `web/` rests on ground and hue together.** `web/` is locked to dark with an indigo `#6366f1` accent; this app defaults to light with navy. Note the caveat: in dark mode `site/`'s accent lifts to `#5b9be8`, which is nearer web/'s indigo, so the "which app am I in" cue leans on the light ground. Do not make dark the default.
-- **State never reads by colour alone.** Every licence state renders as a stripe, a shaped-and-labelled pill, and copy.
-- **Served at `vantage-hq.hostxtra.co.uk`, published on `3004`.** Spec 4 said 3002, but `deploy/docker-compose.site.yml` now maps `site` to `3002:3000`. Container port stays `3000`. Note the host is deliberately *not* under `*.vantage.hostxtra.co.uk`: that namespace is per-tenant instance subdomains, and the control plane's `APP_ROOT_LABEL` guard resolves an org from the label before `vantage`. A host like `hq.vantage.hostxtra.co.uk` would look like a tenant slug called `hq`.
-- **`ADMIN_ORIGIN` must contain `https://vantage-hq.hostxtra.co.uk`** exactly — scheme included, no trailing slash. Admin echoes only origins on that list, so a mismatch blocks every browser request while curl from the server keeps working, which is what makes it confusing to diagnose.
-- **`NEXT_PUBLIC_ADMIN_API_URL` is baked in at build time** and must be browser-reachable *and* present in admin's `ADMIN_ORIGIN`. When unset or unreachable the app renders an explicit not-connected state naming the variable. This is the single most common deployment failure in this repo.
-- **Cookies work cross-origin only because both hosts share a registrable domain.** `admin_session` is `SameSite=Lax`, which browsers send on same-*site* subresource requests — and same-site is judged on the registrable domain, not the origin. `vantage-hq.hostxtra.co.uk` and admin's own host are both under `hostxtra.co.uk`, so a `fetch` with `credentials: "include"` carries the cookie. **Moving either host to a different registrable domain breaks every authenticated request** and would need `SameSite=None; Secure`, which is out of scope here.
-- **Route-group guards are UX, not security.** The real protection is admin's `RequireStaff`/`RequireCustomer` plus 404-not-403 scoping. Never rely on the client guard alone.
-- **Four-space indent, no semicolon-free style** — match `web/` and `site/`.
-- Admin **must never** gain a write path into the control plane beyond the three licence fields on `instances`. Tasks 1–2 add reads and admin-database writes only.
-- Every mutating admin endpoint added in tasks 1–2 writes an audit entry.
-- Run `go mod tidy` with `GOWORK=off`; `MSYS_NO_PATHCONV=1` on every `docker` call. Node commands run in a container:
- ```sh
- # /tmp/noderun.sh
- DIR="$1"; shift
- MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \
- -w "/src/$DIR" node:26-alpine "$@"
- ```
-
-## Context this plan inherits
-
-Spec 3 shipped and is verified. Its API is the contract here. **Spec 4 asks for eight things spec 3 does not expose**, found by auditing every screen in the spec against `admin/internal/api/routes.go`:
-
-| Spec 4 needs | Status after spec 3 | Added by |
-|---|---|---|
-| Route guards knowing who is signed in | no `GET /auth/me` at all | Task 1 |
-| `app/signup/` self-hosted account creation | no signup endpoint at all | Task 1 |
-| "2 of 3 relinks remaining" | `relink_count` exposed, cap is a Go constant | Task 1 |
-| Dashboard "subscriptions past_due" | no staff subscriptions endpoint | Task 2 |
-| Account search by instance UUID and Paddle ID | `q` matches name and billing_email only | Task 2 |
-| Account detail: subscriptions, customer users, audit | returns account + instances only | Task 2 |
-| Staff instance detail with licence history | no `GET /api/staff/instances/:id` | Task 2 |
-| Audit filtered to one account | global list only | Task 2 |
-
-Paddle portal deep-links are spec 5's. Until then the billing screen renders the subscription list and says billing changes go through support, rather than linking nowhere.
-
----
-
-## File Structure
-
-**Created — Go (tasks 1–2):** nothing new; all edits land in existing files.
-
-**Created — app:**
-
-| Path | Responsibility |
-|---|---|
-| `adminsite/package.json`, `tsconfig.json`, `next.config.ts`, `postcss.config.js`, `tailwind.config.ts` | app scaffold, tokens wired to CSS variables |
-| `adminsite/Dockerfile`, `.dockerignore` | image, built from `adminsite/` like `web/` |
-| `adminsite/vitest.config.ts`, `test/setup.ts` | the repo's first frontend test harness |
-| `adminsite/app/globals.css` | the token system, light and dark |
-| `adminsite/app/layout.tsx` | root shell, providers, environment badge |
-| `adminsite/app/login/page.tsx`, `signup/page.tsx`, `verify/page.tsx` | unauthed routes |
-| `adminsite/app/(customer)/layout.tsx`, `page.tsx` | customer guard + nav, account overview |
-| `adminsite/app/(customer)/instances/[id]/page.tsx` | licence, download, paste steps, relink |
-| `adminsite/app/(customer)/instances/link/page.tsx` | self-hosted activation |
-| `adminsite/app/(customer)/billing/page.tsx` | subscription list |
-| `adminsite/app/(staff)/staff/layout.tsx`, `page.tsx` | staff guard + nav, operations queue |
-| `adminsite/app/(staff)/staff/accounts/page.tsx`, `accounts/[id]/page.tsx` | search, account detail |
-| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the licence ledger |
-| `adminsite/app/(staff)/staff/licenses/page.tsx`, `audit/page.tsx` | global history |
-| `adminsite/app/(staff)/staff/plans/page.tsx` | plan editing with guard rails |
-| `adminsite/lib/api.ts` | typed client, `NotConnected`, `ApiError` |
-| `adminsite/lib/session.ts` | `useSession`, `useRequireKind` |
-| `adminsite/lib/query-client.ts` | TanStack config, mirrors `web/` |
-| `adminsite/lib/format.ts` | dates, days-remaining, licence state derivation |
-| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Field`, `Button`, `Queue`, `ConfirmPlanChange` |
-
-**Modified:** `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/auth/customer.go`, `admin/internal/models/models.go`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`.
-
----
-
-### Task 1: Session, signup and the relink cap (admin backend)
-
-Without `GET /auth/me` no route guard can know who is signed in, and without signup the spec's `app/signup/` has nothing to post to. Both are backend gaps, so they land before any frontend work.
-
-**Files:**
-- Modify: `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/auth/customer.go`
-
-**Interfaces:**
-- Consumes: `auth.Current`, `auth.CreateCustomerUser`, `models.MaxRelinksPerTerm`
-- Produces:
- - `GET /auth/me` → `200 {"kind","email","account_id"}` or `401`
- - `POST /auth/signup` → `201 {"pending":true}`
- - `GET /api/account` gains `"max_relinks": 3`
-
-- [x] **Step 1: Add the session probe**
-
-Append to `admin/internal/api/customer.go`:
-
-```go
-// getMe reports who the caller is, for route guards in the UI.
-//
-// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a
-// truthful 401 to redirect on, not an error page. It reveals nothing a caller
-// does not already possess, because it only ever describes their own cookie.
-func getMe(c *gin.Context) {
- s := auth.Load(c)
- if s == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
- return
- }
- c.JSON(http.StatusOK, gin.H{
- "kind": s.Kind,
- "email": s.Email,
- "account_id": s.AccountID,
- })
-}
-```
-
-- [x] **Step 2: Export the session loader**
-
-`load` in `admin/internal/auth/middleware.go` is unexported. Rename it to `Load` and update its two call sites in the same file:
-
-```go
-// Load returns the caller's session, or nil. Exported because the session probe
-// in api/ needs to read a session without requiring one.
-func Load(c *gin.Context) *Session {
-```
-
-Both `RequireStaff` and `RequireCustomer` call `s := Load(c)`.
-
-- [x] **Step 3: Add signup**
-
-Append to `admin/internal/auth/customer.go`:
-
-```go
-// HandleSignup creates a self-hosted customer: an account, an unverified user,
-// and a verification email.
-//
-// Nothing is usable until the emailed link is opened, the same rule sitesvc
-// already proves — so an address nobody controls cannot occupy an email or
-// produce an account that can sign in.
-func HandleSignup(c *gin.Context) {
- var body struct {
- Name string `json:"name"`
- Email string `json:"email"`
- Password string `json:"password"`
- Website string `json:"website"` // honeypot; real users never fill it
- }
- if err := c.ShouldBindJSON(&body); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"})
- return
- }
-
- // Honeypot: answer exactly as success so a bot learns nothing.
- if strings.TrimSpace(body.Website) != "" {
- c.JSON(http.StatusCreated, gin.H{"pending": true})
- return
- }
-
- email := strings.ToLower(strings.TrimSpace(body.Email))
- ctx := c.Request.Context()
-
- if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"})
- return
- }
- if !allowAttempt("signup:"+email, c.ClientIP()) {
- c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
- return
- }
-
- if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
- // Same response as success. Telling a stranger the address is taken
- // confirms who has an account here.
- c.JSON(http.StatusCreated, gin.H{"pending": true})
- return
- }
-
- acct := models.Account{
- AccountID: uuid.NewString(),
- Name: strings.TrimSpace(body.Name),
- BillingEmail: email,
- Status: models.AccountActive,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"})
- return
- }
-
- if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
- // Roll the account back rather than strand one with no owner.
- _, _ = db.Admin("accounts").DeleteOne(ctx, bson.M{"account_id": acct.AccountID})
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"})
- return
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()})
- c.JSON(http.StatusCreated, gin.H{"pending": true})
-}
-```
-
-Add `"time"` and `"github.com/google/uuid"` to that file's imports.
-
-- [x] **Step 3b: Make `CreateCustomerUser` undo its own insert**
-
-Found while verifying Step 7: when the verification email fails, `HandleSignup`
-rolls the account back but the `customer_users` row survives. That orphan can
-never be signed in to *and* it holds the unique index on `email`, so the next
-signup with that address hits the "already exists" branch and gets a cheerful
-`201` forever — the customer is locked out of their own address with no error
-anyone can see.
-
-In `admin/internal/auth/customer.go`, replace the tail of `CreateCustomerUser`:
-
-```go
- if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
- return err
- }
-
- if err := mail.SendVerification(u.Email, token); err != nil {
- // Undo the insert. A row whose verification link was never delivered is
- // worse than no row: it can never be signed in to, and it holds the
- // unique index on email, so the customer cannot sign up again with the
- // address they just used.
- _, _ = db.Admin("customer_users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
- return err
- }
- return nil
-}
-```
-
-Confirm by posting the same signup twice with SMTP unconfigured: both must
-return `500`, and both collections must be empty afterwards. Before the fix the
-second call returns `201`.
-
-- [x] **Step 4: Expose the relink cap**
-
-In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`:
-
-```go
- c.JSON(http.StatusOK, gin.H{
- "account": acct,
- "instances": instances,
- // Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a
- // second source of truth for a rule the backend enforces.
- "max_relinks": models.MaxRelinksPerTerm,
- })
-```
-
-- [x] **Step 5: Route them**
-
-In `admin/internal/api/routes.go`, below the existing auth routes:
-
-```go
- r.GET("/auth/me", getMe)
- r.POST("/auth/signup", auth.HandleSignup)
-```
-
-- [x] **Step 6: Build**
-
-```bash
-sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
-```
-
-Expected: no output.
-
-- [x] **Step 7: Confirm the probe and signup by hand**
-
-Start admin as in the spec-3 plan Task 11 steps 2–4, then:
-
-```bash
-curl -s -o /dev/null -w "unauthed me: %{http_code}\n" localhost:8083/auth/me
-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
-curl -s localhost:8083/auth/me -b /tmp/s.txt
-curl -s -o /dev/null -w "honeypot: %{http_code}\n" -X POST localhost:8083/auth/signup \
- -H 'Content-Type: application/json' \
- -d '{"name":"Bot","email":"bot@example.com","password":"aaaaaaaaaaaa","website":"x"}'
-```
-
-Expected: `401`; then `{"kind":"staff","email":"staff@example.com","account_id":""}`; then `201` with **no** account created (confirm with `db.accounts.countDocuments({billing_email:"bot@example.com"})` returning `0`).
-
-- [x] **Step 8: Commit**
-
-```bash
-git add admin/
-git commit -m "feat(admin): session probe, self-hosted signup and the relink cap"
-```
-
----
-
-### Task 2: Staff reads spec 4 needs (admin backend)
-
-**Files:**
-- Modify: `admin/internal/api/staff.go`, `admin/internal/api/routes.go`
-
-**Interfaces:**
-- Produces:
- - `GET /api/staff/subscriptions?status=past_due` → `[]Subscription`
- - `GET /api/staff/instances/:id` → `{instance, account, licenses, injection}`
- - `GET /api/staff/audit?account_id=` filtered
- - `GET /api/staff/accounts?q=` also matching `paddle_customer_id` and an instance UUID
- - `GET /api/staff/accounts/:id` also returning `subscriptions`, `users`, `audit`
-
-- [x] **Step 1: Search accounts by Paddle ID and instance UUID**
-
-In `staffListAccounts`, replace the `if q := c.Query("q"); q != ""` block:
-
-```go
- if q := c.Query("q"); q != "" {
- or := []bson.M{
- {"name": bson.M{"$regex": q, "$options": "i"}},
- {"billing_email": bson.M{"$regex": q, "$options": "i"}},
- {"paddle_customer_id": q},
- }
- // A support email often contains an instance UUID and nothing else, so
- // resolve that to its owning account rather than returning nothing.
- var inst models.Instance
- if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
- bson.M{"instance_id": q}).Decode(&inst); err == nil {
- or = append(or, bson.M{"account_id": inst.AccountID})
- }
- filter["$or"] = or
- }
-```
-
-- [x] **Step 2: Fill out account detail**
-
-Replace the body of `staffGetAccount` after the account lookup:
-
-```go
- instances := []models.Instance{}
- if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
- _ = cur.All(ctx, &instances)
- }
- subs := []models.Subscription{}
- if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
- _ = cur.All(ctx, &subs)
- }
- users := []models.CustomerUser{}
- if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
- _ = cur.All(ctx, &users)
- }
- entries := []models.AuditEntry{}
- if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID},
- options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil {
- _ = cur.All(ctx, &entries)
- }
-
- c.JSON(http.StatusOK, gin.H{
- "account": acct,
- "instances": instances,
- "subscriptions": subs,
- "users": users,
- "audit": entries,
- })
-```
-
-`CustomerUser.PasswordHash` and both verify-token fields are `json:"-"`, so no secret leaves here.
-
-- [x] **Step 3: Add staff instance detail**
-
-Append to `admin/internal/api/staff.go`:
-
-```go
-// staffGetInstance is the "why did this stop working" screen's data: one
-// instance, its account, its whole licence history newest first, and whether
-// the control plane currently holds what we think it holds.
-func staffGetInstance(c *gin.Context) {
- 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
- }
-
- var acct models.Account
- _ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct)
-
- lics := []models.License{}
- if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID},
- options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil {
- _ = cur.All(ctx, &lics)
- }
-
- // Injection state is only meaningful for cloud. For self-hosted the
- // customer holds the blob and there is nothing for us to have written.
- injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud}
- if inst.Deployment == license.DeploymentCloud {
- var remote sharedmodels.Instance
- err := db.Control("instances").FindOne(ctx,
- bson.M{"instance_id": inst.InstanceID}).Decode(&remote)
- switch {
- case err != nil:
- injection["state"] = "missing"
- case inst.CurrentLicense == "":
- injection["state"] = "none_issued"
- default:
- var current models.License
- if db.Admin("licenses").FindOne(ctx,
- bson.M{"license_id": inst.CurrentLicense}).Decode(¤t) == nil &&
- remote.LicenseBlob == current.Blob {
- injection["state"] = "current"
- } else {
- injection["state"] = "stale"
- }
- }
- injection["failed_at"] = inst.InjectFailedAt
- }
-
- c.JSON(http.StatusOK, gin.H{
- "instance": inst, "account": acct, "licenses": lics, "injection": injection,
- })
-}
-
-// staffListSubscriptions backs the past-due queue on the dashboard.
-func staffListSubscriptions(c *gin.Context) {
- filter := bson.M{}
- if v := c.Query("status"); v != "" {
- filter["status"] = v
- }
- if v := c.Query("account_id"); v != "" {
- filter["account_id"] = v
- }
- cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter,
- options.Find().SetLimit(500))
- 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)
-}
-```
-
-- [x] **Step 4: Filter audit by account**
-
-In `staffAudit`, replace `bson.M{}` with a filter:
-
-```go
- filter := bson.M{}
- if v := c.Query("account_id"); v != "" {
- filter["account_id"] = v
- }
- cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter,
- options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
-```
-
-- [x] **Step 5: Route them**
-
-In the `staff` group in `admin/internal/api/routes.go`:
-
-```go
- staff.GET("/instances/:id", staffGetInstance)
- staff.GET("/subscriptions", staffListSubscriptions)
-```
-
-- [x] **Step 6: Build and confirm the write surface is unchanged**
-
-```bash
-sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
-grep -rn 'Control("' admin/internal/ --include=*.go | grep -v "db.go"
-```
-
-Expected: build clean; exactly one `UpdateOne` on `instances` in `inject.go`, and reads only elsewhere. `staffGetInstance` adds a read, never a write.
-
-- [x] **Step 7: Confirm search by UUID**
-
-With the spec-3 verification stack running and an instance adopted:
-
-```bash
-curl -s "localhost:8083/api/staff/accounts?q=6a0fe3f0-49d2-4aa1-967c-a3094b200b5d" -b /tmp/s.txt
-curl -s localhost:8083/api/staff/instances/6a0fe3f0-49d2-4aa1-967c-a3094b200b5d -b /tmp/s.txt | head -c 200
-```
-
-Expected: the owning account, and an instance payload whose `injection.state` is `current`.
-
-- [x] **Step 8: Commit**
-
-```bash
-git add admin/
-git commit -m "feat(admin): staff instance detail, subscriptions and richer search"
-```
-
----
-
-### Task 3: App scaffold and tokens
-
-**Files:**
-- Create: `adminsite/package.json`, `adminsite/tsconfig.json`, `adminsite/next.config.ts`, `adminsite/postcss.config.js`, `adminsite/tailwind.config.ts`, `adminsite/app/globals.css`, `adminsite/app/layout.tsx`, `adminsite/.gitignore`
-
-**Interfaces:**
-- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-sans font-mono` (no display face — headings are the sans at weight 800)
-
-- [x] **Step 1: package.json**
-
-```json
-{
- "name": "vantage-adminsite",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "lint": "next lint",
- "test": "vitest run",
- "test:watch": "vitest"
- },
- "dependencies": {
- "next": "16.2.9",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "@tanstack/react-query": "^5.51.1",
- "clsx": "^2.1.1"
- },
- "devDependencies": {
- "@types/node": "^20.14.11",
- "@types/react": "^18.3.3",
- "@types/react-dom": "^18.3.0",
- "@testing-library/react": "^16.0.0",
- "@testing-library/user-event": "^14.5.2",
- "@testing-library/jest-dom": "^6.4.8",
- "@vitejs/plugin-react": "^4.3.1",
- "autoprefixer": "^10.4.19",
- "eslint": "^9.0.0",
- "eslint-config-next": "16.2.9",
- "jsdom": "^24.1.1",
- "postcss": "^8.4.39",
- "tailwindcss": "^3.4.6",
- "typescript": "^5.5.3",
- "vitest": "^2.0.5"
- }
-}
-```
-
-- [x] **Step 2: tsconfig.json**
-
-```json
-{
- "compilerOptions": {
- "target": "ES2022",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": false,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "preserve",
- "incremental": true,
- "types": ["vitest/globals", "@testing-library/jest-dom"],
- "plugins": [{ "name": "next" }],
- "paths": { "@/*": ["./*"] }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
-}
-```
-
-- [x] **Step 3: next.config.ts**
-
-```ts
-import type { NextConfig } from "next";
-
-/*
- * Unlike web/, this app does NOT proxy /api through a rewrite. The browser
- * calls admin directly, so NEXT_PUBLIC_ADMIN_API_URL must be reachable from the
- * browser and must appear in admin's ADMIN_ORIGIN. lib/api.ts renders an
- * explicit not-connected state when it is not.
- */
-const nextConfig: NextConfig = {
- output: "standalone",
-};
-
-export default nextConfig;
-```
-
-- [x] **Step 4: postcss.config.js and tailwind.config.ts**
-
-`postcss.config.js`:
-
-```js
-module.exports = {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-};
-```
-
-`tailwind.config.ts` — every colour is a `var()` reference, so `site/`'s token file is the single source of truth and one set serves both themes:
-
-```ts
-import type { Config } from "tailwindcss";
-
-/*
- * Tokens are shared with site/ — same names, same values, copied verbatim into
- * app/globals.css. Nothing here may hold a hex value: if a colour needs to
- * change it changes in globals.css, in both apps, in one commit.
- *
- * The semantic three are aliased rather than renamed. site/ calls them up,
- * down and pend because it shows monitor state; this app calls them valid,
- * expired and warn because it shows licence state. Same colours, honest names
- * on both sides.
- */
-const config: Config = {
- content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
- theme: {
- extend: {
- colors: {
- ground: "var(--ground)",
- panel: "var(--panel)",
- "panel-2": "var(--panel-2)",
- ink: "var(--ink)",
- "ink-2": "var(--ink-2)",
- "ink-3": "var(--ink-3)",
- rule: "var(--rule)",
- "rule-soft": "var(--rule-soft)",
- accent: "var(--accent)",
- "accent-ink": "var(--accent-ink)",
- "accent-wash": "var(--accent-wash)",
- valid: "var(--up)",
- warn: "var(--pend)",
- expired: "var(--down)",
- archival: "var(--ink-3)",
- },
- fontFamily: {
- sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
- mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
- },
- // site/ uses 4px on panels and buttons, 2px on focus rings.
- borderRadius: { DEFAULT: "4px" },
- maxWidth: { rail: "1200px" },
- },
- },
- plugins: [],
-};
-
-export default config;
-```
-
-- [x] **Step 5: app/globals.css**
-
-Copy the token block out of `site/app/globals.css` unchanged — same names, same
-values, all three theme selectors. The only addition is `--accent-wash`, which
-`site/` has no need for.
-
-```css
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-/* ==========================================================================
- Vantage admin console design tokens.
-
- These are site/app/globals.css's tokens, copied verbatim: the marketing site
- and this console are one visual system. Change them in both apps in the same
- commit — nothing enforces the match automatically.
-
- Light is the default because web/ is locked to dark, and telling the two
- apart at a glance is what stops a Reissue landing in the wrong tab. In dark
- mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
- distinction leans on the ground rather than the hue.
- ========================================================================== */
-
-:root {
- color-scheme: light dark;
-
- --ground: #eaedf3;
- --panel: #ffffff;
- --panel-2: #f4f6fa;
- --ink: #0a1b33;
- --ink-2: #41556f;
- --ink-3: #6c7f96;
- --rule: #cdd6e2;
- --rule-soft: #e0e6ef;
- --accent: #0b2a58;
- --accent-ink: #ffffff;
- --up: #2f8a60;
- --down: #c6462f;
- --pend: #b0801f;
- --shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
- --logo: #0b2a58;
-
- /* Not in site/: the hatched sandbox badge and hover washes need a tinted
- fill, and deriving it per-use would drift. */
- --accent-wash: rgba(11, 42, 88, 0.07);
-
- --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- --mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
-
- --s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
- --s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
- --s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
- --s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
- --s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
- --s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
-
- --rail: 1200px;
-}
-
-/* Dark tokens are defined once and applied through three selectors: the OS
- preference, and both explicit values of data-theme so an in-page toggle wins
- in either direction. Same pattern as site/. */
-@media (prefers-color-scheme: dark) {
- :root {
- --ground: #071628;
- --panel: #0d2138;
- --panel-2: #102842;
- --ink: #e4ecf6;
- --ink-2: #9fb3ca;
- --ink-3: #71879f;
- --rule: #1e3855;
- --rule-soft: #172c44;
- --accent: #5b9be8;
- --accent-ink: #04101f;
- --up: #4fb484;
- --down: #e2705a;
- --pend: #d6a63f;
- --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
- --logo: #7fb2f0;
- --accent-wash: rgba(91, 155, 232, 0.1);
- }
-}
-
-:root[data-theme="dark"] {
- --ground: #071628;
- --panel: #0d2138;
- --panel-2: #102842;
- --ink: #e4ecf6;
- --ink-2: #9fb3ca;
- --ink-3: #71879f;
- --rule: #1e3855;
- --rule-soft: #172c44;
- --accent: #5b9be8;
- --accent-ink: #04101f;
- --up: #4fb484;
- --down: #e2705a;
- --pend: #d6a63f;
- --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
- --logo: #7fb2f0;
- --accent-wash: rgba(91, 155, 232, 0.1);
-}
-
-:root[data-theme="light"] {
- --ground: #eaedf3;
- --panel: #ffffff;
- --panel-2: #f4f6fa;
- --ink: #0a1b33;
- --ink-2: #41556f;
- --ink-3: #6c7f96;
- --rule: #cdd6e2;
- --rule-soft: #e0e6ef;
- --accent: #0b2a58;
- --accent-ink: #ffffff;
- --up: #2f8a60;
- --down: #c6462f;
- --pend: #b0801f;
- --shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
- --logo: #0b2a58;
- --accent-wash: rgba(11, 42, 88, 0.07);
-}
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- background: var(--ground);
- color: var(--ink);
- font-family: var(--sans);
- font-size: var(--s-0);
- line-height: 1.6;
- -webkit-font-smoothing: antialiased;
-}
-
-/* site/'s heading treatment, which is what replaces a display face. */
-h1,
-h2,
-h3 {
- margin: 0;
- font-weight: 800;
- line-height: 1.03;
- letter-spacing: -0.03em;
- text-wrap: balance;
-}
-
-p {
- margin: 0;
-}
-
-code {
- font-family: var(--mono);
- font-size: 0.92em;
-}
-
-:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 3px;
- border-radius: 2px;
-}
-
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.001ms !important;
- transition-duration: 0.001ms !important;
- }
-}
-```
-
-Because `h1`–`h3` carry the weight and tracking from this file, the Tailwind
-classes on headings in later tasks (`text-3xl font-extrabold tracking-[-0.03em]`)
-are belt-and-braces for elements that are not `h1`–`h3`. Leave them; they cost
-nothing and keep a `
` used as a title looking right.
-
-- [x] **Step 6: app/layout.tsx**
-
-```tsx
-import type { Metadata } from "next";
-import "./globals.css";
-import { Providers } from "@/components/Providers";
-import { EnvBadge } from "@/components/EnvBadge";
-
-export const metadata: Metadata = {
- title: "Vantage Licensing",
- description: "Licences, instances and billing for Vantage.",
-};
-
-export default function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
- Vantage
-
- Licensing
-
-
-
-
-
- {children}
-
-
- );
-}
-```
-
-- [x] **Step 7: .gitignore**
-
-```
-node_modules
-.next
-next-env.d.ts
-.env
-*.lic
-```
-
-- [x] **Step 8: Install and confirm it builds later**
-
-```bash
-sh /tmp/noderun.sh adminsite npm install
-```
-
-Expected: a lockfile appears. `npm run build` cannot pass until Task 5 creates the pages it imports; that is Task 5's verification.
-
-- [x] **Step 9: Commit**
-
-```bash
-git add adminsite/
-git commit -m "feat(adminsite): scaffold and the approved token system"
-```
-
----
-
-### Task 4: Test harness, API client and the not-connected state
-
-The repo has no frontend test setup. This task creates it and proves it on spec 4's test 6.
-
-**Files:**
-- Create: `adminsite/vitest.config.ts`, `adminsite/test/setup.ts`, `adminsite/lib/api.ts`, `adminsite/lib/query-client.ts`, `adminsite/lib/format.ts`, `adminsite/components/Providers.tsx`, `adminsite/components/NotConnected.tsx`, `adminsite/components/NotConnected.test.tsx`
-
-**Interfaces:**
-- Produces:
- - `NotConnected` (error class), `ApiError` with `.status`
- - `api.me()`, `api.login()`, `api.staffLogin()`, `api.logout()`, `api.signup()`, `api.verify()`
- - `api.account()`, `api.link()`, `api.relink()`, `api.license()`, `api.licenseBlobUrl()`, `api.subscriptions()`
- - `api.staff.*` — `accounts`, `account`, `instances`, `instance`, `issue`, `relink`, `licenses`, `plans`, `updatePlan`, `audit`, `injectionHealth`, `subscriptions`
- - ``
- - `licenceState(expiresAt: string | undefined, hasLicence: boolean): "valid" | "warn" | "expired" | "none"`, `daysRemaining(iso: string): number`, `formatDate(iso: string): string`
-
-- [x] **Step 1: vitest.config.ts and test/setup.ts**
-
-```ts
-import { defineConfig } from "vitest/config";
-import react from "@vitejs/plugin-react";
-import { resolve } from "node:path";
-
-export default defineConfig({
- plugins: [react()],
- resolve: { alias: { "@": resolve(__dirname, ".") } },
- test: {
- environment: "jsdom",
- globals: true,
- setupFiles: ["./test/setup.ts"],
- include: ["**/*.test.{ts,tsx}"],
- },
-});
-```
-
-`test/setup.ts`:
-
-```ts
-import "@testing-library/jest-dom/vitest";
-import { cleanup } from "@testing-library/react";
-import { afterEach, vi } from "vitest";
-
-afterEach(() => {
- cleanup();
- vi.restoreAllMocks();
-});
-```
-
-- [x] **Step 2: Write the failing test for the not-connected state**
-
-`adminsite/components/NotConnected.test.tsx`:
-
-```tsx
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import { NotConnectedPanel } from "./NotConnected";
-
-describe("NotConnectedPanel", () => {
- it("names the variable that is wrong and where it is set", () => {
- render();
-
- expect(screen.getByRole("heading")).toHaveTextContent(
- /not connected to the licensing service/i,
- );
- expect(screen.getByText(/ADMIN_API_URL/)).toBeInTheDocument();
- expect(screen.getByText(/https:\/\/admin\.example\.com/)).toBeInTheDocument();
- // The two mistakes that actually cause this, both named.
- expect(screen.getByText(/reachable from your browser/i)).toBeInTheDocument();
- expect(screen.getByText(/ADMIN_ORIGIN/)).toBeInTheDocument();
- });
-
- it("says the value is missing when no URL was baked in", () => {
- render();
- expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument();
- });
-});
-```
-
-- [x] **Step 3: Run it and watch it fail**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run components/NotConnected.test.tsx
-```
-
-Expected: FAIL — `Failed to resolve import "./NotConnected"`.
-
-- [x] **Step 4: Write the component**
-
-`adminsite/components/NotConnected.tsx`:
-
-```tsx
-/*
- * The deployment failure this repo makes most often, made legible. It names the
- * variable, the value baked in, and both reasons it fails — unreachable from
- * the browser, or missing from admin's ADMIN_ORIGIN.
- */
-export function NotConnectedPanel({ url }: { url: string }) {
- return (
-
-
- Not connected to the licensing service
-
- {url ? (
-
- This build points at ADMIN_API_URL ={" "}
- {url}, which did not respond.
-
- ) : (
-
- ADMIN_API_URL was not set when this
- app was built, so there is nowhere to send requests.
-
- )}
-
- The value is baked in when the image is built and has to be reachable from your
- browser, not just from the server. It also has to appear in the licensing
- service’s ADMIN_ORIGIN, or the browser
- blocks every request.
-
-
- );
-}
-```
-
-- [x] **Step 5: Run it and watch it pass**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run components/NotConnected.test.tsx
-```
-
-Expected: `2 passed`.
-
-- [x] **Step 6: Write the API client**
-
-`adminsite/lib/api.ts`:
-
-```ts
-/*
- * The typed client for the licensing service.
- *
- * The browser calls admin directly, so every request carries credentials and
- * every failure mode is one of three: the API is unreachable (NotConnected),
- * the caller is not signed in (ApiError 401, which layouts redirect on), or the
- * request was refused (ApiError with the backend's own message, which is
- * customer-facing and should be shown verbatim).
- */
-
-export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
-
-export class NotConnected extends Error {
- constructor() {
- super("not connected");
- this.name = "NotConnected";
- }
-}
-
-export class ApiError extends Error {
- status: number;
- constructor(status: number, message: string) {
- super(message);
- this.name = "ApiError";
- this.status = status;
- }
-}
-
-async function req(path: string, init?: RequestInit): Promise {
- if (!API_BASE) throw new NotConnected();
-
- let res: Response;
- try {
- res = await fetch(`${API_BASE}${path}`, {
- ...init,
- credentials: "include",
- headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
- });
- } catch {
- // Network-level failure, DNS, or a CORS preflight the browser refused.
- throw new NotConnected();
- }
-
- if (res.status === 204) return undefined as T;
-
- const body = await res.json().catch(() => null);
- if (!res.ok) {
- throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`);
- }
- return body as T;
-}
-
-const post = (path: string, payload?: unknown) =>
- req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
-
-// --- types ---------------------------------------------------------------
-
-export type Deployment = "cloud" | "self_hosted";
-export type Tier = "free" | "professional" | "self_hosted";
-export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled";
-
-export interface Session {
- kind: "staff" | "customer";
- email: string;
- account_id?: string;
-}
-
-export interface Limits {
- max_servers: number;
- max_secret_groups: number;
- max_channels: number;
-}
-
-export interface Account {
- account_id: string;
- name: string;
- billing_email: string;
- paddle_customer_id?: string;
- status: "active" | "suspended";
- created_at: string;
-}
-
-export interface Instance {
- instance_id: string;
- account_id: string;
- name: string;
- slug?: string;
- deployment: Deployment;
- tier?: Tier;
- status: InstanceStatus;
- current_license?: string;
- relink_count: number;
- inject_failed_at?: string | null;
- created_at: string;
-}
-
-export interface License {
- license_id: string;
- instance_id: string;
- account_id: string;
- tier: Tier;
- deployment: Deployment;
- limits: Limits;
- features: string[];
- issued_at: string;
- expires_at: string;
- superseded_by?: string;
- issued_by: string;
- reason: "new" | "renewal" | "tier_change" | "relink" | "manual";
-}
-
-export interface Subscription {
- subscription_id: string;
- account_id: string;
- instance_id?: string;
- tier: Tier;
- term: string;
- status: string;
- current_period_end: string;
-}
-
-export interface Plan {
- tier: Tier;
- name: string;
- deployment: Deployment;
- limits: Limits;
- features: string[];
- paddle_product_id?: string;
- paddle_price_ids?: Record;
- active: boolean;
-}
-
-export interface CustomerUser {
- user_id: string;
- account_id: string;
- email: string;
- verified_at?: string | null;
- created_at: string;
-}
-
-export interface AuditEntry {
- actor: string;
- action: string;
- account_id?: string;
- target?: string;
- detail?: string;
- ip?: string;
- created_at: string;
-}
-
-export interface AccountResponse {
- account: Account;
- instances: Instance[];
- max_relinks: number;
-}
-
-export interface StaffAccountResponse {
- account: Account;
- instances: Instance[];
- subscriptions: Subscription[];
- users: CustomerUser[];
- audit: AuditEntry[];
-}
-
-export type InjectionState = "current" | "stale" | "missing" | "none_issued";
-
-export interface StaffInstanceResponse {
- instance: Instance;
- account: Account;
- licenses: License[];
- injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
-}
-
-// --- calls ---------------------------------------------------------------
-
-export const api = {
- me: () => req("/auth/me"),
- login: (email: string, password: string) => post("/auth/login", { email, password }),
- staffLogin: (email: string, password: string) =>
- post("/auth/staff/login", { email, password }),
- logout: () => post<{ ok: boolean }>("/auth/logout"),
- signup: (payload: { name: string; email: string; password: string; website?: string }) =>
- post<{ pending: boolean }>("/auth/signup", payload),
- verify: (token: string) => req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
-
- account: () => req("/api/account"),
- link: (instance_id: string, name: string) =>
- post("/api/instances/link", { instance_id, name }),
- relink: (id: string, instance_id: string) =>
- post(`/api/instances/${id}/relink`, { instance_id }),
- license: (id: string) => req(`/api/instances/${id}/license`),
- licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
- subscriptions: () => req("/api/subscriptions"),
-
- staff: {
- accounts: (q?: string) =>
- req(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
- account: (id: string) => req(`/api/staff/accounts/${id}`),
- instances: (params?: Record) =>
- req(`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`),
- instance: (id: string) => req(`/api/staff/instances/${id}`),
- issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
- post(`/api/staff/instances/${id}/issue`, payload),
- relink: (id: string, instance_id: string) =>
- post(`/api/staff/instances/${id}/relink`, { instance_id }),
- licenses: (params?: Record) =>
- req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
- plans: () => req("/api/staff/plans"),
- updatePlan: (tier: Tier, plan: Omit) =>
- req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
- method: "PUT",
- body: JSON.stringify(plan),
- }),
- audit: (accountId?: string) =>
- req(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
- injectionHealth: () =>
- req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
- subscriptions: (status?: string) =>
- req(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
- },
-};
-```
-
-- [x] **Step 7: Write format helpers and providers**
-
-`adminsite/lib/format.ts`:
-
-```ts
-export type LicenceState = "valid" | "warn" | "expired" | "none";
-
-/** Amber inside 14 days, matching the window staff chase renewals on. */
-export const EXPIRY_WARNING_DAYS = 14;
-
-export function daysRemaining(iso: string): number {
- const ms = new Date(iso).getTime() - Date.now();
- return Math.ceil(ms / 86_400_000);
-}
-
-export function licenceState(expiresAt: string | undefined, hasLicence: boolean): LicenceState {
- if (!hasLicence || !expiresAt) return "none";
- const days = daysRemaining(expiresAt);
- if (days <= 0) return "expired";
- if (days <= EXPIRY_WARNING_DAYS) return "warn";
- return "valid";
-}
-
-export function formatDate(iso: string): string {
- return new Date(iso).toLocaleDateString("en-GB", {
- day: "numeric",
- month: "short",
- year: "numeric",
- });
-}
-
-export function formatStamp(iso: string): string {
- return `${new Date(iso).toISOString().slice(11, 19)} UTC`;
-}
-
-export function limitLabel(n: number): string {
- return n === -1 ? "unlimited" : String(n);
-}
-```
-
-`adminsite/lib/query-client.ts`:
-
-```ts
-"use client";
-
-import { QueryClient } from "@tanstack/react-query";
-import { ApiError, NotConnected } from "./api";
-
-export const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 30_000,
- // Retrying a 401 or a missing API URL just delays the redirect and
- // the not-connected panel.
- retry: (count, error) =>
- error instanceof NotConnected || error instanceof ApiError ? false : count < 1,
- },
- },
-});
-```
-
-`adminsite/components/Providers.tsx`:
-
-```tsx
-"use client";
-
-import { QueryClientProvider } from "@tanstack/react-query";
-import { queryClient } from "@/lib/query-client";
-
-export function Providers({ children }: { children: React.ReactNode }) {
- return {children};
-}
-```
-
-- [x] **Step 8: Run the whole suite**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run
-```
-
-Expected: `2 passed`.
-
-- [x] **Step 9: Commit**
-
-```bash
-git add adminsite/
-git commit -m "feat(adminsite): test harness, typed client and the not-connected state"
-```
-
----
-
-### Task 5: Session, route-group guards and auth screens
-
-Covers spec test 1.
-
-**Files:**
-- Create: `adminsite/lib/session.ts`, `adminsite/components/EnvBadge.tsx`, `adminsite/components/Button.tsx`, `adminsite/components/Field.tsx`, `adminsite/app/login/page.tsx`, `adminsite/app/signup/page.tsx`, `adminsite/app/verify/page.tsx`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(staff)/staff/layout.tsx`, `adminsite/app/page.tsx`, `adminsite/lib/session.test.tsx`
-
-**Interfaces:**
-- Consumes: `api`, `NotConnected`, `ApiError`
-- Produces: `useSession()`, ``, ``, ``, ``
-
-- [x] **Step 1: Write the failing guard test**
-
-`adminsite/lib/session.test.tsx`:
-
-```tsx
-import { render, screen, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { ApiError } from "./api";
-import { RequireKind } from "./session";
-
-const replace = vi.fn();
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ replace, push: vi.fn() }),
-}));
-
-const me = vi.fn();
-vi.mock("./api", async () => {
- const actual = await vi.importActual("./api");
- return { ...actual, api: { ...actual.api, me: () => me() } };
-});
-
-function wrap(ui: React.ReactNode) {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return render({ui});
-}
-
-describe("RequireKind", () => {
- beforeEach(() => replace.mockClear());
-
- it("renders staff screens for a staff session", async () => {
- me.mockResolvedValue({ kind: "staff", email: "s@example.com" });
- wrap(operations);
- expect(await screen.findByText("operations")).toBeInTheDocument();
- expect(replace).not.toHaveBeenCalled();
- });
-
- it("sends a customer session away from staff screens without telling it anything", async () => {
- me.mockResolvedValue({ kind: "customer", email: "c@example.com", account_id: "a1" });
- wrap(operations);
- await waitFor(() => expect(replace).toHaveBeenCalledWith("/"));
- expect(screen.queryByText("operations")).not.toBeInTheDocument();
- });
-
- it("sends an unauthenticated visitor to sign in", async () => {
- me.mockRejectedValue(new ApiError(401, "not signed in"));
- wrap(account);
- await waitFor(() => expect(replace).toHaveBeenCalledWith("/login"));
- });
-});
-```
-
-- [x] **Step 2: Run it and watch it fail**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run lib/session.test.tsx
-```
-
-Expected: FAIL — cannot resolve `./session`.
-
-- [x] **Step 3: Write the session module**
-
-`adminsite/lib/session.ts`:
-
-```tsx
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { useRouter } from "next/navigation";
-import { useEffect } from "react";
-import { ApiError, NotConnected, api, API_BASE, type Session } from "./api";
-import { NotConnectedPanel } from "@/components/NotConnected";
-
-export function useSession() {
- const { data, error, isLoading } = useQuery({
- queryKey: ["me"],
- queryFn: api.me,
- staleTime: 60_000,
- });
- return { session: data, error, isLoading };
-}
-
-/*
- * The route-group guard. This is UX, not security: admin enforces the same
- * boundary with RequireStaff/RequireCustomer and returns 404 rather than 403
- * for another account's data. A customer hitting a staff route is redirected
- * rather than shown a refusal, because there is nothing to tell them about.
- */
-export function RequireKind({
- kind,
- children,
-}: {
- kind: Session["kind"];
- children: React.ReactNode;
-}) {
- const router = useRouter();
- const { session, error, isLoading } = useSession();
-
- useEffect(() => {
- if (error instanceof ApiError && error.status === 401) {
- router.replace("/login");
- return;
- }
- if (session && session.kind !== kind) {
- router.replace(session.kind === "staff" ? "/staff" : "/");
- }
- }, [error, session, kind, router]);
-
- if (error instanceof NotConnected) return ;
- if (isLoading || !session || session.kind !== kind) return null;
- return <>{children}>;
-}
-```
-
-Note the redirect target for a mismatched session is the *other* home, so a customer on `/staff/*` lands on `/`.
-
-- [x] **Step 4: Run it and watch it pass**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run lib/session.test.tsx
-```
-
-Expected: `3 passed`. The mismatch test asserts `replace("/")`, which the code produces for a customer.
-
-- [x] **Step 5: Write the shared primitives**
-
-`adminsite/components/EnvBadge.tsx`:
-
-```tsx
-/*
- * Sandbox is hatched as well as coloured, so it survives a colourblind reader
- * and a glance. It sits in the same place on every screen: issuing against the
- * wrong environment should feel wrong before you click.
- */
-const ENV = process.env.NEXT_PUBLIC_ADMIN_ENV === "sandbox" ? "sandbox" : "production";
-
-export function EnvBadge() {
- const sandbox = ENV === "sandbox";
- return (
-
-
- {sandbox ? "Sandbox" : "Production"}
-
- );
-}
-```
-
-`adminsite/components/Button.tsx`:
-
-```tsx
-import clsx from "clsx";
-
-type Props = React.ButtonHTMLAttributes & { variant?: "solid" | "line" };
-
-/*
- * Matches site/'s .btn--solid and .btn--line exactly, including the neutral
- * border on the secondary variant. site/ does not have an accent-outlined
- * button and this app should not invent one.
- */
-export function Button({ variant = "solid", className, ...rest }: Props) {
- return (
-
- );
-}
-```
-
-`adminsite/components/Field.tsx`:
-
-```tsx
-export function Field({
- label,
- hint,
- error,
- ...input
-}: React.InputHTMLAttributes & {
- label: string;
- hint?: React.ReactNode;
- error?: string;
-}) {
- return (
-
- );
-}
-```
-
-- [x] **Step 6: Write the auth screens**
-
-`adminsite/app/login/page.tsx`:
-
-```tsx
-"use client";
-
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import Link from "next/link";
-import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
-import { NotConnectedPanel } from "@/components/NotConnected";
-import { Button } from "@/components/Button";
-import { Field } from "@/components/Field";
-
-export default function LoginPage() {
- const router = useRouter();
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [staff, setStaff] = useState(false);
- const [error, setError] = useState(null);
- const [offline, setOffline] = useState(false);
- const [busy, setBusy] = useState(false);
-
- async function submit(e: React.FormEvent) {
- e.preventDefault();
- setBusy(true);
- setError(null);
- try {
- const s = staff ? await api.staffLogin(email, password) : await api.login(email, password);
- router.replace(s.kind === "staff" ? "/staff" : "/");
- } catch (err) {
- if (err instanceof NotConnected) setOffline(true);
- else if (err instanceof ApiError) setError(err.message);
- else setError("Sign in failed. Try again.");
- } finally {
- setBusy(false);
- }
- }
-
- if (offline) return ;
-
- return (
-
-
Sign in
-
-
- );
-}
-
-function Main({ children }: { children: React.ReactNode }) {
- return {children};
-}
-```
-
-`adminsite/app/signup/page.tsx`:
-
-```tsx
-"use client";
-
-import { useState } from "react";
-import { ApiError, NotConnected, api } from "@/lib/api";
-import { Button } from "@/components/Button";
-import { Field } from "@/components/Field";
-
-export default function SignupPage() {
- const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
- const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
- const [error, setError] = useState(null);
-
- async function submit(e: React.FormEvent) {
- e.preventDefault();
- setState("busy");
- setError(null);
- try {
- await api.signup(form);
- setState("sent");
- } catch (err) {
- setState("idle");
- setError(
- err instanceof NotConnected
- ? "The licensing service is not reachable from this page."
- : err instanceof ApiError
- ? err.message
- : "Could not create the account. Try again.",
- );
- }
- }
-
- return (
-
- {state === "sent" ? (
-
-
Check your email
-
- We sent a link to {form.email}. Open it to finish setting up your account —
- it expires in 24 hours. Nothing is created until you do.
-
-
- ) : (
- <>
-
Create an account
-
- For self-hosted licences. If you run on our cloud, sign in with the same
- details you use for your Vantage instance.
-
- {unlinked.length === 1 ? "One purchase is" : `${unlinked.length} purchases are`}{" "}
- not attached to an install yet, so no licence has been issued for{" "}
- {unlinked.length === 1 ? "it" : "them"}.
-
-
- Link an install
-
-
- )}
-
- {data.instances.length === 0 ? (
-
-
No instances yet
-
- There are two ways to run Vantage. Buy a cloud instance and we host it, and
- your licence is applied automatically. Or buy a self-hosted licence, install
- Vantage on your own server, and link it here to get your licence file.
-
- Relinking issues a replacement licence for the new install, covering the rest of
- your current term.
-
- {open && !exhausted && (
- setValue(e.target.value)}
- error={error}
- hint="From Settings → Licence on the new install."
- />
- )}
-
-
-
- {exhausted
- ? "You have used every relink for this term — contact support and we will sort it out."
- : `${remaining} of ${max} relinks left this term`}
-
-
;
- if (!instance) {
- // 404 rather than a refusal: the backend does the same, and confirming
- // an instance exists would be an existence oracle over other accounts.
- return
- );
-}
-```
-
-`License.blob` is `json:"-"` on the backend, so add a customer-facing blob field: in `admin/internal/api/customer.go`, `getInstanceLicense` and `downloadInstanceLicense` already have the record — change `getInstanceLicense` to respond `c.JSON(http.StatusOK, gin.H{"license": lic, "blob": lic.Blob})` and widen the TS type to `License & { blob?: string }`. Do this in Step 7 before the page can render the blob.
-
-- [x] **Step 7: Return the blob to its owner**
-
-In `admin/internal/api/customer.go`, replace the final line of `getInstanceLicense`:
-
-```go
- // The owner gets the blob itself: it is signed public data bound to their
- // own instance, and the download endpoint hands over the same bytes.
- c.JSON(http.StatusOK, gin.H{
- "license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier,
- "deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features,
- "issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason,
- "issued_by": lic.IssuedBy, "blob": lic.Blob,
- })
-```
-
-In `adminsite/lib/api.ts`, change the customer call's type:
-
-```ts
- license: (id: string) => req(`/api/instances/${id}/license`),
-```
-
-Rebuild admin: `sh /tmp/gorun.sh admin go build ./...` — expected no output.
-
-- [x] **Step 8: Run the suite**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run
-```
-
-Expected: `13 passed`.
-
-- [x] **Step 9: Commit**
-
-```bash
-git add adminsite/ admin/
-git commit -m "feat(adminsite): licence delivery, paste instructions and relink"
-```
-
----
-
-### Task 8: The link flow
-
-Covers spec test 3. This is the screen the five-minute bar applies to.
-
-**Files:**
-- Create: `adminsite/app/(customer)/instances/link/page.tsx`, `adminsite/app/(customer)/instances/link/LinkForm.tsx`, `adminsite/app/(customer)/instances/link/LinkForm.test.tsx`
-
-**Interfaces:**
-- Produces: ` void} />`
-
-- [x] **Step 1: Write the failing test**
-
-```tsx
-import { render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import { ApiError } from "@/lib/api";
-import { LinkForm } from "./LinkForm";
-
-const link = vi.fn();
-vi.mock("@/lib/api", async () => {
- const actual = await vi.importActual("@/lib/api");
- return { ...actual, api: { ...actual.api, link: (...a: unknown[]) => link(...a) } };
-});
-
-const VALID = "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d";
-
-describe("LinkForm", () => {
- it("catches a malformed id before asking the server", async () => {
- render();
- await userEvent.type(screen.getByLabelText(/instance id/i), "not-a-uuid");
- await userEvent.click(screen.getByRole("button", { name: /link and issue/i }));
-
- expect(screen.getByText(/does not look like an instance id/i)).toBeInTheDocument();
- expect(link).not.toHaveBeenCalled();
- });
-
- it("lands the customer on their licence on success", async () => {
- const onLinked = vi.fn();
- link.mockResolvedValue({ instance_id: VALID });
- render();
- await userEvent.type(screen.getByLabelText(/instance id/i), VALID);
- await userEvent.click(screen.getByRole("button", { name: /link and issue/i }));
-
- await waitFor(() => expect(onLinked).toHaveBeenCalledWith(VALID));
- });
-
- it("shows the server's own message when the id is already linked", async () => {
- link.mockRejectedValue(new ApiError(409, "that instance ID is already linked to an account"));
- render();
- await userEvent.type(screen.getByLabelText(/instance id/i), VALID);
- await userEvent.click(screen.getByRole("button", { name: /link and issue/i }));
-
- expect(await screen.findByText(/already linked to an account/i)).toBeInTheDocument();
- });
-});
-```
-
-- [x] **Step 2: Run it and watch it fail**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run app/\(customer\)/instances/link/LinkForm.test.tsx
-```
-
-Expected: FAIL — cannot resolve `./LinkForm`.
-
-- [x] **Step 3: Write LinkForm**
-
-```tsx
-"use client";
-
-import { useState } from "react";
-import { ApiError, NotConnected, api } from "@/lib/api";
-import { Button } from "@/components/Button";
-import { Field } from "@/components/Field";
-
-const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
-
-export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) {
- const [id, setId] = useState("");
- const [name, setName] = useState("");
- const [error, setError] = useState();
- const [busy, setBusy] = useState(false);
-
- async function submit(e: React.FormEvent) {
- e.preventDefault();
- const value = id.trim();
-
- // Checked here so a typo costs nothing and the message is instant.
- if (!UUID_RE.test(value)) {
- setError("That does not look like an instance ID. It should look like the example below.");
- return;
- }
-
- setBusy(true);
- setError(undefined);
- try {
- const inst = await api.link(value, name.trim());
- onLinked(inst.instance_id);
- } catch (err) {
- setError(
- err instanceof NotConnected
- ? "The licensing service is not reachable from this page."
- : err instanceof ApiError
- ? err.message
- : "Could not link that instance. Try again.",
- );
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
- );
-}
-```
-
-- [x] **Step 4: Run it and watch it pass**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run app/\(customer\)/instances/link/LinkForm.test.tsx
-```
-
-Expected: `3 passed`.
-
-- [x] **Step 5: Write the page**
-
-```tsx
-"use client";
-
-import { useRouter } from "next/navigation";
-import { useQueryClient } from "@tanstack/react-query";
-import { LinkForm } from "./LinkForm";
-
-export default function LinkPage() {
- const router = useRouter();
- const qc = useQueryClient();
-
- return (
-
-
-
Link an install
-
- Every licence is tied to one install, so we need its ID before we can issue
- yours. Paste it below and your licence is ready on the next screen.
-
-
- {
- qc.invalidateQueries({ queryKey: ["account"] });
- // Straight to the download, not back to a list: the licence is
- // the thing they came for.
- router.push(`/instances/${instanceId}`);
- }}
- />
-
- );
-}
-```
-
-- [x] **Step 6: Commit**
-
-```bash
-git add adminsite/
-git commit -m "feat(adminsite): the self-hosted link flow"
-```
-
----
-
-### Task 9: Billing
-
-**Files:**
-- Create: `adminsite/app/(customer)/billing/page.tsx`
-
-- [x] **Step 1: Write the page**
-
-```tsx
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { API_BASE, NotConnected, api } from "@/lib/api";
-import { NotConnectedPanel } from "@/components/NotConnected";
-import { formatDate } from "@/lib/format";
-
-export default function BillingPage() {
- const { data, error, isLoading } = useQuery({
- queryKey: ["subscriptions"],
- queryFn: api.subscriptions,
- });
-
- if (error instanceof NotConnected) return ;
- if (isLoading) return
Loading…
;
-
- return (
-
-
Billing
-
- {!data || data.length === 0 ? (
-
- You have no subscriptions. Cloud instances and self-hosted licences are both
- bought from the pricing page.
-
- ) : (
-
-
-
-
-
Plan
-
Term
-
Status
-
Renews
-
-
-
- {data.map((s) => (
-
-
{s.tier.replace("_", " ")}
-
{s.term}
-
{s.status}
-
- {formatDate(s.current_period_end)}
-
-
- ))}
-
-
-
- )}
-
-
- To change a card, download an invoice or cancel, email support and we will send you
- a billing link. Self-service billing arrives with card payments.
-
-
- );
-}
-```
-
-That last paragraph is replaced by the Paddle portal deep-link in spec 5. It states the current truth rather than linking nowhere.
-
-- [x] **Step 2: Commit**
-
-```bash
-git add adminsite/
-git commit -m "feat(adminsite): customer billing view"
-```
-
----
-
-### Task 10: Staff operations dashboard
-
-Covers spec test 7.
-
-**Files:**
-- Create: `adminsite/app/(staff)/staff/page.tsx`, `adminsite/components/Queue.tsx`, `adminsite/components/Queue.test.tsx`
-
-**Interfaces:**
-- Produces: `` where `items: { label: string; href: string; meta: string }[]`
-
-- [x] **Step 1: Write the failing test**
-
-```tsx
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import { Queue } from "./Queue";
-
-describe("Queue", () => {
- it("shows the count and links every row to the work", () => {
- render(
- ,
- );
- expect(screen.getByText("Failed injections")).toBeInTheDocument();
- expect(screen.getByText("2")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Acme Production" })).toHaveAttribute(
- "href",
- "/staff/instances/i1",
- );
- });
-
- it("says so plainly when there is nothing to do", () => {
- render();
- expect(screen.getByText(/nothing to do/i)).toBeInTheDocument();
- });
-});
-```
-
-- [x] **Step 2: Run it and watch it fail**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run components/Queue.test.tsx
-```
-
-Expected: FAIL — cannot resolve `./Queue`.
-
-- [x] **Step 3: Write Queue**
-
-```tsx
-import Link from "next/link";
-import clsx from "clsx";
-
-const TONE = {
- expired: "border-l-expired text-expired",
- warn: "border-l-warn text-warn",
- accent: "border-l-accent text-accent",
-} as const;
-
-export function Queue({
- title,
- count,
- tone,
- items,
-}: {
- title: string;
- count: number;
- tone: keyof typeof TONE;
- items: { label: string; href: string; meta: string }[];
-}) {
- return (
-
-
-
- {/* Guard rail two: deployment is shown, never edited. */}
-
- 🔒
- Deployment is fixed at {p.deployment}. Moving a
- tier between cloud and self-hosted is a code change, not a form field.
-
-
-
-
-
-
-
- ))}
-
-
- );
-}
-```
-
-Those two buttons are the concrete edits staff actually need on day one; a general-purpose limits editor is deliberately not built until someone asks for it.
-
-- [x] **Step 6: Run the whole suite**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run
-```
-
-Expected: `26 passed`, covering all ten of spec 4's listed tests.
-
-- [x] **Step 7: Commit**
-
-```bash
-git add adminsite/
-git commit -m "feat(adminsite): plan editing with both guard rails"
-```
-
----
-
-### Task 15: Image, compose, CI and docs
-
-**Files:**
-- Create: `adminsite/Dockerfile`, `adminsite/.dockerignore`
-- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`
-
-- [x] **Step 1: Write the Dockerfile**
-
-Context is `adminsite/`, like `web/` — this app has no shared-module dependency.
-
-```dockerfile
-FROM node:26-alpine AS deps
-
-WORKDIR /app
-
-COPY package.json package-lock.json* ./
-RUN npm install
-
-FROM node:26-alpine AS builder
-
-WORKDIR /app
-
-COPY --from=deps /app/node_modules ./node_modules
-COPY . .
-
-# Baked in at build time and must be reachable from the BROWSER, and present in
-# admin's ADMIN_ORIGIN. Wrong here means every request fails at runtime.
-ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083
-ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
-ARG NEXT_PUBLIC_ADMIN_ENV=production
-ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV
-
-RUN npm run build
-
-FROM node:26-alpine AS runner
-
-WORKDIR /app
-
-ENV NODE_ENV=production
-ENV NEXT_TELEMETRY_DISABLED=1
-
-RUN addgroup --system --gid 1001 nodejs && \
- adduser --system --uid 1001 nextjs
-
-COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
-
-USER nextjs
-
-EXPOSE 3000
-ENV PORT=3000
-ENV HOSTNAME="0.0.0.0"
-
-CMD ["node", "server.js"]
-```
-
-`adminsite/.dockerignore`:
-
-```
-node_modules
-.next
-.env
-*.lic
-```
-
-- [x] **Step 2: Add the compose service**
-
-In `deploy/docker-compose.site.yml`, after `admin`:
-
-```yaml
- adminsite:
- image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
- restart: unless-stopped
- ports:
- # 3002 is the marketing site; this takes 3004.
- - 3004:3000
- depends_on:
- - admin
-```
-
-- [x] **Step 3: Add the image build**
-
-In `.gitea/workflows/server-deploy.yml`, after the admin step:
-
-```yaml
- - name: Build and push adminsite image
- run: |
- IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/adminsite:latest"
- docker build \
- --build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
- --build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
- -t "$IMAGE" \
- -f adminsite/Dockerfile adminsite/
- docker push "$IMAGE"
-```
-
-- [x] **Step 4: Document it**
-
-In `CLAUDE.md`:
-
-- add `adminsite/` to the repository structure and the route table
-- record that it is served at `vantage-hq.hostxtra.co.uk`, listens on `3000` and is published as `3004`, and that the host sits outside `*.vantage.hostxtra.co.uk` on purpose because that namespace is per-tenant instance subdomains
-- add `ADMIN_API_URL` and `ADMIN_ENV` to the CI variables table beside `SITE_API_URL`, carrying the same browser-reachable warning
-- record that admin's HTTP surface gained `GET /auth/me`, `POST /auth/signup`, `GET /api/staff/instances/:id` and `GET /api/staff/subscriptions`, and that `GET /api/instances/:id/license` now returns the blob to its owner
-
-- [x] **Step 5: Build the image**
-
-```bash
-MSYS_NO_PATHCONV=1 docker build -q \
- --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \
- -f adminsite/Dockerfile -t vantage-adminsite:test adminsite/
-```
-
-Expected: an image ID. This is the check that catches a missing dependency the dev server tolerated.
-
-- [x] **Step 6: Confirm the self-hosted deployment is untouched**
-
-```bash
-grep -c "adminsite" deploy/docker-compose.yml
-cd deploy && MSYS_NO_PATHCONV=1 docker compose -f docker-compose.yml config --services | sort | tr '\n' ' '
-```
-
-Expected: `0`, then exactly `guacd redis server web`.
-
-- [x] **Step 7: Commit**
-
-```bash
-git add adminsite/ deploy/ .gitea/ CLAUDE.md
-git commit -m "feat(adminsite): image, compose service and image build"
-```
-
----
-
-### Task 16: Full verification
-
-Everything in containers, against the stack from spec 3's Task 11.
-
-- [ ] **Step 1: Suite green and a clean production build**
-
-```bash
-sh /tmp/noderun.sh adminsite npx vitest run
-sh /tmp/noderun.sh adminsite npm run build
-sh /tmp/noderun.sh adminsite npm run lint
-```
-
-Expected: `26 passed`; a successful standalone build; no lint errors.
-
-- [ ] **Step 2: Bring up the whole stack**
-
-Start Mongo, Redis, the control plane and admin exactly as in the spec-3 plan Task 11 steps 2–4, then adminsite pointed at admin, with admin's `ADMIN_ORIGIN` including it:
-
-```bash
-docker rm -f vadminsite >/dev/null 2>&1
-MSYS_NO_PATHCONV=1 docker build -q \
- --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \
- --build-arg NEXT_PUBLIC_ADMIN_ENV=sandbox \
- -f adminsite/Dockerfile -t vantage-adminsite:test adminsite/
-MSYS_NO_PATHCONV=1 docker run -d --name vadminsite -p 3004:3000 vantage-adminsite:test
-sleep 6
-curl -s -o /dev/null -w "adminsite: %{http_code}\n" localhost:3004/login
-```
-
-Admin must be started with `-e ADMIN_ORIGIN=http://localhost:3004`. Locally the origin is `localhost:3004`; in production it is `https://vantage-hq.hostxtra.co.uk`, and both must appear in `ADMIN_ORIGIN` if you want the local build to keep working against the deployed API.
-
-Expected: `200`.
-
-- [ ] **Step 3: Self-hosted purchase to working licence, timed, no documentation**
-
-In a browser at `http://localhost:3004`: sign up, open the verification link from admin's logs (`docker logs vadmin | grep verify`), sign in, link the instance UUID from a self-hosted install, download the licence, paste it into that install's `Settings → Licence`.
-
-Expected: the install reports `Valid · Self Hosted`. **Time it. Over five minutes means the flow needs work, not the plan.**
-
-- [ ] **Step 4: Cloud pass**
-
-Sign in as the cloud owner (`owner@example.com`), confirm the overview shows the instance as valid with days remaining, and that the instance's own `/settings/license` agrees.
-
-- [ ] **Step 5: Staff pass, the whole point of the ledger**
-
-Sign in with `I work at Vantage`, search accounts by the instance UUID, open the instance, read the ledger top to bottom, reissue, and confirm the control plane picks it up within a minute:
-
-```bash
-curl -s localhost:8080/api/license -b /tmp/a.txt | grep -oE '"state":"[a-z]+"|"tier":"[a-z_]+"'
-```
-
-Expected: the ledger shows the previous licence overprinted `Superseded` and linked to the new one; the control plane reports `valid`.
-
-- [ ] **Step 6: Confirm the guards both ways**
-
-```bash
-curl -s -o /dev/null -w "customer hitting staff API: %{http_code}\n" \
- localhost:8083/api/staff/accounts -b /tmp/c.txt
-curl -s -o /dev/null -w "customer reading another account's instance: %{http_code}\n" \
- localhost:8083/api/instances/11111111-2222-3333-4444-555555555555/license -b /tmp/c.txt
-```
-
-Expected: `401` and `404`. Then in the browser, as a customer, visit `/staff` and confirm it redirects to `/` rather than showing a refusal.
-
-- [ ] **Step 7: Not-connected state**
-
-```bash
-docker rm -f vadminsite-broken >/dev/null 2>&1
-MSYS_NO_PATHCONV=1 docker build -q \
- --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:9999 \
- -f adminsite/Dockerfile -t vantage-adminsite:broken adminsite/
-MSYS_NO_PATHCONV=1 docker run -d --name vadminsite-broken -p 3005:3000 vantage-adminsite:broken
-```
-
-Expected: `localhost:3005` renders the not-connected panel naming `ADMIN_API_URL` and `ADMIN_ORIGIN`, not a blank page or a spinner forever.
-
-- [ ] **Step 8: Environment badge and responsive check**
-
-Confirm the sandbox badge is hatched and visible on every screen in the `NEXT_PUBLIC_ADMIN_ENV=sandbox` build. Then at 375px width, walk the customer overview, instance detail and link flow — a customer hit by an expiry email opens this on a phone.
-
-- [ ] **Step 9: Clean up and commit**
-
-```bash
-docker rm -f vadminsite vadminsite-broken vadmin vadmin-server vadmin-mongo vadmin-redis
-docker rmi vantage-adminsite:test vantage-adminsite:broken
-git add -A
-git commit -m "chore: verify the admin site end to end" --allow-empty
-```
-
----
-
-## Rollout
-
-1. Point `vantage-hq.hostxtra.co.uk` at the host and terminate TLS in front of `3004`.
-2. Set `ADMIN_API_URL` and `ADMIN_ENV` as Gitea variables **before the first build** — `ADMIN_API_URL` is baked into the image, so changing it later means a rebuild, not a restart. It must be browser-reachable and listed in admin's `ADMIN_ORIGIN`.
-3. Add `https://vantage-hq.hostxtra.co.uk` to `ADMIN_ORIGIN` in the host `.env` and restart admin.
-4. Deploy admin first (tasks 1–2 change its API), then adminsite.
-5. `adminctl staff-add` per staff member if not already done.
-6. **First real job: licence the existing cloud instances.** Staff → Accounts → create an account, attach the instance, issue. They stay read-only until that is done.
-
-## Risks
-
-| Risk | Mitigation |
-|---|---|
-| `ADMIN_API_URL` misconfigured at build | Explicit not-connected panel naming the variable and `ADMIN_ORIGIN`; verified in Task 16 Step 7 |
-| Cross-origin cookies silently dropped | Both hosts stay under one registrable domain so `SameSite=Lax` still applies; documented in Global Constraints and exercised by every browser step in Task 16 |
-| Staff action against the wrong environment | Hatched badge on every screen from the root layout; confirmation on plan changes |
-| Customer session reaching staff data | Route-group guard (test 1) plus admin's own 401/404 (Task 16 Step 6). Two layers |
-| Customer confused by the self-hosted flow | Named UUID location, client-side format check, success lands on the download; five-minute bar in Task 16 Step 3 |
-| Plan edit mistaken for retroactive | Confirmation names each field and the count of licences already issued |
-| Ledger unreadable once an instance has years of history | Newest first, superseded rows dimmed and overprinted; filterable global view on `/staff/licenses` |
diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md
deleted file mode 100644
index 85a7325..0000000
--- a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md
+++ /dev/null
@@ -1,2161 +0,0 @@
-# Cloud Instance Creation — Phase 2: Creation, Free Lifecycle and Reclaim
-
-> **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:** Let a verified HQ customer create their own Free cloud instance from the portal, licence it automatically, remind them to renew, and reclaim it if they never do.
-
-**Architecture:** Admin gains a second, deliberately narrow write path into the control plane (`cloudprov`) that creates instances and users — `inject` keeps owning exactly three licence fields and is not touched. The Free licence runs a month plus the existing three-day grace. Reclaim is split across services on purpose: admin sends the notices because it knows the billing address, and the **control plane** performs the delete because it knows what an instance is made of.
-
-**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, `shared/provision`, `shared/license`.
-
-## Global Constraints
-
-- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
-- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
- ```sh
- # /tmp/gorun.sh
- 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" \
- 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".
-- **Admin's control-plane writes are confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry` on `instances`. Do not widen `inject`.
-- **The reaper defaults OFF.** `FREE_INSTANCE_REAP_AFTER` empty means never delete. It is set only in `deploy/docker-compose.site.yml`, never in `deploy/docker-compose.yml`.
-- **Free is one per account** and cloud-only by construction — `licensing.Issue` already refuses a deployment mismatch. Do not add a tier flag.
-- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`.
-- Paddle is **out of scope**. Accounts have an empty `PaddleCustomerID`.
-- Account roles, invitations and instance membership are **phase 3**. In phase 2 an account has exactly one user, so no role checks are needed anywhere.
-
-## Context this plan inherits
-
-Phase 1 shipped (commits `da3afca`..`5f35b57`, on `main`, **not yet pushed**):
-
-- `users` is unique on `(instance_id, email)`, not on `email` alone. The same address may hold a user in several instances.
-- Every lookup by email is scoped by instance. `services.GetUserByEmail` no longer exists.
-- `shared/models.User` has `HQUserID` (`hq_user_id`) and the constants `AuthLocal`, `AuthOIDC`, `AuthHQ`. **Nothing writes them yet — this phase is the first writer**, setting `AuthHQ` and `HQUserID` on the instance owner it creates.
-- `admin/internal/auth/cloud.go` is deleted; `/auth/login` points at `HandleCustomerLogin`. Every customer authenticates against `customer_users`.
-- `admin` already has account-first signup at `POST /auth/signup` (`admin/internal/auth/customer.go`) creating an `accounts` row plus an unverified `customer_users` row plus a verification email. **This phase adds no signup code** — it repoints the marketing form at it.
-
-Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), phase 2.
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| ------------------------------------------------------- | ------------------------------------------------------------ |
-| `admin/internal/cloudprov/cloudprov.go` | admin's ONLY instance/user write path into the control plane |
-| `admin/internal/lifecycle/lifecycle.go` | lapse sweep and the four renewal notices |
-| `server/internal/services/reap.go` | the Free-instance purge and its scheduler |
-| `adminsite/app/(customer)/instances/new/page.tsx` | create-instance form |
-| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component |
-| `site/components/AccountForm.tsx` | replaces `InstanceForm.tsx` |
-
-**Modified:**
-
-| Path | Change |
-| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
-| `admin/internal/db/db.go` | `ControlDB()` accessor |
-| `admin/internal/config/config.go` | `AppLoginURL` from `APP_LOGIN_URL` |
-| `admin/internal/models/models.go` | `StatusDeleted`, `NoticesSent` on `Instance` |
-| `admin/internal/mail/mail.go` | four lifecycle emails |
-| `admin/internal/api/customer.go` | `createInstance`, `renewInstance` |
-| `admin/internal/api/routes.go` | the two new customer routes |
-| `admin/internal/inject/inject.go` | reconciler marks vanished instances `deleted` |
-| `admin/cmd/main.go` | start the lifecycle sweeper |
-| `server/cmd/main.go` | start the reaper |
-| `adminsite/lib/api.ts` | `createInstance`, `renewInstance`, `"deleted"` status |
-| `adminsite/components/InstanceCard.tsx` | renew action, deletion countdown, monthly-aware bar |
-| `adminsite/app/(customer)/page.tsx` | create-instance call to action |
-| `site/app/start/page.tsx` | account-signup copy |
-| `site/lib/submit.ts` | `submitSignup` posts to admin |
-| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed |
-| `deploy/docker-compose.site.yml` | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` |
-| `.gitea/workflows/server-deploy.yml` | `ADMIN_API_URL` build arg for `site` |
-| `CLAUDE.md` | the boundary paragraph, sitesvc's table, the env tables |
-
-**Deleted:** `sitesvc/internal/api/signup.go`, `site/components/InstanceForm.tsx`.
-
----
-
-### Task 1: `cloudprov` — admin's instance and user write path
-
-**Files:**
-
-- Create: `admin/internal/cloudprov/cloudprov.go`
-- Modify: `admin/internal/db/db.go`, `admin/internal/config/config.go`
-
-**Interfaces:**
-
-- Consumes: `shared/provision.CreateInstance`, `CreateUserWithHash`, `RollbackInstance`; `shared/models`.
-- Produces:
- - `db.ControlDB() *mongo.Database`
- - `config.Config.AppLoginURL string`
- - `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)`
- - `cloudprov.DeleteUser(ctx, instanceID, userID string) error`
- - `cloudprov.RollbackInstance(ctx, instanceID string) error`
-
-- [ ] **Step 1: Add the `ControlDB` accessor**
-
-In `admin/internal/db/db.go`, after `func Control(name string) *mongo.Collection`, add:
-
-```go
-// ControlDB exposes the control-plane database itself, because shared/provision
-// takes a database rather than a collection.
-//
-// It is used by cloudprov and nothing else. Reach for Control(name) unless you
-// are calling into shared/provision.
-func ControlDB() *mongo.Database { return controlDB }
-```
-
-Then update the package doc comment at the top of the file. Replace the sentence beginning "Control() is the control plane's database" with:
-
-```go
-// Control() is the control plane's database. Admin's access to it is narrow and
-// lives in exactly two packages: inject writes three licence fields on
-// `instances`, and cloudprov creates and rolls back `instances` and `users` when
-// a customer provisions a cloud instance. Nothing else may write there, and a
-// third write path is a design change rather than a refactor.
-```
-
-- [ ] **Step 2: Add `APP_LOGIN_URL` to config**
-
-In `admin/internal/config/config.go`, add `AppLoginURL string` to the `Config` struct after `PublicURL`, and in `Load()` add to the struct literal:
-
-```go
- AppLoginURL: os.Getenv("APP_LOGIN_URL"),
-```
-
-It is **not** added to the required-variables map: an admin with no `APP_LOGIN_URL` still works, it just omits the sign-in link from the instance-ready email. Boot-failing on a cosmetic value would be worse than the missing link.
-
-- [ ] **Step 3: Write `cloudprov`**
-
-Create `admin/internal/cloudprov/cloudprov.go`:
-
-```go
-// Package cloudprov provisions cloud instances in the control plane.
-//
-// This is admin's second and final write path into the control-plane database,
-// alongside inject. It writes `instances` and `users` and nothing else. A third
-// write target, or a write to any other collection from here, is a design change
-// and not a refactor — see the spec's "Admin's control-plane write boundary".
-//
-// Every function here is called from a customer request, so each one leaves the
-// control plane in a consistent state or not at all: the caller unwinds in
-// reverse order on failure, and RollbackInstance refuses to delete an instance
-// that has users.
-package cloudprov
-
-import (
- "context"
- "fmt"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
- sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// CreateInstance creates a control-plane instance and its owner.
-//
-// The owner's password hash is COPIED from the HQ account rather than shared.
-// Changing the password on either side does not propagate, and they diverge from
-// that moment — accepted deliberately, because propagating a hash across two
-// services' databases is a worse problem than two passwords that started equal.
-//
-// On owner-insert failure the instance is rolled back, so a failed provision
-// never leaves a slug permanently occupied by an instance nobody owns.
-func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
- inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
- if err != nil {
- return nil, err
- }
-
- u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
- ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
- if err != nil {
- if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
- return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
- }
- return nil, err
- }
-
- // hq_user_id is what phase 3 uses to find every row projected from one HQ
- // user when its password changes. Set at creation so the owner is not a
- // special case later.
- if _, err := db.Control("users").UpdateOne(ctx,
- bson.M{"user_id": u.UserID},
- bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
- return nil, fmt.Errorf("set hq_user_id: %w", err)
- }
-
- return inst, nil
-}
-
-// DeleteUser removes one control-plane user. Used only to unwind a failed
-// provision.
-func DeleteUser(ctx context.Context, instanceID, userID string) error {
- _, err := db.Control("users").DeleteOne(ctx,
- bson.M{"instance_id": instanceID, "user_id": userID})
- return err
-}
-
-// RollbackInstance deletes an instance that has no users.
-func RollbackInstance(ctx context.Context, instanceID string) error {
- return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
-}
-
-// OwnerUserID returns the control-plane user_id of an instance's owner, so a
-// caller can unwind a partial provision without re-deriving it.
-func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
- var u sharedmodels.User
- err := db.Control("users").FindOne(ctx, bson.M{
- "instance_id": instanceID,
- "role": sharedmodels.RoleOwner,
- }).Decode(&u)
- if err != nil {
- return "", err
- }
- return u.UserID, nil
-}
-```
-
-- [ ] **Step 4: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 5: Confirm the write boundary holds**
-
-Run:
-
-```sh
-grep -rn 'db.Control(' --include=*.go admin/ | grep -v '_test'
-```
-
-Expected: matches only in `admin/internal/inject/inject.go`, `admin/internal/cloudprov/cloudprov.go`, and read-only uses in `admin/internal/api/staff.go` and `admin/internal/licensing/link.go`. Any **write** (`UpdateOne`, `InsertOne`, `DeleteOne`) outside `inject` and `cloudprov` is a boundary violation — report it rather than fixing it silently.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/internal/cloudprov/ admin/internal/db/db.go admin/internal/config/config.go
-git commit -m "feat(admin): cloudprov, the instance provisioning write path
-
-Admin's second and final write path into the control plane. It creates
-instances and users and nothing else; inject still owns exactly three
-licence fields and is untouched.
-
-The owner's password hash is copied from the HQ account, not shared. The
-two diverge on the next password change, which is accepted: propagating a
-hash across two databases is worse than two passwords that started equal.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 2: Create an instance
-
-**Files:**
-
-- Modify: `admin/internal/models/models.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go`
-
-**Interfaces:**
-
-- Consumes: `cloudprov` from Task 1; `licensing.Issue`, `licensing.ErrFreeLimit`; `inject.Deliver`; `auth.Current`.
-- Produces:
- - `models.StatusDeleted = "deleted"`
- - `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty`
- - `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error`
- - `POST /api/instances`
-
-- [ ] **Step 1: Add the model fields**
-
-In `admin/internal/models/models.go`, add to the instance-status constants:
-
-```go
- // StatusDeleted marks an instance the control plane has reaped. The row is
- // kept because the licence history references it and support questions
- // outlive the instance.
- StatusDeleted = "deleted"
-```
-
-And to the `Instance` struct, after `InjectFailedAt`:
-
-```go
- // NoticesSent holds the lifecycle notice keys already emailed for the
- // CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
- // clears it, so the next term starts the sequence again. It is what stops a
- // restart re-sending a notice.
- NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
-```
-
-- [ ] **Step 2: Add the instance-ready email**
-
-In `admin/internal/mail/mail.go`, add:
-
-```go
-// SendInstanceReady tells a customer their cloud instance exists, where it is,
-// and when its licence runs out.
-//
-// The expiry is stated here rather than only in a later reminder: a Free licence
-// that quietly expires in a month is a surprise, and the first email is the one
-// people keep.
-func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
- body := fmt.Sprintf("%s is ready.\n\n", instanceName)
- if loginURL != "" {
- body += "Sign in here:\n\n" + loginURL + "\n\n"
- }
- body += fmt.Sprintf(
- "Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
- "Sign in with the same email address and password you use for your Vantage account. "+
- "Changing one does not change the other.\n",
- expires.Format("2 January 2006"))
- return send(to, instanceName+" is ready", body)
-}
-```
-
-Add `"time"` to the imports.
-
-- [ ] **Step 3: Add the handler**
-
-In `admin/internal/api/customer.go`, add:
-
-```go
-// createInstance provisions a Free cloud instance for the calling account.
-//
-// The ordering matters and each step unwinds the previous one. Licence issuance
-// and email are deliberately NOT allowed to fail the request: the instance
-// exists and the customer can sign in, they see the licence banner, and staff
-// can issue by hand. Rolling back an instance the customer can already see would
-// be worse than shipping it unlicensed.
-func createInstance(c *gin.Context) {
- var body struct {
- Name string `json:"name"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
- return
- }
- name := strings.TrimSpace(body.Name)
- ctx := c.Request.Context()
- s := auth.Current(c)
-
- // Pre-check the Free rule so we never create an instance we then cannot
- // licence. licensing.Issue enforces it too; this is the friendly refusal.
- n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
- "account_id": s.AccountID,
- "tier": license.TierFree,
- "status": bson.M{"$ne": models.StatusCancelled},
- })
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
- return
- }
- if n > 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "this account already has a Free instance"})
- return
- }
-
- var cu models.CustomerUser
- if err := db.Admin("customer_users").FindOne(ctx,
- bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
- return
- }
-
- inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
- if err != nil {
- if errors.Is(err, provision.ErrEmailTaken) {
- // users.email is unique per instance, so this means the address
- // already owns a user in an instance we are not creating — a legacy
- // cloud tenant. Staff have to attach that one by hand.
- c.JSON(http.StatusConflict, gin.H{
- "error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
- return
- }
- if errors.Is(err, provision.ErrNameRejected) {
- c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
- return
- }
-
- rec := models.Instance{
- InstanceID: inst.InstanceID,
- AccountID: s.AccountID,
- Name: inst.Name,
- Slug: inst.Slug,
- Deployment: license.DeploymentCloud,
- Status: models.StatusActive,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
- // Unwind in reverse: the owner first, because RollbackInstance refuses
- // an instance that still has users.
- if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
- _ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
- }
- if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
- log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
- return
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
- Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
-
- // Past this point nothing fails the request.
- lic, err := licensing.Issue(ctx, licensing.IssueInput{
- InstanceID: inst.InstanceID,
- Tier: license.TierFree,
- Term: "monthly",
- Reason: models.ReasonNew,
- IssuedBy: "self-serve",
- })
- if err != nil {
- log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
- c.JSON(http.StatusCreated, rec)
- return
- }
- inject.Deliver(ctx, lic)
-
- if mail.Enabled() {
- if err := mail.SendInstanceReady(s.Email, inst.Name,
- loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
- log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
- }
- }
-
- rec.Tier = lic.Tier
- rec.CurrentLicense = lic.LicenseID
- c.JSON(http.StatusCreated, rec)
-}
-
-// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
-// yields an empty string, and the email simply omits the link.
-func loginURLFor(slug string) string {
- if appLoginURL == "" {
- return ""
- }
- return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
-}
-
-// appLoginURL is set once at boot from config.
-var appLoginURL string
-
-// SetAppLoginURL is called from main.
-func SetAppLoginURL(v string) { appLoginURL = v }
-```
-
-Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported.
-
-- [ ] **Step 4: Wire the route and the config**
-
-In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.GET("/account", getAccount)`:
-
-```go
- cust.POST("/instances", createInstance)
-```
-
-In `admin/cmd/main.go`, after the config is loaded and before the HTTP server starts, add:
-
-```go
- api.SetAppLoginURL(cfg.AppLoginURL)
-```
-
-Import `admin/internal/api` there if it is not already imported (it will be, for `api.Routes`).
-
-- [ ] **Step 5: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/
-git commit -m "feat(admin): POST /api/instances creates a Free cloud instance
-
-Provisions the control-plane instance and its owner, records the
-admin_instances row, issues and injects a Free licence, and emails the
-customer where it is and when it expires.
-
-Licence issuance and email cannot fail the request. The instance exists
-and the customer can sign in; rolling back something they can already see
-would be worse than shipping it unlicensed for staff to fix.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 3: Renew a Free instance
-
-**Files:**
-
-- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go`
-
-**Interfaces:**
-
-- Consumes: `ownedInstance`, `licensing.Issue`, `inject.Deliver`.
-- Produces:
- - `mail.SendRenewed(to, instanceName string, expires time.Time) error`
- - `POST /api/instances/:id/renew`
- - `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go`
-
-- [ ] **Step 1: Add the renewal window constant**
-
-In `admin/internal/models/models.go`, after `GracePeriod`:
-
-```go
-// RenewWindow is how long before expiry a Free instance may be renewed.
-//
-// Renewal stays available after expiry too, right up until the reaper takes the
-// instance, so the same button rescues a lapsed instance instead of needing a
-// second mechanism.
-const RenewWindow = 7 * 24 * time.Hour
-```
-
-- [ ] **Step 2: Add the renewed email**
-
-In `admin/internal/mail/mail.go`:
-
-```go
-// SendRenewed confirms a renewal and states the new date.
-func SendRenewed(to, instanceName string, expires time.Time) error {
- return send(to, instanceName+" renewed",
- fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n",
- instanceName, expires.Format("2 January 2006")))
-}
-```
-
-- [ ] **Step 3: Add the handler**
-
-In `admin/internal/api/customer.go`:
-
-```go
-// renewInstance extends a Free licence by another term.
-//
-// Renewal is manual on purpose: it is the entire reclaim signal. An instance
-// nobody renews is an instance nobody is using, and that is what makes the
-// reaper safe to run at all.
-func renewInstance(c *gin.Context) {
- inst, ok := ownedInstance(c, c.Param("id"))
- if !ok {
- return
- }
- if inst.Tier != license.TierFree {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "only Free instances renew here; paid plans renew through billing"})
- return
- }
-
- ctx := c.Request.Context()
-
- var current models.License
- if err := db.Admin("licenses").FindOne(ctx,
- bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
- return
- }
- if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
- current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
- return
- }
-
- lic, err := licensing.Issue(ctx, licensing.IssueInput{
- InstanceID: inst.InstanceID,
- Tier: license.TierFree,
- Term: "monthly",
- Reason: models.ReasonRenewal,
- IssuedBy: "self-serve",
- })
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- inject.Deliver(ctx, lic)
-
- // Clear the notice log so the next term starts the sequence again. Issue has
- // already set status back to active.
- if _, err := db.Admin("admin_instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID},
- bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
- log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
- }
-
- s := auth.Current(c)
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
- Target: inst.InstanceID, IP: c.ClientIP()})
-
- if mail.Enabled() {
- if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
- log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
- }
- }
-
- c.JSON(http.StatusOK, lic)
-}
-```
-
-Add `"fmt"` to `customer.go`'s imports.
-
-- [ ] **Step 4: Wire the route**
-
-In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.POST("/instances/:id/relink", relinkInstance)`:
-
-```go
- cust.POST("/instances/:id/renew", renewInstance)
-```
-
-- [ ] **Step 5: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/
-git commit -m "feat(admin): renew a Free instance from the portal
-
-Available from seven days before expiry and, deliberately, at any point
-after it up to deletion, so the same button rescues a lapsed instance.
-
-Renewal is manual because it is the entire reclaim signal: an instance
-nobody renews is one nobody is using, which is what makes reaping safe.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 4: The lapse sweep and the four notices
-
-**Files:**
-
-- Create: `admin/internal/lifecycle/lifecycle.go`
-- Modify: `admin/internal/mail/mail.go`, `admin/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: `models.Instance`, `models.License`, `mail`.
-- Produces:
- - `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning`
- - `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)`
-
-The notice schedule, all relative to the licence's `ExpiresAt` (which already includes the three-day grace):
-
-| Key | Sent when | Says |
-| ---------- | ---------------------- | ---------------------------- |
-| `expiring` | 7 days before expiry | renew, one click |
-| `expired` | at expiry | read-only; deleted in N days |
-| `delete_7` | 7 days before deletion | deleted in 7 days |
-| `delete_1` | 1 day before deletion | deleted tomorrow |
-
-- [ ] **Step 1: Add the three emails**
-
-In `admin/internal/mail/mail.go`:
-
-```go
-// SendExpiring is the renew-now nudge, seven days out.
-func SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
- return send(to, instanceName+" expires on "+expires.Format("2 January"),
- fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+
- "Renew it in one click:\n\n%s\n\n"+
- "If you do nothing, the instance keeps running but stops accepting changes.\n",
- instanceName, expires.Format("2 January 2006"), portalURL))
-}
-
-// SendExpired states plainly what has stopped and what happens next.
-//
-// It names the deletion date rather than a vague warning: the whole point of the
-// sequence is that nobody loses an instance without having been told a date.
-func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
- return send(to, instanceName+" is now read-only",
- fmt.Sprintf("%s's Free licence has expired.\n\n"+
- "Your servers and monitors keep running and your agents keep their keys, "+
- "but changes are disabled.\n\n"+
- "Renew it here:\n\n%s\n\n"+
- "If it is not renewed, the instance and everything in it will be deleted on %s.\n",
- instanceName, portalURL, deleteOn.Format("2 January 2006")))
-}
-
-// SendDeletionWarning is the final countdown, sent at seven days and one day.
-func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
- when := fmt.Sprintf("in %d days", daysLeft)
- if daysLeft <= 1 {
- when = "tomorrow"
- }
- return send(to, instanceName+" will be deleted "+when,
- fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+
- "This cannot be undone. Renew it here to keep it:\n\n%s\n",
- instanceName, when, deleteOn.Format("2 January 2006"), portalURL))
-}
-```
-
-- [ ] **Step 2: Write the lifecycle sweeper**
-
-Create `admin/internal/lifecycle/lifecycle.go`:
-
-```go
-// Package lifecycle marks lapsed Free instances and sends the renewal notices.
-//
-// It sends; it never deletes. Deletion belongs to the control plane, which is
-// the only service that knows what an instance is made of. The two are kept
-// apart on purpose: a bug here sends a wrong email, a bug there loses data.
-package lifecycle
-
-import (
- "context"
- "log"
- "slices"
- "time"
-
- "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"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// Interval is how often the sweep runs. Hourly is far finer than the daily
-// granularity of the notices, which means a notice goes out within an hour of
-// becoming due rather than up to a day late.
-const Interval = time.Hour
-
-// Notice keys, recorded on the instance so a restart cannot re-send one.
-const (
- noticeExpiring = "expiring"
- noticeExpired = "expired"
- noticeDelete7 = "delete_7"
- noticeDelete1 = "delete_1"
-)
-
-// portalURL is the customer portal address used in notice emails.
-var portalURL string
-
-// SetPortalURL is called once at boot.
-func SetPortalURL(v string) { portalURL = v }
-
-// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails
-// can name the real deletion date. Zero means the reaper is off, and the
-// deletion notices are then suppressed — promising a deletion that will never
-// happen would be a lie, and a scarier one than saying nothing.
-var reapAfter time.Duration
-
-// Run performs one sweep: mark lapsed instances, then send whatever notices are
-// due. Errors on one instance never stop the others.
-func Run(ctx context.Context) error {
- now := time.Now().UTC()
-
- cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
- "deployment": license.DeploymentCloud,
- "tier": license.TierFree,
- "status": bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}},
- })
- if err != nil {
- return err
- }
- var instances []models.Instance
- if err := cur.All(ctx, &instances); err != nil {
- return err
- }
-
- for _, inst := range instances {
- var lic models.License
- if err := db.Admin("licenses").FindOne(ctx,
- bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
- continue // no licence yet; nothing to expire
- }
-
- // Flip active -> lapsed once the licence is past its expiry.
- if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive {
- if _, err := db.Admin("admin_instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID},
- bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil {
- log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err)
- }
- }
-
- due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
- if due == "" {
- continue
- }
- if err := sendNotice(ctx, inst, lic, due); err != nil {
- log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err)
- continue
- }
- if _, err := db.Admin("admin_instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID},
- bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
- log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err)
- }
- }
- return nil
-}
-
-// dueNotice returns the most urgent unsent notice, or "".
-//
-// Most urgent first, so an instance that was missed for a week — because admin
-// was down — sends the one that matters now rather than working through a
-// backlog of stale warnings.
-func dueNotice(now, expires time.Time, sent []string) string {
- deleteOn := expires.Add(reapAfter)
-
- if reapAfter > 0 {
- if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) {
- return noticeDelete1
- }
- if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) {
- return noticeDelete7
- }
- }
- if now.After(expires) && !slices.Contains(sent, noticeExpired) {
- return noticeExpired
- }
- if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) {
- return noticeExpiring
- }
- return ""
-}
-
-func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error {
- if !mail.Enabled() {
- return nil
- }
- var acct models.Account
- if err := db.Admin("accounts").FindOne(ctx,
- bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil {
- return err
- }
- to := acct.BillingEmail
- deleteOn := lic.ExpiresAt.Add(reapAfter)
-
- switch key {
- case noticeExpiring:
- return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
- case noticeExpired:
- return mail.SendExpired(to, inst.Name, portalURL, deleteOn)
- case noticeDelete7:
- return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
- case noticeDelete1:
- return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
- }
- return nil
-}
-
-// Start runs the sweep on a ticker until ctx is cancelled.
-//
-// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they
-// disagree, the emails name a date the reaper does not honour — so they are
-// documented as a pair in CLAUDE.md and set together in the compose file.
-func Start(ctx context.Context, reapAfterDur time.Duration) {
- reapAfter = reapAfterDur
- go func() {
- runOnce(ctx)
- t := time.NewTicker(Interval)
- defer t.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-t.C:
- runOnce(ctx)
- }
- }
- }()
-}
-
-func runOnce(ctx context.Context) {
- runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
- defer cancel()
- if err := Run(runCtx); err != nil {
- log.Printf("lifecycle: %v", err)
- }
-}
-```
-
-- [ ] **Step 3: Start it at boot**
-
-In `admin/internal/config/config.go`, add `ReapAfter time.Duration` to `Config`, and in `Load()`, after the origins loop:
-
-```go
- // Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can
- // name the real deletion date. An unparseable value is refused rather than
- // silently treated as "off": a typo here would quietly stop every deletion
- // warning while the control plane still deletes.
- if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" {
- d, err := time.ParseDuration(v)
- if err != nil {
- return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err)
- }
- c.ReapAfter = d
- }
-```
-
-Add `"time"` to that file's imports.
-
-In `admin/cmd/main.go`, next to `inject.StartReconciler(ctx)`:
-
-```go
- lifecycle.SetPortalURL(cfg.PublicURL)
- lifecycle.Start(ctx, cfg.ReapAfter)
-```
-
-- [ ] **Step 4: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add admin/
-git commit -m "feat(admin): lapse sweep and the four renewal notices
-
-Hourly sweep marks expired Free instances lapsed and sends at most one
-notice per instance per pass, most urgent first, recorded on the document
-so a restart cannot re-send.
-
-Deletion warnings are suppressed when the reaper is off. Promising a
-deletion that will never happen is a lie, and a scarier one than silence.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 5: The reaper
-
-**Files:**
-
-- Create: `server/internal/services/reap.go`
-- Modify: `server/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: `db.Col`, `db.Database`.
-- Produces: `services.ReapFreeInstances(ctx) (checked, purged int, err error)`, `services.StartReaper(ctx)`, `services.PurgeInstance(ctx, instanceID string) (map[string]int64, error)`.
-
-This is the only irreversible path in the system. Read the eligibility rule twice.
-
-- [ ] **Step 1: Write the reaper**
-
-Create `server/internal/services/reap.go`:
-
-```go
-package services
-
-import (
- "context"
- "fmt"
- "log"
- "os"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// ReapInterval is how often eligibility is re-checked. Deletion is measured in
-// days, so an hour is ample and keeps the query cheap.
-const ReapInterval = time.Hour
-
-// instanceScopedCollections is every collection carrying instance_id.
-//
-// This list is the reason the reaper lives in the control plane rather than in
-// admin: it is knowledge of what an instance is made of, and it belongs in the
-// codebase that defines these documents. A collection added without being added
-// here leaks rows that outlive their instance.
-//
-// `instances` is deliberately absent — it is keyed by instance_id, not scoped by
-// it, and is deleted last so a crash mid-purge leaves an instance that will be
-// retried rather than orphaned rows with no instance.
-var instanceScopedCollections = []string{
- "assignments",
- "audit_logs",
- "console_sessions",
- "incidents",
- "instance_oidc",
- "keys",
- "monitor_rollups",
- "monitors",
- "notification_channels",
- "secrets",
- "servers",
- "settings",
- "users",
- "workflow_runs",
- "workflow_steps",
- "workflows",
-}
-
-// reapAfter reads FREE_INSTANCE_REAP_AFTER.
-//
-// An empty or unparseable value returns 0, which disables the reaper. Defaulting
-// OFF is the whole safety design: a deployment that never heard of this variable
-// must never delete a customer's instance.
-func reapAfter() time.Duration {
- v := os.Getenv("FREE_INSTANCE_REAP_AFTER")
- if v == "" {
- return 0
- }
- d, err := time.ParseDuration(v)
- if err != nil {
- log.Printf("reaper: FREE_INSTANCE_REAP_AFTER %q is not a duration; reaper stays OFF", v)
- return 0
- }
- if d <= 0 {
- return 0
- }
- return d
-}
-
-// PurgeInstance deletes an instance and every document scoped to it.
-//
-// Idempotent: re-running over a half-deleted instance completes it. The instance
-// document goes last, so an interrupted purge is retried on the next sweep
-// instead of leaving rows behind with nothing pointing at them.
-func PurgeInstance(ctx context.Context, instanceID string) (map[string]int64, error) {
- counts := map[string]int64{}
- for _, name := range instanceScopedCollections {
- res, err := db.Col(name).DeleteMany(ctx, bson.M{"instance_id": instanceID})
- if err != nil {
- return counts, fmt.Errorf("purge %s: %w", name, err)
- }
- if res.DeletedCount > 0 {
- counts[name] = res.DeletedCount
- }
- }
- res, err := db.Col("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID})
- if err != nil {
- return counts, fmt.Errorf("purge instances: %w", err)
- }
- if res.DeletedCount > 0 {
- counts["instances"] = res.DeletedCount
- }
- return counts, nil
-}
-
-// ReapFreeInstances deletes Free cloud instances whose licence expired longer
-// ago than the configured window.
-//
-// Eligibility requires ALL of:
-// - license_tier == "free" — a paid instance is never eligible
-// - license_expiry present — an instance that was never licensed, or whose
-// issuance failed, has no expiry and is never eligible whatever its age
-// - license_expiry older than now minus the window
-//
-// Every one of those is a positive assertion. Nothing is eligible by default,
-// which is what makes a missing or stale field fail safe.
-func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
- window := reapAfter()
- if window == 0 {
- return 0, 0, nil
- }
- cutoff := time.Now().UTC().Add(-window)
-
- cur, err := db.Col("instances").Find(ctx, bson.M{
- "license_tier": license.TierFree,
- "license_expiry": bson.M{"$ne": nil, "$lt": cutoff},
- })
- if err != nil {
- return 0, 0, err
- }
- var doomed []struct {
- InstanceID string `bson:"instance_id"`
- Name string `bson:"name"`
- Slug string `bson:"slug"`
- Expiry time.Time `bson:"license_expiry"`
- }
- if err := cur.All(ctx, &doomed); err != nil {
- return 0, 0, err
- }
-
- for _, d := range doomed {
- checked++
-
- // Logged BEFORE the delete. Afterwards there is nothing left to
- // describe, and "why did this instance vanish" is the only question
- // anyone will ever ask about this code.
- //
- // The process log is the durable record, not the audit row: the purge
- // deletes this instance's audit_logs along with everything else, so an
- // audit entry written here would delete itself moments later. It is
- // written anyway, because an operator reading audit during the window
- // should see it coming.
- log.Printf("REAPING instance %s (%s, slug=%s) — Free licence expired %s, past the %s window",
- d.InstanceID, d.Name, d.Slug, d.Expiry.Format(time.RFC3339), window)
- LogEvent(d.InstanceID, "instance.reaped", "system", "", "",
- fmt.Sprintf("free licence expired %s, window %s", d.Expiry.Format(time.RFC3339), window))
-
- counts, err := PurgeInstance(ctx, d.InstanceID)
- if err != nil {
- log.Printf("reaper: purge of %s failed after %v: %v", d.InstanceID, counts, err)
- continue
- }
- purged++
- log.Printf("reaped instance %s: %v", d.InstanceID, counts)
- }
- return checked, purged, nil
-}
-
-// StartReaper sweeps once at boot, then on a ticker until ctx is cancelled, and
-// logs loudly which mode it is in.
-//
-// The pass at boot follows inject.StartReconciler's precedent and earns its keep
-// the same way: it makes a restart a supported way to force a sweep, which is
-// the only way this code can be exercised on demand — the ticker is hourly and
-// deletion is measured in days.
-func StartReaper(ctx context.Context) {
- window := reapAfter()
- if window == 0 {
- log.Printf("reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)")
- return
- }
- log.Printf("reaper: ENABLED — Free instances are deleted %s after their licence expires", window)
-
- go func() {
- reapOnce(ctx)
-
- t := time.NewTicker(ReapInterval)
- defer t.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-t.C:
- reapOnce(ctx)
- }
- }
- }()
-}
-
-func reapOnce(ctx context.Context) {
- runCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
- defer cancel()
-
- checked, purged, err := ReapFreeInstances(runCtx)
- if err != nil {
- log.Printf("reaper: %v", err)
- return
- }
- if purged > 0 {
- log.Printf("reaper: checked %d, purged %d", checked, purged)
- }
-}
-```
-
-**Note on the audit write:** `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details string)` is the real helper in `server/internal/services/audit.go:14`. It returns nothing, so there is no error to handle — and its row is deleted by the purge moments later anyway. The durable record is the `log.Printf` line above it, which names the instance, slug, expiry and window. Do not invent a new audit helper, and do not try to preserve an audit row for a deleted instance; admin's `admin_audit` is where cross-instance history lives.
-
-- [ ] **Step 2: Start it at boot**
-
-In `server/cmd/main.go`, next to `monitorsched.Start(context.Background())`:
-
-```go
- services.StartReaper(context.Background())
-```
-
-- [ ] **Step 3: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh server go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 4: Confirm the collection list is complete**
-
-Run:
-
-```sh
-grep -rho 'db\.Col("[a-z_]*"' server/internal/ | sed 's/db.Col("//;s/"//' | sort -u
-```
-
-Expected output, exactly:
-
-```
-assignments
-audit_logs
-console_sessions
-incidents
-instance_oidc
-instances
-keys
-migrations
-monitor_rollups
-monitors
-notification_channels
-orgs
-secrets
-servers
-settings
-users
-workflow_runs
-workflow_steps
-workflows
-```
-
-Every name there must appear in `instanceScopedCollections` except three: `instances` (deleted last, by `instance_id`), `migrations` (has no `instance_id`) and `orgs` (the pre-rename collection, empty after migration 0004). If the real output contains a name not in that list, add it to `instanceScopedCollections` and say so in your report.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add server/
-git commit -m "feat(server): reap Free instances whose licence lapsed
-
-The control plane owns deletion because it is the only service that knows
-what an instance is made of; mirroring that collection list into admin
-would drift, and a drift here deletes the wrong rows.
-
-Defaults OFF. Eligibility is three positive assertions — Free tier, an
-expiry that exists, and an expiry past the window — so a missing or stale
-field is never eligible. The instance document is deleted last, making an
-interrupted purge retryable rather than orphaning rows.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 6: The reconciler marks reaped instances
-
-**Files:**
-
-- Modify: `admin/internal/inject/inject.go`
-
-**Interfaces:**
-
-- Consumes: `models.StatusDeleted` from Task 2.
-- Produces: nothing new.
-
-The reaper deletes the control-plane instance. Admin's `admin_instances` row must stop claiming it is active, or the lifecycle sweep keeps emailing about an instance that no longer exists.
-
-- [ ] **Step 1: Mark vanished instances deleted**
-
-In `admin/internal/inject/inject.go`, inside `Reconcile`'s loop, replace:
-
-```go
- 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
- }
-```
-
-with:
-
-```go
- var remote sharedmodels.Instance
- if err := db.Control("instances").FindOne(ctx,
- bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
- // The control plane's reaper deletes lapsed Free instances. Record
- // that here rather than re-logging it every fifteen minutes forever,
- // and so the lifecycle sweep stops emailing about it.
- if errors.Is(err, mongo.ErrNoDocuments) {
- if _, uErr := db.Admin("admin_instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID},
- bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil {
- log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr)
- } else {
- log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID)
- }
- continue
- }
- log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
- continue
- }
-```
-
-Add `"errors"` and `"go.mongodb.org/mongo-driver/v2/mongo"` to the imports.
-
-- [ ] **Step 2: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add admin/internal/inject/inject.go
-git commit -m "feat(admin): reconciler marks reaped instances deleted
-
-Without this the row stays active forever, the reconciler re-logs the
-same miss every fifteen minutes, and the lifecycle sweep keeps emailing
-about an instance that no longer exists.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 7: The HQ portal — create and renew
-
-**Files:**
-
-- Create: `adminsite/app/(customer)/instances/new/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`
-- Modify: `adminsite/lib/api.ts`, `adminsite/components/InstanceCard.tsx`, `adminsite/app/(customer)/page.tsx`
-
-**Interfaces:**
-
-- Consumes: `POST /api/instances` (Task 2), `POST /api/instances/:id/renew` (Task 3).
-- Produces: `api.createInstance(name)`, `api.renewInstance(id)`.
-
-Design rules from `CLAUDE.md` that bind this task: `adminsite` uses only `var(--…)` Tailwind tokens — **no component may carry a hex value**. Licence state never reads by colour alone; every pill carries a shape and a text label. Light is the default theme and must stay so.
-
-- [ ] **Step 1: Extend the API client**
-
-In `adminsite/lib/api.ts`:
-
-- Change `InstanceStatus` to include `"deleted"`:
- ```ts
- export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
- ```
-- Add `notices_sent?: string[];` to the `Instance` interface.
-- Add to the `api` object, after `link`:
-
- ```ts
- createInstance: (name: string) => post("/api/instances", { name }),
- renewInstance: (id: string) => post(`/api/instances/${id}/renew`, {}),
- ```
-
-- [ ] **Step 2: Write the create form**
-
-Create `adminsite/app/(customer)/instances/new/CreateForm.tsx`:
-
-```tsx
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { ApiError, api } from "@/lib/api";
-import { Button } from "@/components/Button";
-import { Field } from "@/components/Field";
-
-function slugify(value: string) {
- return value
- .toLowerCase()
- .trim()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-|-$/g, "");
-}
-
-export function CreateForm() {
- const [name, setName] = useState("");
- const [error, setError] = useState(null);
- const router = useRouter();
- const qc = useQueryClient();
-
- const create = useMutation({
- mutationFn: () => api.createInstance(name.trim()),
- onSuccess: async () => {
- await qc.invalidateQueries({ queryKey: ["account"] });
- router.push("/");
- },
- onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
- });
-
- const slug = slugify(name);
-
- return (
-
- );
-}
-```
-
-The real signatures, already checked — do not change these components:
-
-- `Field` takes `React.InputHTMLAttributes` plus `label: string`, `hint?: React.ReactNode`, `error?: string`. **It renders its own `` and takes no children**, which is why the slug preview goes in `hint`.
-- `Button` takes `React.ButtonHTMLAttributes` plus `variant?: "solid" | "line"`, and already carries its own classes. Do not pass a `className` of your own.
-
-- [ ] **Step 3: Write the page**
-
-Create `adminsite/app/(customer)/instances/new/page.tsx`:
-
-```tsx
-import type { Metadata } from "next";
-import { CreateForm } from "./CreateForm";
-
-export const metadata: Metadata = { title: "New instance" };
-
-export default function NewInstancePage() {
- return (
-
-
-
Create a free instance
-
- An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Free covers three servers, and the licence runs for a month
- at a time — we email you before it needs renewing.
-
-
-
-
- );
-}
-```
-
-- [ ] **Step 4: Add the call to action**
-
-In `adminsite/app/(customer)/page.tsx`, replace the "No instances yet" block's contents with a version that offers the action. Keep the existing wrapper classes; change the inner markup to:
-
-```tsx
-
-
No instances yet
-
- Create a free cloud instance and we host it, with your licence applied automatically. Or buy a self-hosted licence, install Vantage on your own server, and link it here to get your licence
- file.
-
-
- Create a free instance
-
-
-```
-
-And below the instances grid, when the account has instances but no Free cloud one, add:
-
-```tsx
-{
- data.instances.length > 0 && !data.instances.some((i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted") && (
-
- Create a free instance
-
- );
-}
-```
-
-- [ ] **Step 5: Renew action and deletion countdown on the card**
-
-In `adminsite/components/InstanceCard.tsx`:
-
-- Make it a client component: add `"use client";` as the first line, and import `useMutation`, `useQueryClient` from `@tanstack/react-query` and `api` from `@/lib/api`.
-- The progress bar currently divides by 365, which renders a 30-day Free licence as a 8% sliver. Make the denominator depend on the tier:
- ```tsx
- const termDays = instance.tier === "free" ? 30 : 365;
- ```
- and use `(days / termDays) * 100` in the width calculation.
-- Add, after the `state === "expired"` paragraph, a deletion countdown driven by props rather than colour:
- ```tsx
- {
- state === "expired" && deleteInDays !== null && (
-
- );
- }
- ```
- Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised.
-- Add a Renew button for Free instances inside the window:
- ```tsx
- const qc = useQueryClient();
- const renew = useMutation({
- mutationFn: () => api.renewInstance(instance.instance_id),
- onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
- });
- const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
- ```
- and render it beside the existing link when `canRenew`:
- ```tsx
- {
- canRenew && (
-
- );
- }
- ```
-
-`daysRemaining` already exists in `adminsite/lib/format.ts`; check its exact signature before use.
-
-- [ ] **Step 6: Confirm it builds**
-
-Run:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
- sh -c "npm ci --silent && npm run build"
-```
-
-Expected: a successful Next.js build. Type errors here are real — fix them rather than loosening types.
-
-- [ ] **Step 7: Confirm no hex colours were introduced**
-
-Run:
-
-```sh
-grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/ adminsite/app/ --include=*.tsx
-```
-
-Expected: no matches. `CLAUDE.md` requires `adminsite` components to reference `var(--…)` tokens only.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add adminsite/
-git commit -m "feat(adminsite): create and renew a free instance
-
-Adds the create form with a live slug preview, a renew action inside the
-seven-day window, and a deletion countdown that renders only when the
-backend has actually promised a date.
-
-The progress bar denominator now follows the tier; a 30-day Free licence
-was rendering as an 8% sliver against the hardcoded 365.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 8: The marketing form creates an account
-
-**Files:**
-
-- Create: `site/components/AccountForm.tsx`
-- Delete: `site/components/InstanceForm.tsx`
-- Modify: `site/lib/submit.ts`, `site/app/start/page.tsx`
-
-**Interfaces:**
-
-- Consumes: `POST /auth/signup` on admin (already exists; unchanged by this phase).
-- Produces: `submitAccountSignup(fields)` in `site/lib/submit.ts`.
-
-The form now creates an HQ **account**, not an instance. The live slug preview must go: there is no instance at this point, and showing `your-instance.vantage.hostxtra.co.uk` would promise something the submission does not create.
-
-- [ ] **Step 1: Point signup at admin**
-
-In `site/lib/submit.ts`, add near `SITE_API`:
-
-```ts
-const ADMIN_API = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
-```
-
-Replace the whole `submitSignup` function with:
-
-```ts
-/*
- * Account signup posts to the admin service, not sitesvc. The two form targets
- * are deliberately separate variables rather than one base URL: contact and
- * signup are owned by different services, and an implied shared host is how they
- * silently end up pointing at the wrong one.
- */
-export async function submitAccountSignup(fields: { name: string; email: string; password: string; website: string }): Promise {
- if (!ADMIN_API) {
- return {
- state: "error",
- message: `Signup is not connected yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
- };
- }
- return post(`${ADMIN_API}/auth/signup`, fields);
-}
-```
-
-- [ ] **Step 2: Write the account form**
-
-Create `site/components/AccountForm.tsx` by adapting `site/components/InstanceForm.tsx`:
-
-- Read `site/components/InstanceForm.tsx` first and keep its markup conventions, class names, `Honeypot` usage, error rendering and `MIN_PASSWORD` check.
-- Rename the component to `AccountForm`.
-- Replace the `instance_name` field with a `name` field labelled **"Your organisation"**, placeholder `Northgate Systems`.
-- Delete the `slug` state, the `slugify` helper and the `` preview entirely.
-- Call `submitAccountSignup` with `{ name, email, password, website }`.
-- Change the success panel text to:
- ```
- Check your email.
- We sent a confirmation link. Open it and your Vantage account is ready — then you
- can create your first instance from the portal. The link works once and expires in
- 24 hours.
- ```
-
-Then delete `site/components/InstanceForm.tsx`.
-
-- [ ] **Step 3: Update the start page**
-
-In `site/app/start/page.tsx`:
-
-- Import `AccountForm` instead of `InstanceForm` and render it.
-- Change the `
` to `Create your account.`
-- Change the lede to:
- ```
- Your account is where instances, licences and billing live. Confirm your email and
- you can create a free instance straight away — three servers, hosted by us.
- ```
-- Change the "What happens next" specs to four steps, in this order:
- 1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before."
- 2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner."
- 3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once."
- 4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds."
-- Update `metadata.description` to describe an account rather than an instance.
-
-- [ ] **Step 4: Confirm it builds**
-
-Run:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/site":/app -w /app node:26-alpine \
- sh -c "npm ci --silent && npm run build"
-```
-
-Expected: a successful build.
-
-- [ ] **Step 5: Confirm nothing still imports the deleted component**
-
-Run:
-
-```sh
-grep -rn "InstanceForm\|submitSignup" site/
-```
-
-Expected: no matches.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add -A site/
-git commit -m "feat(site): /start creates an account, not an instance
-
-The form posts to admin's signup and the slug preview goes: there is no
-instance at this point, and previewing one promises something the
-submission does not create. Creating the instance is now a step in the
-portal, which the page's What happens next panel spells out.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 9: Retire sitesvc's signup
-
-**Files:**
-
-- Delete: `sitesvc/internal/api/signup.go`
-- Modify: `sitesvc/internal/api/api.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models/`, `sitesvc/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: nothing.
-- Produces: sitesvc serving `POST /api/contact` and nothing else.
-
-**Do this task last, and do not deploy it until Task 8 is live.** The cutover is staged for a reason: sitesvc's `/api/verify` must keep answering until every outstanding pending signup has expired, or someone's verification link breaks. The 24-hour wait belongs to the deployment, not to this commit — but the commit is what makes the wait necessary, so it goes last.
-
-- [ ] **Step 1: Remove the handlers**
-
-- Delete `sitesvc/internal/api/signup.go`.
-- In `sitesvc/internal/api/api.go`, remove the `/api/signup` and `/api/verify` route registrations, the `signups` rate limiter field and its initialisation, and the `publicURL` / `appLoginURL` fields if nothing else uses them. Keep `/api/contact` and the health endpoint exactly as they are.
-
-- [ ] **Step 2: Remove the store's signup half**
-
-In `sitesvc/internal/store/store.go`, delete `CreatePending`, `Verify`, `EmailTaken`, `randomToken`, `hashToken`, `PendingTTL`, the `ErrEmailTaken` / `ErrBadToken` / `ErrNameRejected` vars, and the `site_pending_signups` index block inside `EnsureIndexes`.
-
-Keep `Connect`, `DatabaseName`, `col` and `RequireMigratedDatabase`.
-
-`EnsureIndexes` should now only call `indexes.EnsureCoreIndexes`. Keep that call: it is cheap, it is idempotent, and it means sitesvc does not depend on another service having started first.
-
-Delete `sitesvc/internal/models/` if `PendingSignup` was its only type; otherwise delete just that type.
-
-- [ ] **Step 3: Drop the unused config**
-
-In `sitesvc/cmd/main.go`, remove `APP_LOGIN_URL` and any signup-only wiring. Leave `PUBLIC_URL` if the contact flow still uses it; remove it if not.
-
-- [ ] **Step 4: Confirm it compiles and the signup surface is gone**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh sitesvc go build ./...
-```
-
-Expected: no output.
-
-Run:
-
-```sh
-grep -rn "site_pending_signups\|CreatePending\|handleSignup\|handleVerify" sitesvc/
-```
-
-Expected: no matches.
-
-- [ ] **Step 5: Run `go mod tidy` with GOWORK off**
-
-Removing code may orphan a dependency:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
- -w /src/sitesvc -e GOWORK=off golang:1.26 go mod tidy
-sh /tmp/gorun.sh sitesvc go build ./...
-```
-
-Expected: build still clean. `GOWORK=off` is mandatory — in workspace mode `tidy` drops `require` lines and the Docker build then fails with "missing go.sum entry".
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add -A sitesvc/
-git commit -m "refactor(sitesvc): remove signup and verification
-
-Account creation moved to admin, which owns accounts, and the marketing
-form now posts there. sitesvc keeps the contact mailer only.
-
-DEPLOY LAST: sitesvc's verify endpoint must stay live until every
-outstanding pending signup has expired, or an in-flight verification link
-breaks. Do not roll this out until the site change has been live 24 hours
-and site_pending_signups is empty.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 10: Configuration, docs and end-to-end verification
-
-**Files:**
-
-- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`
-
-This task proves the phase. With no test suite, this transcript is the only evidence — run it in full.
-
-- [ ] **Step 1: Wire the compose file**
-
-In `deploy/docker-compose.site.yml`:
-
-- On `admin`, add `APP_LOGIN_URL: "https://{slug}.vantage.hostxtra.co.uk/login"` and `FREE_INSTANCE_REAP_AFTER: "336h"`.
-- On `server`, add `FREE_INSTANCE_REAP_AFTER: "336h"`.
-- On `sitesvc`, remove `APP_LOGIN_URL`.
-
-**Do not add `FREE_INSTANCE_REAP_AFTER` to `deploy/docker-compose.yml`.** A self-hosted deployment must never reap. Confirm after editing:
-
-```sh
-grep -n "FREE_INSTANCE_REAP_AFTER" deploy/docker-compose.yml
-```
-
-Expected: no matches.
-
-The two values must match: admin uses it to name the deletion date in emails, the server uses it to decide. `CLAUDE.md` documents them as a pair in Step 3.
-
-- [ ] **Step 2: Add the site build arg**
-
-In `.gitea/workflows/server-deploy.yml`, wherever the `site` image is built, pass `ADMIN_API_URL` through as a build arg alongside the existing `SITE_API_URL`, mapping to `NEXT_PUBLIC_ADMIN_API_URL`. Read how `SITE_API_URL` is wired and mirror it exactly — including any `site/Dockerfile` `ARG`/`ENV` lines it needs.
-
-- [ ] **Step 3: Update `CLAUDE.md`**
-
-- In **Subsystems → Marketing site and sitesvc**, change the table so only Contact remains, and state that account signup posts to admin.
-- In the **Signup and verification** section, replace the sitesvc-centred description with the account-first flow: signup creates an HQ account, the instance is created from the portal afterwards, and `site_pending_signups` is gone.
-- In **Admin REST API**, add `POST /api/instances` and `POST /api/instances/:id/renew` to the customer-session block.
-- In **Environment Variables (server)**, add:
- | `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
-- In **Design Decisions**, add:
- - **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
-- Update the sentence in **Admin REST API** or **Security** that describes admin's control-plane access as read-only apart from three licence fields, to name `cloudprov` as the second write path.
-
-- [ ] **Step 4: Build every image**
-
-```sh
-MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:p2 .
-MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:p2 .
-MSYS_NO_PATHCONV=1 docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:p2 .
-```
-
-Expected: three image IDs. A "missing go.sum entry" failure means Step 5 of Task 9 was run in workspace mode.
-
-- [ ] **Step 5: Start scratch infrastructure**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run -d --name p2-redis -p 6390:6379 redis:7
-MSYS_NO_PATHCONV=1 docker run -d --name p2-mongo -p 27024:27017 mongo:7
-sleep 6
-MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
- -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
- -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
- --add-host host.docker.internal:host-gateway vantage-server:p2
-sleep 6
-MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
-```
-
-Expected: `reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)`. **This is the safety default and must appear.**
-
-- [ ] **Step 6: Start admin with a real signing key**
-
-`licensing.Issue` actually signs here, so a placeholder will not do. Generate one and keep it for the rest of this task:
-
-```sh
-export LK=$(MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
- -w /src/shared golang:1.26 go run ./cmd/lkctl genkey 2>/dev/null | tail -1)
-echo "key length: ${#LK}"
-```
-
-Check `shared/cmd/lkctl`'s real subcommand names first (`go run ./cmd/lkctl --help`) and use whatever it provides to produce a signing key. If `lkctl` cannot generate one, say so and stop — issuing licences cannot be verified without it.
-
-```sh
-MSYS_NO_PATHCONV=1 docker run -d --name p2-admin -p 8094:8083 \
- -e ADMIN_MONGO_URI=mongodb://host.docker.internal:27024/p2_admin \
- -e CONTROL_MONGO_URI=mongodb://host.docker.internal:27024/p2 \
- -e REDIS_ADDR=host.docker.internal:6390 \
- -e LICENSE_SIGNING_KEY="$LK" \
- -e PUBLIC_URL=http://localhost:8094 -e ADMIN_ORIGIN=http://localhost:3004 \
- -e APP_LOGIN_URL='https://{slug}.vantage.test/login' \
- --add-host host.docker.internal:host-gateway vantage-admin:p2
-sleep 6
-curl -s http://localhost:8094/healthz
-```
-
-Expected: `{"ok":true}`.
-
-- [ ] **Step 7: Create an account and verify it**
-
-SMTP is not configured, so signup will refuse to send. Create the account and mark it verified directly:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval '
- const acct = { account_id: "acct-test", name: "Test Co",
- billing_email: "owner@example.com", status: "active", created_at: new Date() };
- db.accounts.insertOne(acct);
- // bcrypt cost-12 hash of "hunter2hunter2"
- db.customer_users.insertOne({ user_id: "cu-test", account_id: "acct-test",
- email: "owner@example.com",
- password_hash: "$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyDF2xrjRkPYVe",
- verified_at: new Date(), created_at: new Date() });
- print("seeded account + verified user");'
-```
-
-That hash is a well-known bcrypt test vector; if the sign-in below returns 401, generate a real cost-12 hash instead and say so in your report.
-
-```sh
-curl -s -X POST http://localhost:8094/auth/login -H 'Content-Type: application/json' \
- -c /tmp/p2.jar -d '{"email":"owner@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: `{"kind":"customer","email":"owner@example.com"}`.
-
-- [ ] **Step 8: Seed the Free plan and create an instance**
-
-Admin seeds `plans` at boot from `shared/license`; confirm Free is there:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval \
- 'printjson(db.plans.find({},{tier:1,deployment:1,_id:0}).toArray())'
-```
-
-Expected: includes `{tier:"free", deployment:"cloud"}`. If `plans` is empty, admin's seeding did not run — report it.
-
-```sh
-curl -s -X POST http://localhost:8094/api/instances -b /tmp/p2.jar \
- -H 'Content-Type: application/json' -d '{"name":"Northgate Systems"}'
-```
-
-Expected: `201` with an instance whose `slug` is `northgate-systems`, `deployment` `cloud`, `status` `active`, and a `tier` of `free`.
-
-- [ ] **Step 9: Confirm the whole chain landed**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024" --quiet --eval '
- const c = db.getSiblingDB("p2"), a = db.getSiblingDB("p2_admin");
- const inst = c.instances.findOne({slug:"northgate-systems"});
- print("control instance: " + (inst ? inst.instance_id : "MISSING"));
- const u = c.users.findOne({instance_id: inst.instance_id});
- print("owner: " + u.email + " role=" + u.role + " auth_source=" + u.auth_source + " hq_user_id=" + u.hq_user_id);
- print("license_tier: " + inst.license_tier + " expiry: " + inst.license_expiry);
- print("blob present: " + (inst.license_blob ? inst.license_blob.length > 0 : false));
- const ai = a.admin_instances.findOne({instance_id: inst.instance_id});
- print("admin row: status=" + ai.status + " tier=" + ai.tier + " account=" + ai.account_id);
- print("licences recorded: " + a.licenses.countDocuments({instance_id: inst.instance_id}));'
-```
-
-Expected, all of them:
-
-- `owner: owner@example.com role=owner auth_source=hq hq_user_id=cu-test`
-- `license_tier: free`, an expiry roughly one month and three days out
-- `blob present: true` — this proves injection ran
-- `admin row: status=active tier=free account=acct-test`
-- `licences recorded: 1`
-
-- [ ] **Step 10: Confirm the owner can sign in to the new instance**
-
-```sh
-curl -s -X POST http://localhost:8092/auth/login -H 'Host: northgate-systems.vantage.test' \
- -H 'Content-Type: application/json' -c /tmp/p2i.jar \
- -d '{"email":"owner@example.com","password":"hunter2hunter2"}'
-curl -s http://localhost:8092/auth/me -H 'Host: northgate-systems.vantage.test' -b /tmp/p2i.jar
-```
-
-Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the payoff of the whole phase — the HQ password works on the instance.
-
-- [ ] **Step 11: Confirm the Free cap**
-
-```sh
-curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8094/api/instances \
- -b /tmp/p2.jar -H 'Content-Type: application/json' -d '{"name":"Second One"}'
-```
-
-Expected: `409`.
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \
- 'print("instances: " + db.instances.countDocuments({}))'
-```
-
-Expected: `instances: 1`. The refusal must leave nothing behind.
-
-- [ ] **Step 12: Confirm renewal refuses outside the window**
-
-```sh
-INST=$(MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \
- 'print(db.instances.findOne({slug:"northgate-systems"}).instance_id)')
-curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
-```
-
-Expected: `400` with "not due yet".
-
-- [ ] **Step 13: Confirm renewal works inside the window**
-
-Move the licence's expiry to two days out, then renew:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval '
- const soon = new Date(Date.now() + 2*24*3600*1000);
- db.licenses.updateMany({}, {$set:{expires_at: soon}});
- print("expiry moved to " + soon.toISOString());'
-
-curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
-```
-
-Expected: `200` with a new licence whose `expires_at` is about a month out, and a `reason` of `renewal`.
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024" --quiet --eval '
- const a = db.getSiblingDB("p2_admin"), c = db.getSiblingDB("p2");
- print("licences: " + a.licenses.countDocuments({}));
- print("superseded: " + a.licenses.countDocuments({superseded_by:{$exists:true,$ne:""}}));
- const inst = c.instances.findOne({slug:"northgate-systems"});
- print("injected expiry: " + inst.license_expiry);'
-```
-
-Expected: 2 licences, 1 superseded, and the injected expiry matching the new one.
-
-- [ ] **Step 14: Confirm the reaper stays off, then deletes only what it should**
-
-Restart the server WITH the window set, and an instance whose licence expired long ago:
-
-```sh
-MSYS_NO_PATHCONV=1 docker rm -f p2-server
-MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
- -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
- -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
- --add-host host.docker.internal:host-gateway vantage-server:p2
-sleep 6
-MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
-```
-
-Expected: `reaper: ENABLED — Free instances are deleted 336h0m0s after their licence expires`.
-
-Now seed three instances the reaper must treat differently, plus rows scoped to the doomed one:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval '
- const old = new Date(Date.now() - 400*3600*1000); // past the 336h window
- const recent = new Date(Date.now() - 100*3600*1000); // expired, inside the window
- db.instances.insertOne({instance_id:"doomed", name:"Doomed", slug:"doomed",
- license_tier:"free", license_expiry: old, created_at:new Date()});
- db.instances.insertOne({instance_id:"safe-recent", name:"Recent", slug:"recent",
- license_tier:"free", license_expiry: recent, created_at:new Date()});
- db.instances.insertOne({instance_id:"safe-paid", name:"Paid", slug:"paid",
- license_tier:"professional", license_expiry: old, created_at:new Date()});
- db.instances.insertOne({instance_id:"safe-nolicence", name:"NoLic", slug:"nolic",
- created_at:new Date()});
- db.users.insertOne({user_id:"u-doomed", instance_id:"doomed", email:"d@example.com",
- role:"owner", auth_source:"local", created_at:new Date()});
- db.servers.insertOne({instance_id:"doomed", name:"srv"});
- db.secrets.insertOne({instance_id:"doomed", group:"g"});
- print("seeded 4 instances");'
-```
-
-Now force a sweep. `StartReaper` sweeps once at boot, so a restart runs it immediately:
-
-```sh
-MSYS_NO_PATHCONV=1 docker restart p2-server
-sleep 10
-MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i "REAPING\|reaped\|reaper:"
-```
-
-Expected: a `REAPING instance doomed (Doomed, slug=doomed)` line naming the expiry and window, then `reaped instance doomed: map[...]` listing the deleted counts, then `reaper: checked 1, purged 1`.
-
-**`checked` must be 1.** If it is higher, something ineligible was selected — stop and report it. This is the one bug in this phase that destroys customer data.
-
-- [ ] **Step 15: Confirm the purge deleted exactly the doomed instance**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval '
- print("doomed instance: " + db.instances.countDocuments({instance_id:"doomed"}));
- print("doomed users: " + db.users.countDocuments({instance_id:"doomed"}));
- print("doomed servers: " + db.servers.countDocuments({instance_id:"doomed"}));
- print("doomed secrets: " + db.secrets.countDocuments({instance_id:"doomed"}));
- print("--- survivors ---");
- print("safe-recent: " + db.instances.countDocuments({instance_id:"safe-recent"}));
- print("safe-paid: " + db.instances.countDocuments({instance_id:"safe-paid"}));
- print("safe-nolicence: " + db.instances.countDocuments({instance_id:"safe-nolicence"}));
- print("northgate: " + db.instances.countDocuments({slug:"northgate-systems"}));
- print("northgate users: " + db.users.countDocuments({email:"owner@example.com"}));'
-```
-
-Expected, every line:
-
-- all four `doomed` counts are `0`
-- all four survivor counts are `1`
-
-A surviving `doomed` row means `instanceScopedCollections` is missing a collection. A missing survivor means the eligibility rule is too broad — either is a stop-and-report.
-
-- [ ] **Step 15b: Confirm the purge is idempotent**
-
-Restart again. `doomed` is already gone, so the sweep must find nothing and must not error:
-
-```sh
-MSYS_NO_PATHCONV=1 docker restart p2-server
-sleep 10
-MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | tail -20 | grep -i "reaper\|panic\|error"
-```
-
-Expected: `reaper: ENABLED …` and nothing else — no `REAPING`, no panic, no error.
-
-- [ ] **Step 15c: Confirm the collection list is exhaustive**
-
-This is the check that catches a leak nothing else would:
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval '
- const names = db.getCollectionNames().sort();
- print("collections present: " + names.join(", "));
- let orphanable = [];
- names.forEach(n => {
- if (["instances","migrations","orgs"].includes(n)) return;
- const s = db.getCollection(n).findOne({instance_id:{$exists:true}});
- if (s) orphanable.push(n);
- });
- print("carry instance_id: " + orphanable.join(", "));'
-```
-
-Every name in `carry instance_id` must appear in `instanceScopedCollections` in `server/internal/services/reap.go`. Compare them by eye and state the result explicitly in your report. Any collection present there and absent from the list leaks rows that outlive their instance.
-
-- [ ] **Step 16: Tear down**
-
-```sh
-MSYS_NO_PATHCONV=1 docker rm -f p2-server p2-admin p2-mongo p2-redis
-```
-
-- [ ] **Step 17: Commit**
-
-```bash
-git add deploy/ .gitea/ CLAUDE.md
-git commit -m "docs: phase 2 configuration and the reaper's containment
-
-FREE_INSTANCE_REAP_AFTER is set only in docker-compose.site.yml, so a
-self-hosted deployment can never reap. Admin and server must carry the
-same value: one names the deletion date in warnings, the other acts on it.
-
-Records that admin now has a second control-plane write path, cloudprov,
-and that deletion lives in the control plane because that is where the
-knowledge of what an instance is made of belongs.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-## Done when
-
-- A verified HQ customer can create a Free cloud instance in one request, and the control plane, `admin_instances`, the licence and the injected blob all agree.
-- The instance owner signs in to their new instance with their HQ password, and their control-plane row carries `auth_source: hq` and `hq_user_id`.
-- A second Free instance is refused `409` and leaves nothing behind.
-- Renewal refuses outside the window, works inside it, supersedes the old licence and re-injects.
-- The reaper logs `DISABLED` with no configuration, and its eligibility query selects exactly the one instance that is Free, licensed, and past the window.
-- Every collection carrying `instance_id` appears in `instanceScopedCollections`.
-- `deploy/docker-compose.yml` does not mention `FREE_INSTANCE_REAP_AFTER`.
-- `grep -rn "InstanceForm\|submitSignup" site/` and `grep -rn "site_pending_signups" sitesvc/` both return nothing.
-
-**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice _selection_ run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat.
-
-## Deployment order
-
-This phase is not safe to roll out in an arbitrary order:
-
-1. `server` and `admin` first, with `FREE_INSTANCE_REAP_AFTER` **unset**, so creation and renewal work while nothing can be deleted.
-2. `site`, pointing at admin's signup.
-3. Wait 24 hours with sitesvc's verify still live, until `site_pending_signups` is empty.
-4. `sitesvc` without signup.
-5. Only then set `FREE_INSTANCE_REAP_AFTER=336h` on both `server` and `admin`, once you have watched a notice email actually send.
-
-## Not in this phase
-
-Account roles, invitations, instance membership, per-instance grants, password propagation, and the `web/` read-only treatment for `hq`-sourced users. Those are phase 3.
diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md b/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md
deleted file mode 100644
index acfdb86..0000000
--- a/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md
+++ /dev/null
@@ -1,989 +0,0 @@
-# Cloud Instance Creation — Phase 1: Identity
-
-> **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:** Replace the control plane's global unique index on `users.email` with a per-instance one, and scope every lookup that relied on the global index, so one address can belong to several instances.
-
-**Architecture:** The index change is safe only because the two unscoped `FindOne({email})` lookups are scoped in the same binary that performs the swap. The new compound index is created **before** the old one is dropped, so a failure at any point leaves a working constraint in place. The unscoped helper is deleted rather than left unused, and admin's one unscoped control-plane lookup — which has no instance to scope by — is removed entirely.
-
-**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, `shared/indexes`, `shared/models`, `shared/provision`.
-
-## Global Constraints
-
-- **No automated Go tests.** Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step below is a command with expected output. This matches plans 0a through 4.
-- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper from earlier plans:
- ```sh
- # /tmp/gorun.sh
- 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" \
- 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".
-- **`shared/` is consumed through `replace` directives** in `server`, `admin` and `sitesvc`. A change to `shared/` reaches all three on their next build; there is no version to bump.
-- **All three service images must ship together.** An older image booting after this change would recreate `email_1`. `.gitea/workflows/server-deploy.yml` rebuilds every image on every push to `main`, so this is automatic — the hazard is only a partial manual rollout on the host.
-- **This migration is one-way.** Once two users share an address across instances, `email_1` cannot be recreated. There is no rollback; fixes go forward.
-- Nothing in this phase projects users, creates instances, or adds UI. Those are phases 2 and 3.
-
-## Context this plan inherits
-
-`CLAUDE.md` currently states that the unique index on user email is "a security property, not an optimisation", because `GetUserByEmail` does an unscoped `FindOne`. That statement is true today and stops being true in Task 1. Task 7 updates it in the same series of commits, and the replacement property is stronger: a scoped query cannot be ambiguous, whereas an index merely prevents the ambiguity from arising.
-
-Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), phase 1.
-
----
-
-## File Structure
-
-**Modified:**
-
-| Path | Change |
-| ----------------------------------- | -------------------------------------------------------------------------- |
-| `shared/indexes/indexes.go` | compound `(instance_id, email)` unique index; idempotent drop of `email_1` |
-| `shared/models/user.go` | `HQUserID` field, `AuthLocal`/`AuthOIDC`/`AuthHQ` constants |
-| `server/internal/services/users.go` | `GetUserByEmail` deleted, `GetUserInInstanceByEmail` added |
-| `server/internal/auth/local.go` | `resolveLoginInstance`, scoped sign-in |
-| `server/internal/auth/oidc.go` | scoped lookup, cross-instance guard deleted |
-| `admin/internal/auth/cloud.go` | **deleted** |
-| `admin/internal/api/routes.go` | `/auth/login` points at `HandleCustomerLogin`; new staff route |
-| `admin/internal/api/staff.go` | `staffCreateAccountUser` |
-| `CLAUDE.md` | the index security-property paragraph, and the auth section |
-
-**Created:** none.
-
----
-
-### Task 1: Compound index and the drop
-
-**Files:**
-
-- Modify: `shared/indexes/indexes.go`
-
-**Interfaces:**
-
-- Consumes: nothing new.
-- Produces: `indexes.EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error` — unchanged signature, new behaviour. Called at boot by `server`, `sitesvc` and `admin`.
-
-- [ ] **Step 1: Replace the body of `EnsureCoreIndexes` and add the drop helper**
-
-Replace the whole file with:
-
-```go
-// Package indexes declares the MongoDB indexes more than one Vantage service
-// depends on.
-package indexes
-
-import (
- "context"
- "errors"
- "fmt"
-
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
-)
-
-// legacyUserEmailIndex is the global unique index on users.email that this
-// package used to declare. It is dropped on sight.
-const legacyUserEmailIndex = "email_1"
-
-// indexNotFound is MongoDB's IndexNotFound error code. Two services booting at
-// once can both decide to drop the legacy index; the loser must not treat that
-// as a failure.
-const indexNotFound = 27
-
-// EnsureCoreIndexes declares the unique indexes on users and instances.
-//
-// users is unique on (instance_id, email), NOT on email alone. One address is
-// one user WITHIN an instance; the same address may hold a user in several
-// instances, because an account's people are projected into each instance they
-// are granted access to.
-//
-// This is a security property, not an optimisation, and it is only sufficient
-// because every lookup by email is scoped by instance. There is deliberately no
-// unscoped lookup by email anywhere in the codebase: an unscoped FindOne would
-// return an arbitrary one of several matching users, which on the login path
-// means signing someone into a tenant that is not theirs. If you are about to
-// add one, you are about to reintroduce that bug.
-//
-// Creating an index that already exists with the same specification is a no-op,
-// so this is safe to call at every boot from every service.
-func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
- // Create the replacement BEFORE dropping the legacy index. A failure here
- // leaves the old constraint in place, which is safe; a failure after the
- // drop would leave the collection unconstrained, which is not.
- if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "email", Value: 1}},
- Options: options.Index().SetUnique(true).SetName("instance_email_unique"),
- }); err != nil {
- return fmt.Errorf("users.instance_id+email index: %w", err)
- }
-
- if err := dropIndexIfExists(ctx, db.Collection("users"), legacyUserEmailIndex); err != nil {
- return fmt.Errorf("drop users.%s: %w", legacyUserEmailIndex, err)
- }
-
- if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "slug", Value: 1}},
- Options: options.Index().SetUnique(true),
- }); err != nil {
- return fmt.Errorf("instances.slug index: %w", err)
- }
-
- return nil
-}
-
-// dropIndexIfExists drops name, treating "it was not there" as success whether
-// that is discovered by listing or by racing another service to the drop.
-func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string) error {
- cur, err := col.Indexes().List(ctx)
- if err != nil {
- return err
- }
- var existing []struct {
- Name string `bson:"name"`
- }
- if err := cur.All(ctx, &existing); err != nil {
- return err
- }
-
- found := false
- for _, i := range existing {
- if i.Name == name {
- found = true
- break
- }
- }
- if !found {
- return nil
- }
-
- err = col.Indexes().DropOne(ctx, name)
- if err == nil {
- return nil
- }
- var srvErr mongo.ServerError
- if errors.As(err, &srvErr) && srvErr.HasErrorCode(indexNotFound) {
- return nil
- }
- return err
-}
-```
-
-- [ ] **Step 2: Confirm it compiles**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh shared go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 3: Confirm the legacy index is not declared anywhere else**
-
-Run:
-
-```sh
-grep -rn '"email"' --include=*.go shared/ server/ sitesvc/ admin/ | grep -i index
-```
-
-Expected: no matches. If sitesvc or the server declares its own `users.email` index, it would recreate what Task 1 drops.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add shared/indexes/indexes.go
-git commit -m "feat(shared): unique users index is (instance_id, email)
-
-One address is one user within an instance, not globally, so an account's
-people can be projected into every instance they are granted.
-
-The replacement index is created before email_1 is dropped, so a failure
-at any point leaves a working constraint. The drop is idempotent and
-tolerates two services racing it.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 2: `hq` fields on the user document
-
-**Files:**
-
-- Modify: `shared/models/user.go`
-- Modify: `server/internal/models/user.go`
-
-**Interfaces:**
-
-- Consumes: nothing.
-- Produces:
- - `shared/models.AuthLocal = "local"`, `AuthOIDC = "oidc"`, `AuthHQ = "hq"`
- - `shared/models.User.HQUserID string` — bson `hq_user_id,omitempty`
- - the same three constants re-exported from `server/internal/models`, which is a thin alias file over `shared/models` and is what server code imports
-
-Nothing writes `AuthHQ` or `HQUserID` in this phase. They land now so phases 2 and 3 do not have to change the shared module and rebuild every service again.
-
-- [ ] **Step 1: Add the constants and the field**
-
-In `shared/models/user.go`, after the `ValidRole` function, add:
-
-```go
-// Auth sources. A user's auth_source says who owns the row.
-const (
- AuthLocal = "local"
- AuthOIDC = "oidc"
- // AuthHQ marks a user projected from a Vantage HQ account. Its role,
- // password and existence are owned by HQ, and the instance API refuses to
- // change any of them locally — a role editable in two places is a role with
- // two answers.
- AuthHQ = "hq"
-)
-```
-
-And in the `User` struct, add `HQUserID` immediately after `AuthSource`:
-
-```go
-type User struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
- UserID string `bson:"user_id" json:"user_id"`
- InstanceID string `bson:"instance_id" json:"instance_id"`
- Email string `bson:"email" json:"email"`
- PasswordHash string `bson:"password_hash,omitempty" json:"-"`
- Role string `bson:"role" json:"role"`
- AuthSource string `bson:"auth_source" json:"auth_source"`
- // HQUserID is the customer_users.user_id this row was projected from,
- // absent on locally-created users.
- HQUserID string `bson:"hq_user_id,omitempty" json:"hq_user_id,omitempty"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
- LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
-}
-```
-
-- [ ] **Step 2: Re-export the constants from the server's alias file**
-
-`server/internal/models/user.go` is a thin alias over `shared/models`, and server code imports that rather than the shared package directly. Add the auth sources alongside the roles it already re-exports:
-
-```go
-package models
-
-import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
-
-type User = shared.User
-
-const (
- RoleOwner = shared.RoleOwner
- RoleAdmin = shared.RoleAdmin
- RoleMember = shared.RoleMember
-)
-
-const (
- AuthLocal = shared.AuthLocal
- AuthOIDC = shared.AuthOIDC
- AuthHQ = shared.AuthHQ
-)
-
-func ValidRole(role string) bool { return shared.ValidRole(role) }
-```
-
-- [ ] **Step 3: Confirm both compile**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh shared go build ./...
-sh /tmp/gorun.sh server go build ./...
-```
-
-Expected: no output from either.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add shared/models/user.go server/internal/models/user.go
-git commit -m "feat(shared): auth_source constants and hq_user_id on User
-
-Nothing writes them yet. They land now so phases 2 and 3 do not require a
-second rebuild of every service that consumes the shared module.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 3: Scoped lookup in the user service
-
-**Files:**
-
-- Modify: `server/internal/services/users.go:65-75`
-
-**Interfaces:**
-
-- Consumes: `shared/indexes` from Task 1.
-- Produces: `services.GetUserInInstanceByEmail(instanceID, email string) (*models.User, error)`.
-- Removes: `services.GetUserByEmail`. Tasks 4 and 5 fix its two callers; the build will be red between this task and Task 5, which is expected and is why they are adjacent.
-
-- [ ] **Step 1: Replace `GetUserByEmail`**
-
-In `server/internal/services/users.go`, delete the whole `GetUserByEmail` function and put this in its place:
-
-```go
-// GetUserInInstanceByEmail finds a user by address WITHIN one instance.
-//
-// There is deliberately no unscoped lookup by email. users is unique on
-// (instance_id, email), not on email alone, so an unscoped FindOne would return
-// an arbitrary one of several matching users — which on the login path means
-// signing someone into a tenant that is not theirs.
-func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
- email = strings.ToLower(strings.TrimSpace(email))
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- var u models.User
- err := db.Col("users").FindOne(ctx, bson.M{
- "instance_id": instanceID,
- "email": email,
- }).Decode(&u)
- if err != nil {
- return nil, err
- }
- return &u, nil
-}
-```
-
-- [ ] **Step 2: Confirm the unscoped helper is gone and the build is red for the expected reason**
-
-Run:
-
-```sh
-grep -rn "GetUserByEmail" --include=*.go .
-```
-
-Expected: exactly two matches, both call sites — `server/internal/auth/local.go` and `server/internal/auth/oidc.go`. No definition.
-
-Run:
-
-```sh
-sh /tmp/gorun.sh server go build ./...
-```
-
-Expected: FAIL with `undefined: services.GetUserByEmail` at those two call sites. Any other error means something else was broken.
-
-- [ ] **Step 3: Do not commit yet**
-
-The build is red. Commit at the end of Task 5, when both callers are fixed. A commit that does not build is a commit nobody can bisect through.
-
----
-
-### Task 4: Scoped local login
-
-**Files:**
-
-- Modify: `server/internal/auth/local.go:25-49`
-
-**Interfaces:**
-
-- Consumes: `services.GetUserInInstanceByEmail` from Task 3, `services.CountInstances` and `services.FirstInstance` from `server/internal/services/instances.go:57` and `:63`, `auth.InstanceFromHost` from `server/internal/auth/instancehost.go:53`.
-- Produces: `resolveLoginInstance(c *gin.Context) (string, error)`, unexported, used only by this file.
-
-**Behaviour change worth knowing:** signing in at the bare apex host stops working when more than one instance exists. Cloud sign-in is always on `.vantage.` — `APP_LOGIN_URL` fills `{slug}` in, so every link already points there — and self-hosted has exactly one instance, so both supported paths keep working. A bookmark to the apex login page on a multi-instance deployment will now get a 400 that names the cause.
-
-- [ ] **Step 1: Add `resolveLoginInstance` and rewrite `HandleLocalLogin`**
-
-In `server/internal/auth/local.go`, add `"fmt"` to the imports if it is not already there, then add above `HandleLocalLogin`:
-
-```go
-// resolveLoginInstance decides which instance a sign-in attempt belongs to.
-//
-// Cloud always answers from the host: every instance has its own subdomain, and
-// APP_LOGIN_URL fills the slug in, so every sign-in link already points at one.
-// Self-hosted has no subdomain and exactly one instance, because a licence
-// binds one instance UUID.
-//
-// Anything else is refused rather than guessed. Picking an instance on someone's
-// behalf is how you sign them into the wrong tenant.
-func resolveLoginInstance(c *gin.Context) (string, error) {
- if inst, ok := InstanceFromHost(c); ok {
- return inst.InstanceID, nil
- }
- n, err := services.CountInstances()
- if err != nil {
- return "", err
- }
- if n != 1 {
- return "", fmt.Errorf(
- "cannot tell which instance this sign-in is for: %d instances exist and the host %q names none of them; sign in at your instance's own address",
- n, c.Request.Host)
- }
- inst, err := services.FirstInstance()
- if err != nil {
- return "", err
- }
- return inst.InstanceID, nil
-}
-```
-
-Then replace the body of `HandleLocalLogin` between the JSON bind and `SaveSession` with:
-
-```go
- instanceID, err := resolveLoginInstance(c)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- u, err := services.GetUserInInstanceByEmail(instanceID, body.Email)
- if err != nil || !services.VerifyPassword(u, body.Password) {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
- return
- }
-```
-
-The `SaveSession` call below it is unchanged: it already reads `u.InstanceID`.
-
-- [ ] **Step 2: Confirm only the OIDC caller is left broken**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh server go build ./...
-```
-
-Expected: FAIL with `undefined: services.GetUserByEmail` at `internal/auth/oidc.go:130` only.
-
----
-
-### Task 5: Scoped OIDC callback
-
-**Files:**
-
-- Modify: `server/internal/auth/oidc.go:129-141`
-
-**Interfaces:**
-
-- Consumes: `services.GetUserInInstanceByEmail` from Task 3.
-- Produces: nothing new.
-
-The cross-instance guard is deleted because it becomes unreachable: the lookup is now scoped to `instanceID`, so a user belonging to another instance is simply not found, and the OIDC callback provisions a new member — which is correct. OIDC is configured per instance, so only that instance's identity provider can reach this code with that instance's state.
-
-- [ ] **Step 1: Replace the lookup and delete the guard**
-
-In `server/internal/auth/oidc.go`, replace:
-
-```go
- email := strings.ToLower(claims.Email)
- u, err := services.GetUserByEmail(email)
- if err != nil {
-
- u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
- return
- }
- } else if u.InstanceID != instanceID {
- c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
- return
- }
-```
-
-with:
-
-```go
- email := strings.ToLower(claims.Email)
-
- // Scoped to the instance the callback state names, so an address that also
- // exists in another instance is invisible here. That scoping replaces the
- // cross-instance guard this code used to need: there is no longer a way for
- // the lookup to return a user belonging to somebody else.
- u, err := services.GetUserInInstanceByEmail(instanceID, email)
- if err != nil {
- u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
- return
- }
- }
-```
-
-`services.CreateUser`'s signature is `CreateUser(instanceID, email, password, role, authSource string)` — the argument order above matches it, with the two string literals the old code passed replaced by the constants Task 2 added.
-
-`oidc.go` already imports `gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models`; confirm it before relying on the constants:
-
-```sh
-grep -n "server/internal/models" server/internal/auth/oidc.go
-```
-
-If that returns nothing, add the import rather than reverting to string literals — Task 2 exists so these two values have one spelling.
-
-- [ ] **Step 2: Confirm the build is green**
-
-Run:
-
-```sh
-sh /tmp/gorun.sh server go build ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 3: Confirm no unscoped email lookup survives anywhere in the server**
-
-Run:
-
-```sh
-grep -rn "GetUserByEmail" --include=*.go .
-```
-
-Expected: no matches at all.
-
-Run:
-
-```sh
-grep -rn 'FindOne(ctx, bson.M{"email"' --include=*.go server/
-```
-
-Expected: no matches.
-
-**Coverage note.** The spec's phase-1 test 6 exercises this path end to end, which needs a working identity provider and is not reproducible in the container harness Task 7 uses. It is verified here by inspection and by the greps in Step 3 instead: the lookup is scoped by `instanceID`, which comes from `ConsumeStateInstance` and not from user input, and the deleted guard was the only other consumer of the unscoped helper. The first real OIDC sign-in after deployment is the confirming evidence — check that an existing SSO user still lands in their own instance before considering this closed.
-
-- [ ] **Step 4: Commit Tasks 3, 4 and 5 together**
-
-```bash
-git add server/internal/services/users.go server/internal/auth/local.go server/internal/auth/oidc.go
-git commit -m "feat(server): scope every user lookup by instance
-
-users is unique on (instance_id, email) now, so an unscoped FindOne could
-return an arbitrary one of several matching users. On the login path that
-means signing someone into a tenant that is not theirs.
-
-GetUserByEmail is deleted rather than left unused. Local sign-in resolves
-its instance from the host, falling back to the single instance a
-self-hosted deployment has, and refuses to guess otherwise. The OIDC
-cross-instance guard goes: a scoped lookup cannot return another
-instance's user, which is a stronger guarantee than the check it replaces.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 6: Remove admin's unscoped control-plane login
-
-**Files:**
-
-- Delete: `admin/internal/auth/cloud.go`
-- Modify: `admin/internal/api/routes.go:30`, `admin/internal/api/routes.go:50-52`
-- Modify: `admin/internal/api/staff.go`
-
-**Interfaces:**
-
-- Consumes: `auth.CreateCustomerUser(ctx, accountID, email, password string) error` from `admin/internal/auth/customer.go:32`.
-- Produces: `POST /api/staff/accounts/:id/users`.
-
-`HandleCloudLogin` authenticates against control-plane `users` with an unscoped `FindOne({email})`, and unlike the server's two lookups there is no instance in context to scope it by — HQ sign-in is not per-instance. It already falls through to `HandleCustomerLogin` whenever a `customer_users` row exists, which after phase 2 is every customer. Legacy cloud customers get an HQ login from staff, which is what the new endpoint is for; staff already attach those instances by hand per the spec README.
-
-- [ ] **Step 1: Delete the file**
-
-```sh
-git rm admin/internal/auth/cloud.go
-```
-
-- [ ] **Step 2: Point `/auth/login` at the customer handler**
-
-In `admin/internal/api/routes.go`, replace:
-
-```go
- r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
-```
-
-with:
-
-```go
- // Every customer authenticates against admin's own customer_users. There is
- // deliberately no path that looks a customer up in the control plane by
- // email alone: HQ sign-in names no instance, so such a lookup could not be
- // scoped, and users.email is no longer globally unique.
- r.POST("/auth/login", auth.HandleCustomerLogin)
-```
-
-- [ ] **Step 3: Add the staff route**
-
-In `admin/internal/api/routes.go`, inside the `staff` group, immediately after the `staff.GET("/accounts/:id", staffGetAccount)` line, add:
-
-```go
- staff.POST("/accounts/:id/users", staffCreateAccountUser)
-```
-
-- [ ] **Step 4: Add the handler**
-
-At the end of `admin/internal/api/staff.go`, add:
-
-```go
-// staffCreateAccountUser gives an account an HQ login.
-//
-// This is how a legacy cloud customer — one whose instance predates HQ accounts
-// — gets into the portal, alongside the manual instance attach the spec README
-// describes. It reuses CreateCustomerUser, so the row is unverified until the
-// emailed link is opened and is rolled back if that email cannot be sent.
-func staffCreateAccountUser(c *gin.Context) {
- var body struct {
- Email string `json:"email"`
- Password string `json:"password"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "email and a password of at least 12 characters are required"})
- return
- }
- ctx := c.Request.Context()
- accountID := c.Param("id")
-
- if n, err := db.Admin("accounts").CountDocuments(ctx,
- bson.M{"account_id": accountID}); err != nil || n == 0 {
- c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
- return
- }
-
- email := strings.ToLower(strings.TrimSpace(body.Email))
- if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- s := auth.Current(c)
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
- c.JSON(http.StatusCreated, gin.H{"pending": true})
-}
-```
-
-Confirm `strings` is imported in `staff.go`; add it if not:
-
-```sh
-grep -n '"strings"' admin/internal/api/staff.go
-```
-
-- [ ] **Step 5: Confirm the build is green and nothing still references the deleted handler**
-
-Run:
-
-```sh
-grep -rn "HandleCloudLogin" --include=*.go .
-```
-
-Expected: no matches.
-
-Run:
-
-```sh
-sh /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output. If `sharedmodels` is now an unused import in some file, remove that import line.
-
-- [ ] **Step 6: Confirm admin has no unscoped control-plane user lookup left**
-
-Run:
-
-```sh
-grep -rn 'db.Control("users")' --include=*.go admin/
-```
-
-Expected: no matches.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add -A admin/
-git commit -m "feat(admin): drop the unscoped control-plane login branch
-
-HQ sign-in names no instance, so a lookup of control-plane users by email
-alone cannot be scoped — and users.email is no longer globally unique, so
-it would return an arbitrary match. Every customer authenticates against
-customer_users instead.
-
-Legacy cloud customers get an HQ login from staff via the new
-POST /api/staff/accounts/:id/users, alongside the manual instance attach
-the spec README already describes.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 7: Documentation and end-to-end verification
-
-**Files:**
-
-- Modify: `CLAUDE.md`
-
-**Interfaces:**
-
-- Consumes: everything above.
-- Produces: nothing.
-
-This is the task that proves the change. With no test suite, this transcript is the only evidence, so run it in full rather than skimming it.
-
-- [ ] **Step 1: Update `CLAUDE.md`**
-
-In the **Auth and Orgs** section, replace the paragraph beginning "Unique indexes on user email and org slug are a **security property**" with:
-
-```markdown
-Unique indexes are a **security property**, not an optimisation. `users` is
-unique on `(instance_id, email)` — one address is one user _within_ an instance,
-and the same address may hold a user in several instances, because an account's
-people are projected into each instance they are granted. This is sufficient only
-because **every lookup by email is scoped by instance**; there is deliberately no
-unscoped lookup anywhere, and adding one would let the login path return an
-arbitrary one of several matching users. Instance slug, settings instance and ESO
-token hash remain globally unique.
-```
-
-In the **Security** section, replace the "Unique indexes on user email, org slug…" bullet with:
-
-```markdown
-- Unique indexes on `(instance_id, email)`, instance slug, settings instance and the ESO token hash are load-bearing for tenant isolation. So is the absence of any unscoped lookup by email.
-```
-
-In the **MongoDB Collections** notes, add:
-
-```markdown
-- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
-```
-
-- [ ] **Step 2: Build both images**
-
-```sh
-MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:test .
-MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:test .
-```
-
-Expected: two image IDs. A "missing go.sum entry" failure here means `go mod tidy` was run in workspace mode.
-
-- [ ] **Step 3: Start a scratch Mongo and Redis, and seed the OLD index**
-
-Redis is not optional here: the server stores sessions in it, so every sign-in below fails without it.
-
-```sh
-MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-redis -p 6389:6379 redis:7
-MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-mongo -p 27023:27017 mongo:7
-
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
- 'db.users.createIndex({email:1},{unique:true}); db.getCollection("users").getIndexes().map(i=>i.name)'
-```
-
-Expected: output includes `email_1`. This reproduces a database that predates the change.
-
-- [ ] **Step 4: Boot the server and confirm the swap**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-server -p 8091:8080 \
- -e MONGO_URI=mongodb://host.docker.internal:27023 -e MONGO_DB=vantage_idx \
- -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6389 \
- --add-host host.docker.internal:host-gateway vantage-server:test
-
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
- 'db.getCollection("users").getIndexes().map(i=>({name:i.name,key:i.key,unique:i.unique}))'
-```
-
-Expected: `instance_email_unique` present with key `{instance_id:1, email:1}` and `unique:true`; **no `email_1`**.
-
-- [ ] **Step 5: Confirm a second boot is a no-op**
-
-```sh
-MSYS_NO_PATHCONV=1 docker restart vantage-idx-server
-sleep 5
-MSYS_NO_PATHCONV=1 docker logs vantage-idx-server 2>&1 | grep -i "index\|fatal" | tail -5
-```
-
-Expected: no index error and no fatal. The drop must tolerate the index already being gone.
-
-- [ ] **Step 6: Bootstrap instance A and capture its user's password hash**
-
-```sh
-curl -s -X POST http://localhost:8091/auth/bootstrap \
- -H 'Content-Type: application/json' \
- -d '{"instance_name":"Alpha","email":"shared@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: JSON with `instance_id` and `"slug":"alpha"`.
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
- 'const u=db.users.findOne({email:"shared@example.com"}); print(u.user_id); print(u.password_hash)'
-```
-
-Expected: a UUID and a bcrypt hash. Keep both.
-
-- [ ] **Step 7: Create instance B with the SAME address — the case that was impossible before**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval '
- const a = db.users.findOne({email:"shared@example.com"});
- const bId = UUID().toString().replace(/[{}]/g,"");
- db.instances.insertOne({instance_id:bId, name:"Beta", slug:"beta", created_at:new Date()});
- db.users.insertOne({
- user_id: UUID().toString().replace(/[{}]/g,""),
- instance_id: bId,
- email: "shared@example.com",
- password_hash: a.password_hash,
- role: "owner",
- auth_source: "local",
- created_at: new Date()
- });
- print("beta instance " + bId);
- print("users with that address: " + db.users.countDocuments({email:"shared@example.com"}));
- '
-```
-
-Expected: `users with that address: 2`. Under the old global index this insert would have failed with E11000 — that failure is exactly what this phase removes.
-
-- [ ] **Step 8: Confirm the compound index still refuses a duplicate WITHIN one instance**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval '
- const a = db.users.findOne({email:"shared@example.com"});
- try {
- db.users.insertOne({user_id:"dup", instance_id:a.instance_id,
- email:"shared@example.com", role:"member", auth_source:"local", created_at:new Date()});
- print("FAIL: duplicate accepted");
- } catch (e) { print("refused as expected: " + (e.code === 11000)); }
- '
-```
-
-Expected: `refused as expected: true`. A `FAIL` line means the compound index is missing or not unique.
-
-- [ ] **Step 9: Confirm each host signs in to its own instance — the whole point of the phase**
-
-```sh
-curl -s -X POST http://localhost:8091/auth/login -H 'Host: alpha.vantage.test' \
- -H 'Content-Type: application/json' -c /tmp/alpha.jar \
- -d '{"email":"shared@example.com","password":"hunter2hunter2"}'
-curl -s http://localhost:8091/auth/me -H 'Host: alpha.vantage.test' -b /tmp/alpha.jar
-```
-
-Expected: `{"ok":true}`, then a body whose `instance` is **Alpha**.
-
-```sh
-curl -s -X POST http://localhost:8091/auth/login -H 'Host: beta.vantage.test' \
- -H 'Content-Type: application/json' -c /tmp/beta.jar \
- -d '{"email":"shared@example.com","password":"hunter2hunter2"}'
-curl -s http://localhost:8091/auth/me -H 'Host: beta.vantage.test' -b /tmp/beta.jar
-```
-
-Expected: `{"ok":true}`, then a body whose `instance` is **Beta**, with a different `instance_id` from the Alpha response.
-
-Two sign-ins, one address, one password, two different tenants. If both responses name the same instance, the lookup is not scoped.
-
-- [ ] **Step 10: Confirm the apex host refuses rather than guesses**
-
-```sh
-curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/login \
- -H 'Host: vantage.test' -H 'Content-Type: application/json' \
- -d '{"email":"shared@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: `400`. Then read the message:
-
-```sh
-curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
- -H 'Content-Type: application/json' \
- -d '{"email":"shared@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: an error naming both the instance count and the host. A `200` here would mean an arbitrary tenant was chosen.
-
-- [ ] **Step 11: Confirm a wrong password still fails, on the right host**
-
-```sh
-curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/login \
- -H 'Host: alpha.vantage.test' -H 'Content-Type: application/json' \
- -d '{"email":"shared@example.com","password":"wrongwrongwrong"}'
-```
-
-Expected: `401`.
-
-- [ ] **Step 12: Confirm a single-instance deployment still signs in on a bare host**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
- mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
- 'const b=db.instances.findOne({slug:"beta"}); db.users.deleteMany({instance_id:b.instance_id}); db.instances.deleteOne({slug:"beta"}); print(db.instances.countDocuments({}))'
-```
-
-Expected: `1`.
-
-```sh
-curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
- -H 'Content-Type: application/json' \
- -d '{"email":"shared@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: `{"ok":true}`. This is the self-hosted path, and it must keep working.
-
-- [ ] **Step 13: Confirm admin boots and its login route still works**
-
-```sh
-MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-admin -p 8093:8083 \
- -e ADMIN_MONGO_URI=mongodb://host.docker.internal:27023/vantage_idx_admin \
- -e CONTROL_MONGO_URI=mongodb://host.docker.internal:27023/vantage_idx \
- -e REDIS_ADDR=host.docker.internal:6389 \
- -e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" \
- -e PUBLIC_URL=http://localhost:8093 -e ADMIN_ORIGIN=http://localhost:3004 \
- --add-host host.docker.internal:host-gateway vantage-admin:test
-
-sleep 5
-curl -s http://localhost:8093/healthz
-```
-
-Expected: `{"ok":true}`. A boot failure here most likely means an unused-import error that `go build` caught but the image build did not, or a missing env var.
-
-```sh
-curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8093/auth/login \
- -H 'Content-Type: application/json' \
- -d '{"email":"nobody@example.com","password":"hunter2hunter2"}'
-```
-
-Expected: `401`, not `500`. This proves `/auth/login` is wired to a live handler after `HandleCloudLogin` was deleted.
-
-- [ ] **Step 14: Tear the scratch environment down**
-
-```sh
-MSYS_NO_PATHCONV=1 docker rm -f vantage-idx-server vantage-idx-admin vantage-idx-mongo vantage-idx-redis
-```
-
-- [ ] **Step 15: Commit**
-
-```bash
-git add CLAUDE.md
-git commit -m "docs: users is unique per instance, not globally
-
-The old index was load-bearing because two lookups were unscoped. Both
-are scoped now and the unscoped helper is gone, so the property that
-matters is the absence of any unscoped lookup by email. Says so, and
-documents auth_source hq.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-## Done when
-
-- `instance_email_unique` exists on `users`, `email_1` does not, and a second boot is a no-op.
-- Two users share one address across two instances, and each signs in to their own.
-- A duplicate address within one instance is still refused.
-- The apex host refuses to guess when several instances exist, and still works when only one does.
-- `grep -rn "GetUserByEmail"` and `grep -rn "HandleCloudLogin"` both return nothing.
-- `admin` boots and `/auth/login` answers `401` rather than `500`.
-- `CLAUDE.md` no longer claims `users.email` is globally unique.
-
-**Not proven by this plan:** the OIDC sign-in path, which needs a real identity provider. Verify it manually on the first SSO sign-in after deployment — an existing SSO user must still land in their own instance.
-
-## Not in this phase
-
-`POST /api/instances`, the Free lifecycle, renewal, the notices, the reaper, the sitesvc cutover, account roles, invitations, instance membership, password propagation, and every UI change. Phases 2 and 3 get their own plans once this one lands.
diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md b/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md
deleted file mode 100644
index 955a2fb..0000000
--- a/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md
+++ /dev/null
@@ -1,2854 +0,0 @@
-# Cloud Instance Creation — Phase 3: Accounts, People and Membership
-
-> **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:** An HQ account becomes a team: it can invite people, give each of them access to individual cloud instances at a chosen role, revoke that access, and change one password that reaches every instance they belong to.
-
-**Architecture:** Grants **project, they do not federate** — granting a person access to a cloud instance writes a real control-plane `users` row with `auth_source: "hq"` and `hq_user_id`, and the instance authenticates it exactly as it authenticates anyone else, with no runtime dependency on admin. Because two writers of one row is two answers, the control plane refuses to change an `hq`-sourced row's role or existence locally, and HQ's password is the single source of truth, propagated best-effort and repaired by a 15-minute reconciler pass.
-
-**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, `shared/provision`, `shared/models`.
-
-## Global Constraints
-
-- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
-- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
- ```sh
- # /tmp/gorun.sh
- 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" \
- 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".
-- **Admin's control-plane writes stay confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry`. **Do not widen `inject`** — the password reconciliation pass added by this phase lives in its own package, `hqsync`, precisely so `inject` stays licence-only.
-- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`.
-- **Self-hosted instances are never projected into.** Every membership endpoint refuses when `deployment != cloud`. The customer's own deployment is theirs; we cannot see it and have no business writing to it.
-- **Only cloud roles are `owner`/`admin`/`member`, and account roles use the same three words on purpose.** Do not invent a second vocabulary.
-- Paddle is **out of scope**. Billing stays owner-only by role check, but no billing behaviour changes here.
-
-## Context this plan inherits
-
-Phase 1 and phase 2 shipped (`da3afca`..`aef5811`, on `main`, **not yet pushed**):
-
-- `users` is unique on `(instance_id, email)`. The same address may hold a user in several instances — that is what makes a grant possible at all.
-- `shared/models.User` already carries `AuthSource` and `HQUserID`, and the constants `AuthLocal`, `AuthOIDC`, `AuthHQ`.
-- `admin/internal/cloudprov` exists and writes the control plane's `instances` and `users`. `CreateInstance` already sets `auth_source: "hq"` and `hq_user_id` on the owner it creates.
-- `admin/internal/auth.CreateCustomerUser` already does the unverified-row-plus-verification-email dance and rolls the row back when the email fails.
-- `POST /api/instances` creates an instance plus an `admin_instances` row. **It does not create an `instance_members` row — that collection does not exist yet.** Task 1 creates it and backfills the owners phase 2 left implicit.
-- `customer_users` has **no** `account_role` field. Every existing row is an account creator, so they all backfill to `owner`.
-- The control plane has **no local password-change endpoint at all** (`grep -n "password" server/internal/api/*.go` finds only the console's RDP password and the create-user body). So "the instance refuses to change an `hq`-sourced user's password locally" needs no code: there is no such path to refuse. What does need refusing is role change and deletion — Task 7.
-
-Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), "Phase 3 — accounts, people and membership".
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| ------------------------------------------------ | ------------------------------------------------------------------------ |
-| `admin/internal/models/members.go` | `InstanceMember`, account-role constants |
-| `admin/internal/db/backfill.go` | one-shot boot backfill: account roles, and members for phase-2 instances |
-| `admin/internal/api/people.go` | account people: list, invite, role, delete, password |
-| `admin/internal/api/members.go` | instance members: list, grant, role, revoke |
-| `admin/internal/hqsync/hqsync.go` | the 15-minute password repair pass |
-| `adminsite/app/(customer)/users/page.tsx` | the account's people |
-| `adminsite/app/(customer)/users/InvitePanel.tsx` | invite form + people table |
-| `adminsite/app/(customer)/settings/page.tsx` | change password |
-| `adminsite/components/MembersPanel.tsx` | who is on one instance |
-| `adminsite/app/accept-invite/page.tsx` | an invitee sets their own password |
-
-**Modified:**
-
-| Path | Change |
-| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
-| `admin/internal/models/models.go` | `CustomerUser.AccountRole`, `HQSyncFailedAt` |
-| `admin/internal/db/db.go` | `instance_members` indexes |
-| `admin/internal/auth/customer.go` | `CreateInvitedUser`, `HandleAcceptInvite`, verify peek, role on signup |
-| `admin/internal/auth/middleware.go` | `RequireAccountRole`, `CurrentUser` |
-| `admin/internal/cloudprov/cloudprov.go` | `GrantUser`, `RevokeUser`, `SetMemberRole`, `CountOtherOwners`, `SetPasswordHash`, `ProjectedUsers` |
-| `admin/internal/api/customer.go` | `createInstance` writes the owner's `instance_members` row |
-| `admin/internal/api/routes.go` | the ten new customer routes |
-| `admin/internal/mail/mail.go` | `SendInvite` |
-| `admin/cmd/main.go` | run the backfill, start `hqsync` |
-| `server/internal/services/users.go` | `ErrHQManaged` on role change and delete |
-| `server/internal/api/instance.go` | map `ErrHQManaged` to 409 |
-| `web/lib/api.ts` | `auth_source: "hq"`, `hq_user_id` on `InstanceUser` |
-| `web/app/(app)/settings/instance/page.tsx` | read-only treatment for `hq` rows |
-| `web/Dockerfile`, `.gitea/workflows/server-deploy.yml` | `NEXT_PUBLIC_HQ_URL` |
-| `adminsite/lib/api.ts` | member/people/password calls, `AccountRole` |
-| `adminsite/app/(customer)/layout.tsx` | People and Settings nav |
-| `adminsite/app/(customer)/instances/[id]/page.tsx` | mount `MembersPanel` |
-| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the password copy is now a lie; fix it |
-| `adminsite/app/verify/page.tsx` | route an invite token to `/accept-invite` |
-| `CLAUDE.md` | membership model, the new routes, `NEXT_PUBLIC_HQ_URL` |
-
----
-
-### Task 1: The membership model, its indexes, and the phase-2 backfill
-
-**Files:**
-
-- Create: `admin/internal/models/members.go`, `admin/internal/db/backfill.go`
-- Modify: `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: `db.Admin`, `db.Control`, `shared/models.RoleOwner`, `shared/license.DeploymentCloud`.
-- Produces:
- - `models.AccountRoleOwner|AccountRoleAdmin|AccountRoleMember string`, `models.ValidAccountRole(string) bool`, `models.AccountRoleAtLeastAdmin(string) bool`
- - `models.InstanceMember` struct
- - `models.CustomerUser.AccountRole string`, `models.CustomerUser.HQSyncFailedAt *time.Time`
- - `db.Backfill(ctx context.Context) error`
-
-- [ ] **Step 1: Add the account-role constants and the member document**
-
-Create `admin/internal/models/members.go`:
-
-```go
-package models
-
-import (
- "time"
-
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// Account roles.
-//
-// Deliberately the same three words as the control plane's own roles rather
-// than a second vocabulary: a customer who reads "admin" in the portal and
-// "admin" in their instance should not have to learn that they mean different
-// things. They govern different scopes — this one governs the HQ account —
-// but they mean the same thing about power.
-//
-// Billing stays owner-only. Owners and admins may invite people, create
-// instances and grant instance access.
-const (
- AccountRoleOwner = "owner"
- AccountRoleAdmin = "admin"
- AccountRoleMember = "member"
-)
-
-func ValidAccountRole(r string) bool {
- switch r {
- case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember:
- return true
- }
- return false
-}
-
-// AccountRoleAtLeastAdmin is the single definition of "may manage people and
-// instances". Every guard calls this rather than comparing strings, so widening
-// the rule is one edit.
-func AccountRoleAtLeastAdmin(r string) bool {
- return r == AccountRoleOwner || r == AccountRoleAdmin
-}
-
-// InstanceMember records that one HQ person holds a projected user inside one
-// cloud instance.
-//
-// It is admin's index of the projection, not the authority: the control-plane
-// `users` row IS the access. This row exists so the portal can list who is on
-// an instance without reading the control plane, and so a password change can
-// find every row to update without scanning every instance.
-//
-// ControlUserID is the projected users.user_id. Role is the role that user
-// holds INSIDE the instance, which is not the person's account role.
-type InstanceMember struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
- MemberID string `bson:"member_id" json:"member_id"`
- AccountID string `bson:"account_id" json:"account_id"`
- InstanceID string `bson:"instance_id" json:"instance_id"`
- CustomerUserID string `bson:"customer_user_id" json:"customer_user_id"`
- ControlUserID string `bson:"control_user_id" json:"control_user_id"`
- Role string `bson:"role" json:"role"`
- Email string `bson:"email" json:"email"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
-}
-```
-
-- [ ] **Step 2: Add the two new `CustomerUser` fields**
-
-In `admin/internal/models/models.go`, replace the `CustomerUser` struct and its doc comment with:
-
-```go
-// CustomerUser is one person on an HQ account.
-//
-// AccountRole governs what they may do to the ACCOUNT — invite people, create
-// instances, grant access. It says nothing about what they may do inside any
-// instance; that is the role on their InstanceMember row.
-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:"-"`
- AccountRole string `bson:"account_role" json:"account_role"`
- 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:"-"`
- // HQSyncFailedAt is set when a password change could not be written to
- // every projected control-plane row. It is visibility only — hqsync repairs
- // by comparing hashes, not by reading this field.
- HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
-}
-```
-
-- [ ] **Step 3: Index `instance_members`**
-
-In `admin/internal/db/db.go`, inside `EnsureIndexes`, immediately before the closing `return nil`, add:
-
-```go
- // One person holds at most one user in one instance. This is the property
- // that makes a grant idempotent-by-refusal rather than silently doubling a
- // projection, and it mirrors users' own (instance_id, email) uniqueness.
- if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}},
- Options: options.Index().SetUnique(true).
- SetName("instance_customer_user_unique"),
- }); err != nil {
- return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err)
- }
- for _, keys := range []bson.D{
- {{Key: "account_id", Value: 1}},
- {{Key: "customer_user_id", Value: 1}},
- } {
- if _, err := Admin("instance_members").Indexes().CreateOne(ctx,
- mongo.IndexModel{Keys: keys}); err != nil {
- return fmt.Errorf("index instance_members: %w", err)
- }
- }
-```
-
-- [ ] **Step 4: Write the backfill**
-
-Create `admin/internal/db/backfill.go`:
-
-```go
-package db
-
-import (
- "context"
- "log"
- "time"
-
- "github.com/google/uuid"
- "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"
-)
-
-// Backfill brings pre-phase-3 data up to the membership model.
-//
-// It runs on every boot and is idempotent by construction: both passes filter
-// on the absence of what they write. There is no migrations collection in
-// admin, and adding one for two `$exists: false` queries would be more
-// machinery than the job deserves.
-func Backfill(ctx context.Context) error {
- // Pass 1: every existing customer_user created their own account, so they
- // are all owners. A row with no account_role would otherwise be able to do
- // nothing at all once the guards land — including managing the account it
- // created.
- res, err := Admin("customer_users").UpdateMany(ctx,
- bson.M{"account_role": bson.M{"$exists": false}},
- bson.M{"$set": bson.M{"account_role": models.AccountRoleOwner}})
- if err != nil {
- return err
- }
- if res.ModifiedCount > 0 {
- log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
- }
-
- // Pass 2: phase 2 created cloud instances and their owners without an
- // instance_members row, because the collection did not exist. Reconstruct
- // one per instance from the control-plane owner it actually created.
- cur, err := Admin("admin_instances").Find(ctx, bson.M{
- "deployment": license.DeploymentCloud,
- "status": bson.M{"$ne": models.StatusDeleted},
- })
- if err != nil {
- return err
- }
- var instances []models.Instance
- if err := cur.All(ctx, &instances); err != nil {
- return err
- }
-
- created := 0
- for _, inst := range instances {
- n, err := Admin("instance_members").CountDocuments(ctx,
- bson.M{"instance_id": inst.InstanceID})
- if err != nil {
- return err
- }
- if n > 0 {
- continue
- }
-
- // Only an hq-sourced owner can be reconstructed: a control-plane owner
- // with no hq_user_id was created inside the instance and belongs to
- // nobody on this side. Leaving it unrecorded is correct.
- var owner sharedmodels.User
- err = Control("users").FindOne(ctx, bson.M{
- "instance_id": inst.InstanceID,
- "role": sharedmodels.RoleOwner,
- "hq_user_id": bson.M{"$nin": bson.A{nil, ""}},
- }).Decode(&owner)
- if err != nil {
- if err != mongo.ErrNoDocuments {
- return err
- }
- log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
- continue
- }
-
- if _, err := Admin("instance_members").InsertOne(ctx, models.InstanceMember{
- MemberID: uuid.NewString(),
- AccountID: inst.AccountID,
- InstanceID: inst.InstanceID,
- CustomerUserID: owner.HQUserID,
- ControlUserID: owner.UserID,
- Role: sharedmodels.RoleOwner,
- Email: owner.Email,
- CreatedAt: time.Now().UTC(),
- }); err != nil {
- return err
- }
- created++
- }
- if created > 0 {
- log.Printf("backfill: recorded %d pre-existing instance owners", created)
- }
- return nil
-}
-```
-
-- [ ] **Step 5: Run the backfill at boot**
-
-In `admin/cmd/main.go`, inside the `idxCtx` block, after the `models.SeedPlans` check and before `idxCancel()`, add:
-
-```go
- if err := db.Backfill(idxCtx); err != nil {
- idxCancel()
- log.Fatalf("backfill: %v", err)
- }
-```
-
-Fatal on purpose: a boot that half-applies the membership model gives some people a role and not others, and the guards would then lock the wrong people out silently.
-
-- [ ] **Step 6: Compile**
-
-Run: `sh /tmp/gorun.sh admin go build ./...`
-Expected: no output.
-
-- [ ] **Step 7: Confirm the shape is what the rest of the phase expects**
-
-Run: `grep -n "AccountRoleAtLeastAdmin\|customer_user_id\|instance_customer_user_unique" admin/internal/models/members.go admin/internal/db/db.go`
-Expected: `AccountRoleAtLeastAdmin` defined once, `customer_user_id` in both the struct tag and the index, `instance_customer_user_unique` once.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add admin/internal/models/members.go admin/internal/models/models.go \
- admin/internal/db/db.go admin/internal/db/backfill.go admin/cmd/main.go
-git commit -m "feat(admin): account roles and the instance_members index
-
-Phase 2 created cloud instances without recording who owns them on this
-side, because the collection did not exist. The boot backfill reconstructs
-one member row per instance from the hq-sourced control-plane owner, and
-marks every existing customer_user an owner — they all created their own
-account.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 2: `cloudprov` learns to project, revoke and repair
-
-**Files:**
-
-- Modify: `admin/internal/cloudprov/cloudprov.go`
-
-**Interfaces:**
-
-- Consumes: `db.Control`, `db.ControlDB`, `shared/provision.CreateUserWithHash`, `shared/models`.
-- Produces:
- - `cloudprov.GrantUser(ctx, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error)`
- - `cloudprov.RevokeUser(ctx, instanceID, hqUserID string) error`
- - `cloudprov.SetMemberRole(ctx, instanceID, hqUserID, role string) error`
- - `cloudprov.CountOtherOwners(ctx, instanceID, exceptHQUserID string) (int64, error)`
- - `cloudprov.SetPasswordHash(ctx, hqUserID, hash string) (int64, error)`
- - `cloudprov.ProjectedUsers(ctx, hqUserID string) ([]sharedmodels.User, error)`
-
-- [ ] **Step 1: Append the projection functions**
-
-In `admin/internal/cloudprov/cloudprov.go`, append at the end of the file:
-
-```go
-// GrantUser projects an HQ person into a control-plane instance.
-//
-// The password hash is copied from customer_users rather than re-derived: HQ
-// owns the password, and a grant that asked for a password again would create
-// a second credential for one person.
-//
-// The row is written with auth_source "hq" and hq_user_id set, which is what
-// makes the control plane refuse to edit it locally and what lets a password
-// change find it later.
-func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
- u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
- email, passwordHash, role, sharedmodels.AuthHQ)
- if err != nil {
- return nil, err
- }
- if _, err := db.Control("users").UpdateOne(ctx,
- bson.M{"user_id": u.UserID},
- bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
- // Unwind: a projected row with no hq_user_id is invisible to revoke and
- // to password propagation, which is worse than no row at all.
- _, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
- return nil, fmt.Errorf("set hq_user_id: %w", err)
- }
- u.HQUserID = hqUserID
- return u, nil
-}
-
-// RevokeUser deletes the projected row for one person in one instance.
-//
-// Deleting rather than disabling is deliberate: the control plane has no
-// concept of a disabled user, and a row that still exists is a row that can
-// still sign in.
-func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
- _, err := db.Control("users").DeleteOne(ctx, bson.M{
- "instance_id": instanceID,
- "hq_user_id": hqUserID,
- })
- return err
-}
-
-// SetMemberRole changes a projected user's role inside one instance.
-func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
- if !sharedmodels.ValidRole(role) {
- return fmt.Errorf("invalid role %q", role)
- }
- res, err := db.Control("users").UpdateOne(ctx,
- bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
- bson.M{"$set": bson.M{"role": role}})
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return fmt.Errorf("no projected user in instance %s", instanceID)
- }
- return nil
-}
-
-// CountOtherOwners counts owners of an instance other than one HQ person.
-//
-// It counts CONTROL-PLANE owners, so an owner created locally inside the
-// instance counts too. That matters: refusing to revoke the last HQ owner of
-// an instance that has three local owners would be a refusal with no cause.
-//
-// $ne matches documents where the field is absent, which is exactly how a
-// locally-created owner is stored.
-func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
- return db.Control("users").CountDocuments(ctx, bson.M{
- "instance_id": instanceID,
- "role": sharedmodels.RoleOwner,
- "hq_user_id": bson.M{"$ne": exceptHQUserID},
- })
-}
-
-// SetPasswordHash writes one hash to every row projected from one HQ person,
-// across every instance, and reports how many it changed.
-func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
- res, err := db.Control("users").UpdateMany(ctx,
- bson.M{"hq_user_id": hqUserID},
- bson.M{"$set": bson.M{"password_hash": hash}})
- if err != nil {
- return 0, err
- }
- return res.ModifiedCount, nil
-}
-
-// ProjectedUsers returns every control-plane row projected from one HQ person.
-// hqsync uses it to compare hashes.
-func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
- cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
- if err != nil {
- return nil, err
- }
- var users []sharedmodels.User
- if err := cur.All(ctx, &users); err != nil {
- return nil, err
- }
- return users, nil
-}
-```
-
-- [ ] **Step 2: Compile**
-
-Run: `sh /tmp/gorun.sh admin go build ./...`
-Expected: no output.
-
-- [ ] **Step 3: Confirm the write boundary did not move**
-
-Run: `grep -n 'db.Control("' admin/internal/cloudprov/cloudprov.go admin/internal/inject/inject.go | grep -o 'db.Control("[a-z_]*")' | sort -u`
-Expected exactly two lines:
-
-```
-db.Control("instances")
-db.Control("users")
-```
-
-If a third collection appears, stop — that is the design change the package comment forbids.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add admin/internal/cloudprov/cloudprov.go
-git commit -m "feat(admin): cloudprov projects, revokes and repairs users
-
-A grant is a real control-plane users row with auth_source hq, not a
-federation shim: the instance authenticates it with no runtime dependency
-on admin. CountOtherOwners counts control-plane owners so a locally-created
-owner satisfies the last-owner rule too.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 3: The account's people — invite, accept, role, remove
-
-**Files:**
-
-- Create: `admin/internal/api/people.go`
-- Modify: `admin/internal/auth/customer.go`, `admin/internal/auth/middleware.go`, `admin/internal/mail/mail.go`, `admin/internal/api/routes.go`
-
-**Interfaces:**
-
-- Consumes: `models.AccountRole*`, `models.InstanceMember`, `cloudprov.RevokeUser`, `cloudprov.CountOtherOwners`, `auth.CreateCustomerUser`.
-- Produces:
- - `auth.CurrentUser(c *gin.Context) *models.CustomerUser`
- - `auth.RequireAccountRole(roles ...string) gin.HandlerFunc`
- - `auth.CreateInvitedUser(ctx context.Context, accountID, email, accountRole string) error`
- - `auth.HandleAcceptInvite(c *gin.Context)`
- - `mail.SendInvite(to, accountName string) error`
- - routes: `GET,POST /api/account/users`, `PUT /api/account/users/:id/role`, `DELETE /api/account/users/:id`, `POST /auth/accept-invite`
-
-- [ ] **Step 1: Add `CurrentUser` and the role guard**
-
-In `admin/internal/auth/middleware.go`, add the import block entries `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` and `"go.mongodb.org/mongo-driver/v2/bson"`, then append:
-
-```go
-const ctxCustomerUser = "admin_customer_user"
-
-// CurrentUser returns the calling customer's own row, loaded once per request
-// by RequireAccountRole.
-//
-// It is nil behind RequireCustomer alone. A handler that needs the role must
-// sit behind RequireAccountRole, which is the only thing that loads it.
-func CurrentUser(c *gin.Context) *models.CustomerUser {
- if v, ok := c.Get(ctxCustomerUser); ok {
- if u, ok := v.(*models.CustomerUser); ok {
- return u
- }
- }
- return nil
-}
-
-// RequireAccountRole admits a customer holding one of the given account roles.
-//
-// The role is read from the database on every request rather than carried in
-// the session. A session lives 24 hours; a demotion that only takes effect
-// when someone signs out again is not a demotion.
-func RequireAccountRole(roles ...string) gin.HandlerFunc {
- return func(c *gin.Context) {
- s := Current(c)
- if s == nil {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
- return
- }
- var u models.CustomerUser
- if err := db.Admin("customer_users").FindOne(c.Request.Context(),
- bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
- return
- }
- if !slices.Contains(roles, u.AccountRole) {
- // 403 rather than 404 here: this is the caller's OWN account, so
- // there is no existence to disclose — the 404 rule protects other
- // accounts' resources, not the caller's view of their own.
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
- "error": "your account role does not allow this"})
- return
- }
- c.Set(ctxCustomerUser, &u)
- c.Next()
- }
-}
-```
-
-Add `"slices"` to the imports.
-
-- [ ] **Step 2: Give signup an explicit owner role**
-
-In `admin/internal/auth/customer.go`, change `CreateCustomerUser`'s signature and the struct literal so the role is passed in rather than assumed. Replace the function header and the `u := models.CustomerUser{...}` literal:
-
-```go
-// CreateCustomerUser creates an unverified HQ login with a chosen password and
-// emails the verification link. Used by signup and by staff.
-func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
-```
-
-and
-
-```go
- u := models.CustomerUser{
- UserID: uuid.NewString(),
- AccountID: accountID,
- Email: strings.ToLower(strings.TrimSpace(email)),
- PasswordHash: string(hash),
- AccountRole: accountRole,
- VerifyTokenHash: hex.EncodeToString(sum[:]),
- VerifyTokenExpiry: &expiry,
- CreatedAt: time.Now().UTC(),
- }
-```
-
-Update the two existing callers:
-
-- in `HandleSignup`: `CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner)`
-- in `admin/internal/api/staff.go`, `staffCreateAccountUser`: `auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner)` — staff attaching a legacy customer are attaching the person who runs that account.
-
-- [ ] **Step 3: Add the invite path**
-
-An invitation cannot carry a password chosen by the inviter. The HQ password is what signs the invitee into every instance they are later granted, so a password the inviter knows is a shared credential to every one of those instances. The invited row is therefore created with an **empty hash**, which cannot authenticate, and the invitee sets their own when they open the link.
-
-Append to `admin/internal/auth/customer.go`:
-
-```go
-// CreateInvitedUser creates a passwordless, unverified member of an existing
-// account and emails them a link to set a password.
-//
-// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
-// never succeed, so the row cannot sign in and cannot usefully be projected
-// into an instance until the invitee has been through /accept-invite. That is
-// also why a grant refuses an unverified user.
-func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
- 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)),
- AccountRole: accountRole,
- VerifyTokenHash: hex.EncodeToString(sum[:]),
- VerifyTokenExpiry: &expiry,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
- return err
- }
-
- if err := mail.SendInvite(u.Email, accountName, token); err != nil {
- // Same rollback rule, and the same detached context, as signup: a row
- // whose link was never delivered can never be signed in to and holds
- // the unique index on email against the person it was meant for.
- rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
- defer cancel()
- if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
- log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
- u.UserID, u.Email, dErr)
- }
- return err
- }
- return nil
-}
-
-// HandleAcceptInvite consumes an invitation token and sets the password.
-//
-// Verification and password-setting are one step for an invitee, because the
-// link IS the proof of address and there is nothing to verify separately.
-func HandleAcceptInvite(c *gin.Context) {
- var body struct {
- Token string `json:"token"`
- Password string `json:"password"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
- return
- }
- if len(body.Password) < 12 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
- return
- }
- hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
- return
- }
-
- sum := sha256.Sum256([]byte(body.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, "password_hash": string(hash)},
- "$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{"accepted": true})
-}
-```
-
-- [ ] **Step 4: Teach `HandleVerify` to recognise an invitation**
-
-An invitation link and a verification link are the same shape. `HandleVerify` must not consume an invitation, because doing so would verify a row that still has no password and leave the invitee unable to do anything. Replace the body of `HandleVerify` after the `sum := sha256.Sum256(...)` line with:
-
-```go
- now := time.Now().UTC()
- ctx := c.Request.Context()
- hashed := hex.EncodeToString(sum[:])
-
- // Peek first. An invited row has no password yet, so consuming its token
- // here would verify an account nobody can sign in to and burn the only
- // link that could fix it.
- var u models.CustomerUser
- if err := db.Admin("customer_users").FindOne(ctx, bson.M{
- "verify_token_hash": hashed,
- "verify_token_expiry": bson.M{"$gt": now},
- }).Decode(&u); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
- return
- }
- if u.PasswordHash == "" {
- c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
- return
- }
-
- res, err := db.Admin("customer_users").UpdateOne(ctx,
- bson.M{
- "verify_token_hash": hashed,
- "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})
-```
-
-- [ ] **Step 5: Add the invite email**
-
-In `admin/internal/mail/mail.go`, after `SendVerification`, add:
-
-```go
-// SendInvite asks someone to join an existing account and set their own
-// password. It names the account, because an unexpected invitation from a
-// service you have never used is otherwise indistinguishable from spam.
-func SendInvite(to, accountName, token string) error {
- link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
- return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
- fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
- "Set your password and finish joining:\n\n%s\n\n"+
- "This link expires in 24 hours. If you were not expecting this, ignore it — "+
- "nothing happens until you open the link.\n", accountName, link))
-}
-```
-
-- [ ] **Step 6: Write the people handlers**
-
-Create `admin/internal/api/people.go`:
-
-```go
-package api
-
-import (
- "log"
- "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/auth"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
- "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"
-)
-
-// listAccountUsers returns the account's people, newest last.
-//
-// Any signed-in member may read this. Knowing who your colleagues are is not
-// privileged, and hiding it would make the members panel unusable for the
-// people it is meant to inform.
-func listAccountUsers(c *gin.Context) {
- s := auth.Current(c)
- ctx := c.Request.Context()
- cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- users := []models.CustomerUser{}
- if err := cur.All(ctx, &users); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, users)
-}
-
-// inviteAccountUser adds a person to the account.
-//
-// It never sets a password: see CreateInvitedUser. Only an owner may invite
-// another owner, mirroring the control plane's own rule that an admin cannot
-// mint someone with more power than themselves.
-func inviteAccountUser(c *gin.Context) {
- var body struct {
- Email string `json:"email"`
- Role string `json:"role"`
- }
- if err := c.ShouldBindJSON(&body); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
- return
- }
- email := strings.ToLower(strings.TrimSpace(body.Email))
- if email == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
- return
- }
- if body.Role == "" {
- body.Role = models.AccountRoleMember
- }
- if !models.ValidAccountRole(body.Role) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
- return
- }
- me := auth.CurrentUser(c)
- if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
- c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
- return
- }
-
- ctx := c.Request.Context()
- s := auth.Current(c)
-
- // customer_users.email is globally unique, so an address already in use
- // anywhere cannot be invited here. Say so plainly: unlike signup there is
- // nothing to conceal, because the inviter already knows this address.
- if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "that address already has a Vantage HQ account"})
- return
- }
-
- var acct models.Account
- if err := db.Admin("accounts").FindOne(ctx,
- bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
- return
- }
-
- if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
- log.Printf("invite %s to %s: %v", email, s.AccountID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
- return
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
- Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
- c.JSON(http.StatusCreated, gin.H{"invited": true})
-}
-
-// accountUser loads one person and confirms they are on the caller's account.
-func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
- s := auth.Current(c)
- var u models.CustomerUser
- if err := db.Admin("customer_users").FindOne(c.Request.Context(),
- bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
- return nil, false
- }
- return &u, true
-}
-
-// countOtherAccountOwners counts owners of an account other than one person.
-func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
- s := auth.Current(c)
- return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
- "account_id": s.AccountID,
- "account_role": models.AccountRoleOwner,
- "user_id": bson.M{"$ne": exceptUserID},
- })
-}
-
-func updateAccountUserRole(c *gin.Context) {
- var body struct {
- Role string `json:"role"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
- return
- }
- target, ok := accountUser(c, c.Param("id"))
- if !ok {
- return
- }
- me := auth.CurrentUser(c)
- if target.UserID == me.UserID {
- c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
- return
- }
- if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
- me.AccountRole != models.AccountRoleOwner {
- c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
- return
- }
- if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
- others, err := countOtherAccountOwners(c, target.UserID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if others == 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "this is the account's last owner; promote someone else first"})
- return
- }
- }
-
- ctx := c.Request.Context()
- if _, err := db.Admin("customer_users").UpdateOne(ctx,
- bson.M{"user_id": target.UserID},
- bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- s := auth.Current(c)
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
- Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
- c.JSON(http.StatusOK, gin.H{"ok": true})
-}
-
-// deleteAccountUser removes a person and every instance they hold.
-//
-// Grants go first, and the whole request is refused if any of them would strand
-// an instance with no owner. Removing the person but leaving their projected
-// rows behind would leave working logins for someone the account has removed —
-// the exact failure this endpoint exists to prevent.
-func deleteAccountUser(c *gin.Context) {
- target, ok := accountUser(c, c.Param("id"))
- if !ok {
- return
- }
- me := auth.CurrentUser(c)
- if target.UserID == me.UserID {
- c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
- return
- }
- if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
- c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
- return
- }
- if target.AccountRole == models.AccountRoleOwner {
- others, err := countOtherAccountOwners(c, target.UserID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if others == 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "this is the account's last owner; promote someone else first"})
- return
- }
- }
-
- ctx := c.Request.Context()
- s := auth.Current(c)
-
- cur, err := db.Admin("instance_members").Find(ctx,
- bson.M{"customer_user_id": target.UserID})
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- members := []models.InstanceMember{}
- if err := cur.All(ctx, &members); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // Check every instance BEFORE deleting anything, so a refusal leaves the
- // person exactly as they were rather than half-revoked.
- for _, m := range members {
- if m.Role != sharedmodels.RoleOwner {
- continue
- }
- others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if others == 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "they are the last owner of an instance; give someone else that instance's owner role first"})
- return
- }
- }
-
- for _, m := range members {
- if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
- log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": "could not remove their instance access; nothing was deleted"})
- return
- }
- if _, err := db.Admin("instance_members").DeleteOne(ctx,
- bson.M{"member_id": m.MemberID}); err != nil {
- log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
- }
- }
-
- if _, err := db.Admin("customer_users").DeleteOne(ctx,
- bson.M{"user_id": target.UserID}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
- Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
- IP: c.ClientIP()})
- c.JSON(http.StatusOK, gin.H{"deleted": true})
-}
-```
-
-Add `"fmt"` to the imports.
-
-- [ ] **Step 7: Mount the routes**
-
-In `admin/internal/api/routes.go`, add `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` to the imports, add the unauthenticated invite route after `r.POST("/auth/signup", auth.HandleSignup)`:
-
-```go
- r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
-```
-
-and inside the `cust` group, after `cust.GET("/account", getAccount)`:
-
-```go
- // People. Reading is open to any member; changing anything is
- // owner-or-admin, enforced per route rather than by splitting the group,
- // so the guard is visible next to the route it guards.
- cust.GET("/account/users", listAccountUsers)
- cust.POST("/account/users",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- inviteAccountUser)
- cust.PUT("/account/users/:id/role",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- updateAccountUserRole)
- cust.DELETE("/account/users/:id",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- deleteAccountUser)
-```
-
-Also guard instance creation, which is now a privileged act: change
-
-```go
- cust.POST("/instances", createInstance)
-```
-
-to
-
-```go
- cust.POST("/instances",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- createInstance)
-```
-
-- [ ] **Step 8: Compile**
-
-Run: `sh /tmp/gorun.sh admin go build ./...`
-Expected: no output.
-
-- [ ] **Step 9: Confirm no route escaped a guard**
-
-Run: `grep -n "cust\." admin/internal/api/routes.go`
-Expected: every mutating route either names `RequireAccountRole` or is a per-instance action guarded by `ownedInstance` (`relink`, `renew`). `GET` routes are unguarded beyond `RequireCustomer`.
-
-- [ ] **Step 10: Commit**
-
-```bash
-git add admin/internal/api/people.go admin/internal/api/routes.go \
- admin/internal/api/staff.go admin/internal/auth/customer.go \
- admin/internal/auth/middleware.go admin/internal/mail/mail.go
-git commit -m "feat(admin): invite people to an account and give them roles
-
-An invitation carries no password. The HQ password is what signs someone
-into every instance they are granted, so a password the inviter chose would
-be a shared credential to all of them — the invited row has an empty hash,
-which cannot authenticate, until /accept-invite sets one.
-
-Removing a person revokes every projected instance user first, and refuses
-outright if any of those is an instance's last owner.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 4: Instance members — grant, role, revoke
-
-**Files:**
-
-- Create: `admin/internal/api/members.go`
-- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`
-
-**Interfaces:**
-
-- Consumes: `ownedInstance`, `cloudprov.GrantUser/RevokeUser/SetMemberRole/CountOtherOwners`, `models.InstanceMember`.
-- Produces: routes `GET,POST /api/instances/:id/members`, `PUT /api/instances/:id/members/:uid/role`, `DELETE /api/instances/:id/members/:uid`. `:uid` is the **customer_users.user_id**, not the control-plane user_id — the portal never has to know the projected ID.
-
-- [ ] **Step 1: Record the owner when an instance is created**
-
-`createInstance` currently leaves the owner's membership implicit. In `admin/internal/api/customer.go`, immediately after the successful `db.Admin("admin_instances").InsertOne(ctx, rec)` block and before the `audit.Write` call, add:
-
-```go
- // Record the owner's membership. Best-effort: the projected user already
- // exists and is what actually grants access, so a missing row here costs a
- // line in the members panel, not access — and the boot backfill rebuilds it.
- ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
- if err != nil {
- log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
- } else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
- MemberID: uuid.NewString(),
- AccountID: s.AccountID,
- InstanceID: inst.InstanceID,
- CustomerUserID: cu.UserID,
- ControlUserID: ownerID,
- Role: sharedmodels.RoleOwner,
- Email: cu.Email,
- CreatedAt: time.Now().UTC(),
- }); err != nil {
- log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
- }
-```
-
-Add `"github.com/google/uuid"` and `sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"` to that file's imports.
-
-- [ ] **Step 2: Write the member handlers**
-
-Create `admin/internal/api/members.go`:
-
-```go
-package api
-
-import (
- "log"
- "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/cloudprov"
- "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"
- "gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
- sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
- "go.mongodb.org/mongo-driver/v2/bson"
- "errors"
-)
-
-// selfHostedRefusal is the one message every membership endpoint gives for a
-// self-hosted instance. Their users live in their own deployment, which we
-// cannot see and must not write to.
-const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"
-
-func listInstanceMembers(c *gin.Context) {
- inst, ok := ownedInstance(c, c.Param("id"))
- if !ok {
- return
- }
- ctx := c.Request.Context()
- cur, err := db.Admin("instance_members").Find(ctx,
- bson.M{"instance_id": inst.InstanceID})
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- members := []models.InstanceMember{}
- if err := cur.All(ctx, &members); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, members)
-}
-
-// grantInstanceMember projects an account person into a cloud instance.
-func grantInstanceMember(c *gin.Context) {
- inst, ok := ownedInstance(c, c.Param("id"))
- if !ok {
- return
- }
- if inst.Deployment != license.DeploymentCloud {
- c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
- return
- }
-
- var body struct {
- UserID string `json:"user_id"`
- Role string `json:"role"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
- return
- }
- if body.Role == "" {
- body.Role = sharedmodels.RoleMember
- }
- if !sharedmodels.ValidRole(body.Role) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
- return
- }
-
- target, ok := accountUser(c, body.UserID)
- if !ok {
- return
- }
- if target.VerifiedAt == nil || target.PasswordHash == "" {
- // The projection copies a hash. An unverified invitee has no hash, so
- // the row would exist and be unusable — and an address nobody has
- // proven they control would hold a login inside a real instance.
- c.JSON(http.StatusConflict, gin.H{
- "error": "they have not accepted their invitation yet"})
- return
- }
-
- ctx := c.Request.Context()
- s := auth.Current(c)
-
- u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
- target.PasswordHash, body.Role, target.UserID)
- if err != nil {
- if errors.Is(err, provision.ErrEmailTaken) {
- c.JSON(http.StatusConflict, gin.H{
- "error": "that address already has a user inside this instance"})
- return
- }
- log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
- return
- }
-
- m := models.InstanceMember{
- MemberID: uuid.NewString(),
- AccountID: inst.AccountID,
- InstanceID: inst.InstanceID,
- CustomerUserID: target.UserID,
- ControlUserID: u.UserID,
- Role: body.Role,
- Email: target.Email,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
- // Unwind the projection: a control-plane login nobody on this side
- // records is a login nobody can revoke through the portal.
- if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
- log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
- target.Email, inst.InstanceID, rErr)
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
- return
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
- Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
- c.JSON(http.StatusCreated, m)
-}
-
-// memberRow loads one membership on an instance the caller owns.
-func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
- var m models.InstanceMember
- if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
- "instance_id": instanceID,
- "customer_user_id": customerUserID,
- }).Decode(&m); err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
- return nil, false
- }
- return &m, true
-}
-
-func updateInstanceMemberRole(c *gin.Context) {
- inst, ok := ownedInstance(c, c.Param("id"))
- if !ok {
- return
- }
- if inst.Deployment != license.DeploymentCloud {
- c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
- return
- }
- var body struct {
- Role string `json:"role"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
- return
- }
- m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
- if !ok {
- return
- }
-
- ctx := c.Request.Context()
- if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
- others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if others == 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "this is the instance's last owner; make someone else an owner first"})
- return
- }
- }
-
- if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
- return
- }
- if _, err := db.Admin("instance_members").UpdateOne(ctx,
- bson.M{"member_id": m.MemberID},
- bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
- log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
- }
-
- s := auth.Current(c)
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
- Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
- c.JSON(http.StatusOK, gin.H{"ok": true})
-}
-
-func revokeInstanceMember(c *gin.Context) {
- inst, ok := ownedInstance(c, c.Param("id"))
- if !ok {
- return
- }
- if inst.Deployment != license.DeploymentCloud {
- c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
- return
- }
- m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
- if !ok {
- return
- }
-
- ctx := c.Request.Context()
- if m.Role == sharedmodels.RoleOwner {
- others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if others == 0 {
- c.JSON(http.StatusConflict, gin.H{
- "error": "this is the instance's last owner; make someone else an owner first"})
- return
- }
- }
-
- if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
- return
- }
- if _, err := db.Admin("instance_members").DeleteOne(ctx,
- bson.M{"member_id": m.MemberID}); err != nil {
- log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
- }
-
- s := auth.Current(c)
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
- Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
- c.JSON(http.StatusOK, gin.H{"revoked": true})
-}
-```
-
-- [ ] **Step 3: Mount the member routes**
-
-In `admin/internal/api/routes.go`, inside the `cust` group after `cust.GET("/instances/:id/license/download", downloadInstanceLicense)`:
-
-```go
- cust.GET("/instances/:id/members", listInstanceMembers)
- cust.POST("/instances/:id/members",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- grantInstanceMember)
- cust.PUT("/instances/:id/members/:uid/role",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- updateInstanceMemberRole)
- cust.DELETE("/instances/:id/members/:uid",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- revokeInstanceMember)
-```
-
-- [ ] **Step 4: Compile**
-
-Run: `sh /tmp/gorun.sh admin go build ./...`
-Expected: no output. If gin panics at boot instead with a wildcard conflict, the `:uid` segment collided — check that no other route uses a different name in that position.
-
-- [ ] **Step 5: Confirm the self-hosted refusal is on all three mutating endpoints**
-
-Run: `grep -c "selfHostedRefusal" admin/internal/api/members.go`
-Expected: `4` (one definition, three uses). `listInstanceMembers` deliberately does not refuse — reading an empty list is harmless and the panel needs a truthful answer.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/internal/api/members.go admin/internal/api/routes.go admin/internal/api/customer.go
-git commit -m "feat(admin): grant, re-role and revoke instance members
-
-A grant writes a real control-plane user; the instance_members row is only
-admin's index of it, which is why a failed insert unwinds the projection.
-Self-hosted instances refuse all three mutations: their users live in a
-deployment we cannot see.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 5: One password, every instance
-
-**Files:**
-
-- Modify: `admin/internal/api/people.go`, `admin/internal/api/routes.go`
-
-**Interfaces:**
-
-- Consumes: `cloudprov.SetPasswordHash`, `auth.BcryptCost`.
-- Produces: route `PUT /api/account/password`, handler `changeAccountPassword`.
-
-- [ ] **Step 1: Add the handler**
-
-Append to `admin/internal/api/people.go`:
-
-```go
-// changeAccountPassword sets one password and pushes it everywhere.
-//
-// HQ's hash is the single source of truth for every hq-sourced row, and the
-// control plane has no local password-change path for them, so there is no
-// competing writer.
-//
-// Propagation is best-effort ON PURPOSE. Failing the password change because
-// one of three instances was briefly unreachable would leave the customer with
-// the password they were trying to get rid of; hqsync repairs a stale instance
-// within fifteen minutes, which is recoverable.
-func changeAccountPassword(c *gin.Context) {
- var body struct {
- CurrentPassword string `json:"current_password"`
- NewPassword string `json:"new_password"`
- }
- if err := c.ShouldBindJSON(&body); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
- return
- }
- if len(body.NewPassword) < 12 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
- return
- }
-
- s := auth.Current(c)
- ctx := c.Request.Context()
-
- var me models.CustomerUser
- if err := db.Admin("customer_users").FindOne(ctx,
- bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
- return
- }
- if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
- c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
- return
- }
-
- hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
- return
- }
- if _, err := db.Admin("customer_users").UpdateOne(ctx,
- bson.M{"user_id": me.UserID},
- bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
- return
- }
-
- pending := false
- if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
- pending = true
- now := time.Now().UTC()
- log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
- _, _ = db.Admin("customer_users").UpdateOne(ctx,
- bson.M{"user_id": me.UserID},
- bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
- } else {
- log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
- _, _ = db.Admin("customer_users").UpdateOne(ctx,
- bson.M{"user_id": me.UserID},
- bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
- Target: me.Email, IP: c.ClientIP()})
- c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
-}
-```
-
-Add `"time"` and `"golang.org/x/crypto/bcrypt"` to that file's imports.
-
-- [ ] **Step 2: Mount it**
-
-In `admin/internal/api/routes.go`, inside the `cust` group after the account-users routes:
-
-```go
- // Any member may change their own password — it is theirs. There is no
- // endpoint for changing anyone else's.
- cust.PUT("/account/password", changeAccountPassword)
-```
-
-- [ ] **Step 3: Compile**
-
-Run: `sh /tmp/gorun.sh admin go build ./...`
-Expected: no output.
-
-- [ ] **Step 4: Confirm there is exactly one password writer for hq rows**
-
-Run: `grep -rn "password_hash" admin/internal/ server/internal/api/ | grep -v "_test"`
-Expected: writes only in `auth/customer.go` (signup and accept-invite), `api/people.go` (this handler), `cloudprov.SetPasswordHash`, and `hqsync` once Task 6 lands. Nothing in `server/internal/api/` writes `password_hash` for an existing user.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add admin/internal/api/people.go admin/internal/api/routes.go
-git commit -m "feat(admin): one password change reaches every instance
-
-Best-effort by design: refusing the change because one instance was
-unreachable would leave the customer holding the password they were trying
-to replace. A failure is flagged and hqsync repairs it.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 6: `hqsync` — the fifteen-minute repair
-
-**Files:**
-
-- Create: `admin/internal/hqsync/hqsync.go`
-- Modify: `admin/cmd/main.go`
-
-**Interfaces:**
-
-- Consumes: `cloudprov.ProjectedUsers`, `cloudprov.SetPasswordHash`, `db.Admin`.
-- Produces: `hqsync.Reconcile(ctx) (checked, repaired int, err error)`, `hqsync.Start(ctx context.Context)`.
-
-- [ ] **Step 1: Write the package**
-
-Create `admin/internal/hqsync/hqsync.go`:
-
-```go
-// Package hqsync keeps projected control-plane users consistent with the HQ
-// people they were projected from.
-//
-// It is separate from inject on purpose. inject writes exactly three licence
-// fields on `instances` and that narrowness is the reason admin's reach into
-// the control plane is reviewable at all; a password repair pass bolted onto it
-// would quietly turn it into "the package that writes whatever admin wants".
-// This one goes through cloudprov, which is the sanctioned user write path.
-package hqsync
-
-import (
- "context"
- "log"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
- "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"
-)
-
-// Interval matches inject's reconciler. Fifteen minutes is the worst-case
-// staleness a password change can suffer, which the spec accepts as
-// recoverable.
-const Interval = 15 * time.Minute
-
-// Reconcile compares every projected user's stored hash against the HQ hash it
-// came from, and repairs mismatches.
-//
-// The comparison is on the hash string, not the password: two bcrypt hashes of
-// one password differ by salt, so this repairs by COPYING HQ's hash rather than
-// re-hashing. That is also why propagation copies rather than re-derives.
-func Reconcile(ctx context.Context) (checked, repaired int, err error) {
- cur, err := db.Admin("customer_users").Find(ctx,
- bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
- if err != nil {
- return 0, 0, err
- }
- var people []models.CustomerUser
- if err := cur.All(ctx, &people); err != nil {
- return 0, 0, err
- }
-
- for _, p := range people {
- projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
- if err != nil {
- log.Printf("hqsync: read projections of %s: %v", p.Email, err)
- continue
- }
- stale := false
- for _, u := range projected {
- checked++
- if u.PasswordHash != p.PasswordHash {
- stale = true
- }
- }
- if !stale {
- // Clear a stale failure flag: the instances agree, whatever the
- // flag says. Nothing reads the flag to decide what to repair.
- if p.HQSyncFailedAt != nil {
- _, _ = db.Admin("customer_users").UpdateOne(ctx,
- bson.M{"user_id": p.UserID},
- bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
- }
- continue
- }
-
- n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
- if err != nil {
- log.Printf("hqsync: repair %s: %v", p.Email, err)
- continue
- }
- repaired += int(n)
- log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
- _, _ = db.Admin("customer_users").UpdateOne(ctx,
- bson.M{"user_id": p.UserID},
- bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
- }
- return checked, repaired, nil
-}
-
-// Start runs once at boot, then on a ticker until ctx is cancelled.
-//
-// The boot pass is for the same reason inject's is: the likeliest moment for a
-// half-applied write is a deploy or a crash, and waiting a full interval to
-// notice means a customer's new password does not work somewhere for fifteen
-// minutes after we already know how to fix it.
-func Start(ctx context.Context) {
- go func() {
- runOnce(ctx)
- t := time.NewTicker(Interval)
- defer t.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-t.C:
- runOnce(ctx)
- }
- }
- }()
-}
-
-func runOnce(ctx context.Context) {
- runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
- defer cancel()
-
- checked, repaired, err := Reconcile(runCtx)
- if err != nil {
- log.Printf("hqsync: %v", err)
- return
- }
- if repaired > 0 {
- log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
- }
-}
-```
-
-- [ ] **Step 2: Start it at boot**
-
-In `admin/cmd/main.go`, add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"` and, immediately after `inject.StartReconciler(reconcileCtx)`:
-
-```go
- hqsync.Start(reconcileCtx)
-```
-
-- [ ] **Step 3: Compile**
-
-Run: `sh /tmp/gorun.sh admin go build ./...`
-Expected: no output.
-
-- [ ] **Step 4: Confirm inject stayed narrow**
-
-Run: `grep -n "password_hash\|role" admin/internal/inject/inject.go`
-Expected: no matches. If either appears, the pass was put in the wrong package.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add admin/internal/hqsync/hqsync.go admin/cmd/main.go
-git commit -m "feat(admin): hqsync repairs stale projected passwords
-
-Its own package rather than a pass inside inject: inject writes three
-licence fields and nothing else, and that narrowness is what makes admin's
-reach into the control plane reviewable.
-
-Repairs by copying HQ's hash, not by re-hashing — two bcrypt hashes of one
-password differ by salt, so a re-hash would never converge.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 7: The control plane refuses to edit what HQ owns
-
-**Files:**
-
-- Modify: `server/internal/services/users.go`, `server/internal/api/instance.go`
-
-**Interfaces:**
-
-- Produces: `services.ErrHQManaged error`; `UpdateUserRole` and `DeleteUser` return it for `auth_source == "hq"`; the API maps it to 409.
-
-- [ ] **Step 1: Add the error and the two guards**
-
-In `server/internal/services/users.go`, after the `ErrLastOwner` declaration, add:
-
-```go
-// ErrHQManaged is returned when a caller tries to change a user this instance
-// does not own.
-//
-// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role,
-// its password and its existence. A role editable in two places is a role with
-// two answers, and the loser is whichever writer ran first. Refusing here
-// rather than merely hiding the control in web/ is the point — the API is the
-// boundary, the UI is a courtesy.
-var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal")
-```
-
-In `UpdateUserRole`, immediately after the `target, err := GetUserInInstance(...)` error check:
-
-```go
- if target.AuthSource == models.AuthHQ {
- return ErrHQManaged
- }
-```
-
-Add the identical block in `DeleteUser` after its own `GetUserInInstance` check.
-
-- [ ] **Step 2: Map it to a status**
-
-In `server/internal/api/instance.go`, replace `orgUserErrStatus` with:
-
-```go
-func orgUserErrStatus(err error) int {
- if errors.Is(err, services.ErrLastOwner) || errors.Is(err, services.ErrHQManaged) {
- return http.StatusConflict
- }
- return http.StatusInternalServerError
-}
-```
-
-409 rather than 403: the caller has the right to manage members, and the request is refused because of the resource's state, not their permissions.
-
-- [ ] **Step 3: Compile**
-
-Run: `sh /tmp/gorun.sh server go build ./...`
-Expected: no output.
-
-- [ ] **Step 4: Confirm both paths are guarded**
-
-Run: `grep -n "ErrHQManaged" server/internal/services/users.go server/internal/api/instance.go`
-Expected: four lines — the declaration, two returns, and the status mapping.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add server/internal/services/users.go server/internal/api/instance.go
-git commit -m "feat(server): refuse local edits to hq-sourced users
-
-The API is the boundary; hiding the control in web/ is a courtesy. A role
-editable in two places is a role with two answers.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 8: The HQ portal — people, members, password
-
-**Files:**
-
-- Create: `adminsite/app/(customer)/users/page.tsx`, `adminsite/app/(customer)/users/InvitePanel.tsx`, `adminsite/app/(customer)/settings/page.tsx`, `adminsite/app/accept-invite/page.tsx`, `adminsite/components/MembersPanel.tsx`
-- Modify: `adminsite/lib/api.ts`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(customer)/instances/[id]/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`, `adminsite/app/verify/page.tsx`
-
-**Interfaces:**
-
-- Consumes: every route from Tasks 3–5.
-- Produces: `AccountRole`, `InstanceRole`, `AccountUser`, `InstanceMember` types; `api.accountUsers`, `api.invite`, `api.setAccountRole`, `api.removeAccountUser`, `api.changePassword`, `api.acceptInvite`, `api.members`, `api.grantMember`, `api.setMemberRole`, `api.revokeMember`.
-
-- [ ] **Step 1: Extend the API client**
-
-In `adminsite/lib/api.ts`, add after the `post` helper:
-
-```ts
-const put = (path: string, payload?: unknown) => req(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
-
-const del = (path: string) => req(path, { method: "DELETE" });
-```
-
-Add to the types section:
-
-```ts
-/*
- * Two role vocabularies, same three words. AccountRole governs the HQ account:
- * who may invite, create instances and grant access. InstanceRole is the role a
- * projected user holds INSIDE one instance. A person can be an account member
- * and an instance owner at once — that is normal, not a mistake.
- */
-export type AccountRole = "owner" | "admin" | "member";
-export type InstanceRole = "owner" | "admin" | "member";
-
-export interface AccountUser {
- user_id: string;
- account_id: string;
- email: string;
- account_role: AccountRole;
- verified_at?: string | null;
- hq_sync_failed_at?: string | null;
- created_at: string;
-}
-
-export interface InstanceMember {
- member_id: string;
- account_id: string;
- instance_id: string;
- customer_user_id: string;
- control_user_id: string;
- role: InstanceRole;
- email: string;
- created_at: string;
-}
-```
-
-Extend the `Session` interface with the caller's own role so the UI can hide what the backend would refuse:
-
-```ts
-export interface Session {
- kind: "staff" | "customer";
- email: string;
- account_id?: string;
- account_role?: AccountRole;
-}
-```
-
-Add to the `api` object, after `subscriptions`:
-
-```ts
- accountUsers: () => req("/api/account/users"),
- invite: (email: string, role: AccountRole) =>
- post<{ invited: boolean }>("/api/account/users", { email, role }),
- setAccountRole: (userId: string, role: AccountRole) =>
- put<{ ok: boolean }>(`/api/account/users/${userId}/role`, { role }),
- removeAccountUser: (userId: string) =>
- del<{ deleted: boolean }>(`/api/account/users/${userId}`),
- changePassword: (current_password: string, new_password: string) =>
- put<{ updated: boolean; propagation_pending: boolean }>("/api/account/password", {
- current_password,
- new_password,
- }),
- acceptInvite: (token: string, password: string) =>
- post<{ accepted: boolean }>("/auth/accept-invite", { token, password }),
-
- members: (instanceId: string) =>
- req(`/api/instances/${instanceId}/members`),
- grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
- post(`/api/instances/${instanceId}/members`, { user_id, role }),
- setMemberRole: (instanceId: string, userId: string, role: InstanceRole) =>
- put<{ ok: boolean }>(`/api/instances/${instanceId}/members/${userId}/role`, { role }),
- revokeMember: (instanceId: string, userId: string) =>
- del<{ revoked: boolean }>(`/api/instances/${instanceId}/members/${userId}`),
-```
-
-Also change `verify` to reflect the new response shape:
-
-```ts
- verify: (token: string) =>
- req<{ verified: boolean; needs_password?: boolean }>(
- `/auth/verify?token=${encodeURIComponent(token)}`,
- ),
-```
-
-- [ ] **Step 2: Report the account role from `/auth/me`**
-
-The UI needs the caller's role to decide what to render. In `admin/internal/api/customer.go`, replace the body of `getMe` after the nil check with:
-
-```go
- out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
- if s.Kind == auth.KindCustomer {
- var u models.CustomerUser
- if err := db.Admin("customer_users").FindOne(c.Request.Context(),
- bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
- out["account_role"] = u.AccountRole
- }
- }
- c.JSON(http.StatusOK, out)
-```
-
-Run: `sh /tmp/gorun.sh admin go build ./...` — expected no output.
-
-- [ ] **Step 3: The people page**
-
-Create `adminsite/app/(customer)/users/InvitePanel.tsx`:
-
-```tsx
-"use client";
-
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
-import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
-import { useSession } from "@/lib/session";
-import { NotConnectedPanel } from "@/components/NotConnected";
-import { Button } from "@/components/Button";
-import { Field } from "@/components/Field";
-
-const ROLES: AccountRole[] = ["owner", "admin", "member"];
-
-export function InvitePanel() {
- const qc = useQueryClient();
- const { session } = useSession();
- const [email, setEmail] = useState("");
- const [role, setRole] = useState("member");
- const [error, setError] = useState(null);
-
- const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
- const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
- const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
-
- const invite = useMutation({
- mutationFn: () => api.invite(email.trim().toLowerCase(), role),
- onSuccess: () => {
- setEmail("");
- setRole("member");
- refresh();
- },
- onError: fail,
- });
- const setRoleFor = useMutation({
- mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
- onSuccess: refresh,
- onError: fail,
- });
- const remove = useMutation({
- mutationFn: (id: string) => api.removeAccountUser(id),
- onSuccess: refresh,
- onError: fail,
- });
-
- if (users.error instanceof NotConnected) return ;
-
- const myRole = session?.account_role;
- const canManage = myRole === "owner" || myRole === "admin";
- const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
-
- return (
-
}>
-
-
-
- );
-}
-```
-
-- [ ] **Step 8: Send an invite token from `/verify` to the right place**
-
-Read `adminsite/app/verify/page.tsx`, find where it renders success from `api.verify(token)`, and add a branch before it: when the response has `needs_password`, redirect with
-
-```tsx
-if (data?.needs_password) {
- router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
- return null;
-}
-```
-
-using `useRouter` from `next/navigation`. This exists because an invitation and a verification link are the same shape, and someone will paste one into the other.
-
-- [ ] **Step 9: Nav and the corrected copy**
-
-In `adminsite/app/(customer)/layout.tsx`, add two links inside the nav after the Billing link:
-
-```tsx
-
- People
-
-
- Settings
-
-```
-
-In `adminsite/app/(customer)/instances/new/CreateForm.tsx`, phase 2's copy is now false — the password does propagate. Replace that paragraph with:
-
-```tsx
-
You sign in to it with this same email address and password. Changing your Vantage HQ password changes it here too.
-```
-
-- [ ] **Step 10: Build the site**
-
-Run:
-
-```bash
-MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
- sh -c "npm ci --silent && npm run build"
-```
-
-Expected: a successful build listing `/users`, `/settings` and `/accept-invite` among the routes.
-
-- [ ] **Step 11: Confirm no hex colours crept in**
-
-Run: `grep -nE "#[0-9a-fA-F]{3,8}\b" adminsite/components/MembersPanel.tsx "adminsite/app/(customer)/users/InvitePanel.tsx" "adminsite/app/(customer)/settings/page.tsx" adminsite/app/accept-invite/page.tsx`
-Expected: no matches. `CLAUDE.md`'s rule is that Tailwind in `adminsite/` maps `var(--…)` only and no component may carry a hex value.
-
-- [ ] **Step 12: Commit**
-
-```bash
-git add adminsite admin/internal/api/customer.go
-git commit -m "feat(adminsite): people, instance members and one password
-
-The members panel is absent for self-hosted instances rather than disabled:
-the backend refuses those, and a panel rendering controls the server will
-reject is a panel that lies.
-
-/auth/me now reports the caller's account role, so the UI hides what the
-backend would refuse rather than discovering it in an error toast.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 9: `web/` shows what it does not own
-
-**Files:**
-
-- Modify: `web/lib/api.ts`, `web/app/(app)/settings/instance/page.tsx`, `web/Dockerfile`, `.gitea/workflows/server-deploy.yml`
-
-**Interfaces:**
-
-- Consumes: `InstanceUser.auth_source === "hq"` from the existing `/instance/users` response.
-- Produces: `NEXT_PUBLIC_HQ_URL` at build time; locked rows in `MembersCard`.
-
-- [ ] **Step 1: Widen the type**
-
-In `web/lib/api.ts`, replace the `InstanceUser` interface with:
-
-```ts
-export interface InstanceUser {
- user_id: string;
- instance_id: string;
- email: string;
- role: Role;
- // "hq" means the row was projected from a Vantage HQ account. Its role,
- // password and existence belong to HQ; this instance refuses to change them.
- auth_source: "local" | "oidc" | "hq";
- hq_user_id?: string;
- created_at: string;
- last_login?: string;
-}
-```
-
-- [ ] **Step 2: Lock the hq rows**
-
-In `web/app/(app)/settings/instance/page.tsx`, inside `MembersCard`, add above the `return`:
-
-```tsx
-const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
-```
-
-Then, in the row map, replace
-
-```tsx
-const locked = isSelf || (u.role === "owner" && !isOwner);
-```
-
-with
-
-```tsx
-const managedByHQ = u.auth_source === "hq";
-// Locked here is a courtesy: the API returns 409 for an hq-sourced
-// role change or deletion whether or not this select is rendered.
-const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
-```
-
-Replace the sign-in cell with:
-
-```tsx
-
-
- );
- }
-
- return (
-
- );
-}
-```
-
-- [ ] **Step 7: Rework the billing page and add the overview call to action**
-
-In `adminsite/app/(customer)/billing/page.tsx`, replace the "Need a change?" rail card with a real portal button plus the cancellation explanation:
-
-```tsx
-
-
- Cards, invoices, VAT details and cancellation all live with
- Paddle, our payment provider.
-
- Billing stops at the end of the period you have paid for, and
- your licence keeps working until it expires — up to a month on
- a monthly plan, up to a year on Self Hosted.
-
-
- On that date the instance goes read-only: monitors keep
- running and alerts keep firing, changes stop. Nothing is
- deleted.
-
-
-```
-
-with the mutation at the top of the component:
-
-```tsx
-const portal = useMutation({
- mutationFn: api.billingPortal,
- onSuccess: (s) => {
- // A new tab, not a redirect: the customer is mid-task in the portal
- // and the Paddle link's token is short-lived, so a back button that
- // returns to a dead link would be worse than a second tab.
- window.open(s.overview_url, "_blank", "noopener,noreferrer");
- },
-});
-```
-
-Add the imports `useMutation`, `Button`, `ApiError`.
-
-Then in `adminsite/app/(customer)/page.tsx`, add a call to action alongside the existing create-instance one:
-
-```tsx
-
- Buy Self Hosted
-
-```
-
-Place it where the existing overview actions are; read the file and match its structure rather than guessing a location.
-
-- [ ] **Step 8: Confirm the build and the guards**
-
-```bash
-sh /tmp/npmrun.sh adminsite npm run build
-```
-
-Expected: `Compiled successfully`, and `/purchase` in the route list.
-
-Confirm no hex colours crept in and no Paddle IDs are hard-coded:
-
-```bash
-grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/CheckoutButton.tsx adminsite/components/UpgradePanel.tsx "adminsite/app/(customer)/purchase/"
-grep -rn "pri_\|pro_\|ctm_" adminsite/ --include=*.tsx --include=*.ts | grep -v "\.test\."
-```
-
-Expected: no output from either.
-
-Run the built image with an empty token and confirm the visible refusal rather than a broken button:
-
-```bash
-MSYS_NO_PATHCONV=1 docker build --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \
- --build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN= -t adminsite-test -f adminsite/Dockerfile adminsite/
-MSYS_NO_PATHCONV=1 docker run --rm -p 3004:3000 adminsite-test
-```
-
-Expected: signed in, `/purchase` shows "Checkout is not configured in this build" and no enabled buy button.
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add adminsite/package.json adminsite/package-lock.json adminsite/Dockerfile adminsite/lib adminsite/components adminsite/app
-git commit -m "feat(adminsite): Paddle checkout, and billing that manages itself
-
-The overlay carries account_id, instance_id and tier in custom_data,
-which is what lets a webhook route without a lookup table. Price IDs come
-from the API, never from this code: a plan is configured in the staff UI.
-
-CheckoutButton compares admin's PADDLE_ENV against the token this build
-was compiled with and refuses visibly on a mismatch, because a sandbox
-token cannot open a production price and the failure would otherwise be a
-silent dead button.
-
-Cancellation is Paddle's screen, so the two things we control say the same
-words: a permanent panel beside Manage billing, and the email the
-canceled webhook sends.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 8: Staff — the price-ID editor and the failed-webhook queue
-
-**Files:**
-
-- Modify: `adminsite/app/(staff)/staff/plans/page.tsx`, `adminsite/app/(staff)/staff/page.tsx`, `adminsite/lib/api.ts`
-
-**Interfaces:**
-
-- Consumes: `api.staff.plans`, `api.staff.updatePlan`, `api.staff.billingHealth`.
-- Produces: a per-plan price-ID form; a "Failed webhooks" queue on the operations dashboard.
-
-- [ ] **Step 1: Update the Plan type**
-
-In `adminsite/lib/api.ts`, change the `Plan` interface's price field:
-
-```ts
- /* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the
- * inner map; both environments are stored so promotion is a config change. */
- paddle_price_ids?: Record>>;
-```
-
-- [ ] **Step 2: Replace the demo buttons with the price editor**
-
-In `adminsite/app/(staff)/staff/plans/page.tsx`, replace the `
` block containing "Cap servers at 7" and "Remove OIDC" with a price-ID editor. Add above the component:
-
-```tsx
-const ENVS = ["sandbox", "production"] as const;
-
-/*
- * Price IDs are the one part of a plan a human pastes in from another system,
- * so they get a form rather than the confirm-dialog treatment: changing one
- * cannot affect an issued licence, only what the next checkout charges.
- *
- * Both environments are editable from one screen on purpose. Promoting to
- * production means pasting the production IDs here, not a deploy, and seeing
- * them side by side is what makes "did I paste the sandbox one" answerable.
- */
-function PriceEditor({ plan, onSave, saving }: { plan: Plan; onSave: (ids: Record>>) => void; saving: boolean }) {
- const [ids, setIds] = useState(plan.paddle_price_ids ?? {});
-
- // Free is not sold through Paddle, so it has nothing to configure.
- if (plan.tier === "free") {
- return (
-
- Free is not a Paddle product. Instances are created and renewed from the portal, so there is no price to configure.
-
- );
- }
-
- // Self Hosted is annual only — the monthly field is absent rather than
- // disabled, because a monthly self-hosted licence would be unrevokable for
- // a month and the backend refuses to issue one at all.
- const terms: Term[] = plan.tier === "self_hosted" ? ["annual"] : ["monthly", "annual"];
-
- const set = (env: string, term: Term, v: string) => setIds((prev) => ({ ...prev, [env]: { ...(prev[env] ?? {}), [term]: v.trim() } }));
-
- return (
-
- {ENVS.map((env) => (
-
- ))}
-
-
- );
-}
-```
-
-And render it inside each plan ``:
-
-```tsx
- savePrices.mutate({ ...p, paddle_price_ids })} />
-```
-
-with a second mutation that skips the confirmation dialog, since prices do not alter issued licences:
-
-```tsx
-const savePrices = useMutation({
- mutationFn: (p: Plan) =>
- api.staff.updatePlan(p.tier, {
- name: p.name,
- limits: p.limits,
- features: p.features,
- paddle_product_id: p.paddle_product_id,
- paddle_price_ids: p.paddle_price_ids,
- active: p.active,
- }),
- onSuccess: () => qc.invalidateQueries({ queryKey: ["plans"] }),
-});
-```
-
-Import `useState` and `Term`.
-
-- [ ] **Step 3: Add the failed-webhook queue**
-
-In `adminsite/app/(staff)/staff/page.tsx`, add the query:
-
-```tsx
-const billing = useQuery({ queryKey: ["billing-health"], queryFn: api.staff.billingHealth });
-```
-
-Include it in the "something is failing" test, since a failed webhook is the same class of problem as a failed injection — a customer who paid and got nothing:
-
-```tsx
-const failed = (injection.data?.count ?? 0) + (billing.data?.count ?? 0);
-```
-
-Keep the injection queue's own count separate where it renders (`injection.data?.count ?? 0`), and add a fourth queue:
-
-```tsx
- ({
- label: e.event_type,
- href: e.instance_id ? `/staff/instances/${e.instance_id}` : "/staff/audit",
- meta: e.received_at.slice(11, 16),
- }))}
-/>
-```
-
-Update the header subtitle so it is still true with two failure sources:
-
-```tsx
-failed > 0 ? "Failures come first — those customers are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing.";
-```
-
-- [ ] **Step 4: Confirm**
-
-```bash
-sh /tmp/npmrun.sh adminsite npm run build
-```
-
-Expected: `Compiled successfully`.
-
-With admin running and a staff cookie in `/tmp/sj`:
-
-```bash
-curl -s -b /tmp/sj -X PUT localhost:8083/api/staff/plans/professional \
- -H 'Content-Type: application/json' \
- -d '{"name":"Vantage Professional","limits":{"max_servers":0,"max_secret_groups":0,"max_channels":0},"features":["console","oidc"],"paddle_price_ids":{"sandbox":{"monthly":"pri_a","annual":"pri_b"},"production":{"monthly":"pri_c"}},"active":true}'
-curl -s -b /tmp/sj localhost:8083/api/staff/plans | python -m json.tool | grep -A6 paddle_price_ids
-```
-
-Expected: the nested map round-trips, both environments intact.
-
-```bash
-curl -s -b /tmp/sj localhost:8083/api/staff/health/billing | python -m json.tool
-```
-
-Expected: `count`, `failed`, `unlinked_placeholders`.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adminsite
-git commit -m "feat(adminsite): paste price IDs, and see the webhooks that failed
-
-Both environments are editable side by side, because promoting sandbox to
-production is pasting IDs here rather than a deploy, and seeing them
-together is what makes 'did I paste the sandbox one' answerable.
-
-Self Hosted shows no monthly field at all: the backend refuses to issue a
-monthly self-hosted licence, and a disabled input would suggest it is a
-setting rather than a rule.
-
-Failed webhooks join failed injections in the same first-position tone.
-Both mean a customer paid and got nothing.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 9: Deployment configuration and documentation
-
-**Files:**
-
-- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`, `docs/superpowers/specs/README.md`
-
-**Interfaces:**
-
-- Consumes: everything above.
-- Produces: the runnable configuration, and documentation that matches it.
-
-- [ ] **Step 1: Add admin's three variables**
-
-In `deploy/docker-compose.site.yml`, in the `admin` service's environment, after `FREE_INSTANCE_REAP_AFTER`:
-
-```yaml
-# sandbox or production. This picks which of a plan's two price-ID
-# maps is served, so promotion is a config change — but it must match
-# the token adminsite was BUILT with, or checkout refuses visibly.
-PADDLE_ENV: ${PADDLE_ENV:-sandbox}
-PADDLE_API_KEY: ${PADDLE_API_KEY:-}
-# Boot fails without this. An unverified webhook endpoint is an
-# endpoint anyone can issue licences through.
-PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-}
-```
-
-**Do not add any of these to `deploy/docker-compose.yml`.** A self-hosted deployment has no `admin` service at all, and `LICENSE_SIGNING_KEY`'s rule applies identically here.
-
-- [ ] **Step 2: Add adminsite's two build args**
-
-In `.gitea/workflows/server-deploy.yml`, in the adminsite build step:
-
-```yaml
-docker build \
---build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
---build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
---build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
---build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
--t "$IMAGE" \
--f adminsite/Dockerfile adminsite/
-```
-
-- [ ] **Step 3: Update `CLAUDE.md`**
-
-Four edits, all in existing tables and sections:
-
-In the **Admin REST API** section, add to the unauthenticated list:
-
-```
-POST /api/paddle/webhook # Paddle; signature-verified, idempotent
-```
-
-and to the customer-session list:
-
-```
-GET /checkout/options # tiers and price IDs for the running PADDLE_ENV
-POST /instances/self-hosted # placeholder row a self-hosted checkout attaches to
-POST /billing/portal # Paddle customer portal deep links (owner only)
-```
-
-and to the staff list:
-
-```
-GET /health/billing
-```
-
-In the **MongoDB Collections** paragraph about admin's own database, add `paddle_events` to the list and a note:
-
-```
-`paddle_events` is admin's webhook idempotency record and paper trail: unique on
-`event_id`, holding the raw payload, and deliberately without a TTL — dropping an
-event ID would reopen the duplicate window for a late replay. A duplicate of a
-handled event answers `200` and does nothing; a retry of a *failed* one is
-reprocessed, which is what makes Paddle's own retry schedule our retry mechanism.
-```
-
-Add a **Billing** subsection under Subsystems, after "Notification channels":
-
-```markdown
-### Billing
-
-Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the
-customer billing portal. Two products only — **Professional** (cloud, monthly or
-annual) and **Self Hosted** (annual only). **Free is not a Paddle product**: it
-is created and renewed from the portal by `POST /api/instances[/:id/renew]`, and
-an account gets a `paddle_customer_id` the first time a paid subscription's
-webhook arrives.
-
-Self Hosted is annual-only because licences are offline-verified and cannot be
-revoked. Cancellation therefore takes effect at **expiry**: `subscription.canceled`
-and `subscription.past_due` take no licence action at all, and the instance stays
-`active` — marking it cancelled would drop it out of `inject.Reconcile`'s filter
-and stop repairing a licence that is still valid.
-
-Price IDs live in `plans.paddle_price_ids` as environment → term → ID and are
-pasted in through the staff Plans screen. `PADDLE_ENV` picks the inner map, so
-promoting sandbox to production is a configuration change. **The same value must
-be baked into `adminsite` as `NEXT_PUBLIC_PADDLE_ENV`** alongside a matching
-`PADDLE_CLIENT_TOKEN`; `CheckoutButton` compares the two and refuses visibly
-rather than opening a checkout that cannot complete.
-
-A self-hosted purchase creates a **placeholder** `admin_instances` row before
-checkout, because `subscription.created` needs something to attach to. It is
-never licensed — a licence binds to the install's own UUID. Linking claims the
-row, moves the subscription onto the real UUID and issues. If the webhook lands
-_after_ the link instead, the hourly lifecycle sweep issues the same licence, so
-both orderings end identically; unclaimed paid placeholders are chased at 24 and
-72 hours.
-```
-
-Add to the server-side environment table's neighbourhood — specifically the admin variables need a home, so add a short table after the sitesvc one:
-
-```markdown
-**admin** (`deploy/docker-compose.site.yml` only, in addition to the existing variables):
-
-| Name | Required | Notes |
-| ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `PADDLE_ENV` | **yes** | `sandbox` or `production`. Selects which of a plan's price-ID maps is served; an unrecognised value refuses to boot rather than defaulting. Must match `adminsite`'s build-time `NEXT_PUBLIC_PADDLE_ENV`. |
-| `PADDLE_API_KEY` | **yes** | server-side API. Only outbound call is the customer portal session. |
-| `PADDLE_WEBHOOK_SECRET` | **yes** | signature verification. **Boot fails without it** — an unverified webhook endpoint is an endpoint anyone can issue licences through. |
-```
-
-And add two rows to the CI **Secrets / variables** table:
-
-```markdown
-| `PADDLE_CLIENT_TOKEN` | Variable | Paddle's **client-side** token, baked into `adminsite`. Environment-specific: a sandbox token cannot open a production price. |
-| `PADDLE_ENV` | Variable | `sandbox` or `production`, baked into `adminsite` and set on `admin`. The two must agree or checkout refuses. |
-```
-
-- [ ] **Step 4: Update the spec index**
-
-In `docs/superpowers/specs/README.md`, change spec 5's row:
-
-```markdown
-| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | **shipped**, with three documented deviations: Free stays outside Paddle, `canceled` marks the subscription rather than the instance, and the signup-migration section was already superseded by 6 |
-```
-
-- [ ] **Step 5: Confirm the configuration is where it should be and nowhere else**
-
-```bash
-grep -n "PADDLE" deploy/docker-compose.yml
-```
-
-Expected: **no output**. If anything matches, remove it — a self-hosted deployment must never carry billing configuration.
-
-```bash
-grep -c "PADDLE" deploy/docker-compose.site.yml .gitea/workflows/server-deploy.yml
-```
-
-Expected: `3` and `2` respectively.
-
-```bash
-grep -rn "pri_\|pro_01\|ctm_" admin/internal --include=*.go | grep -v "_test"
-```
-
-Expected: no output — no Paddle entity ID is hard-coded in Go.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add deploy .gitea CLAUDE.md docs/superpowers/specs/README.md
-git commit -m "doc: Paddle's configuration, and the three rules it must obey
-
-PADDLE_WEBHOOK_SECRET is fatal at boot, PADDLE_ENV must match the token
-baked into adminsite, and none of the three appear in the self-hosted
-compose file — the same rule LICENSE_SIGNING_KEY follows, for the same
-reason.
-
-Records why Free is not a Paddle product and why subscription.canceled
-marks the subscription rather than the instance.
-
-Co-Authored-By: Claude Opus 5 "
-```
-
----
-
-### Task 10: The sandbox catalog, and the end-to-end pass
-
-This task creates no code. It is the runbook that turns a working build into a
-working product, and it is the only place a Paddle dashboard is touched.
-
-**Files:** none. Record the resulting IDs in the staff Plans screen, not in the repo.
-
-**Interfaces:**
-
-- Consumes: everything above, deployed to a host Paddle can reach.
-- Produces: two sandbox products with prices, a webhook destination, and a signed-off verification list.
-
-- [ ] **Step 1: Create the catalog in Paddle sandbox**
-
-The Paddle MCP server is connected in the user's environment; use it if it is available in your session, otherwise do this in the sandbox dashboard. Create **two** products — Free is not a Paddle product:
-
-| Product | Prices | Notes |
-| -------------------- | ----------------------- | ------------------------------------------------------------------------------------- |
-| Vantage Professional | monthly and annual, GBP | cloud |
-| Vantage Self Hosted | **annual only**, GBP | create no monthly price; the backend refuses one and the staff UI has no field for it |
-
-Copy each price's `pri_…` ID.
-
-- [ ] **Step 2: Paste the IDs into the staff Plans screen**
-
-Sign in to `vantage-hq` as staff, open **Plans**, and paste each ID under **sandbox**. Leave production empty.
-
-Confirm the running service sees them:
-
-```bash
-curl -s -b /tmp/cj https://vantage-hq.hostxtra.co.uk/api/checkout/options | python -m json.tool
-```
-
-Expected: `environment: "sandbox"`, `professional` with two prices, `self_hosted` with `annual` only, and **no** `free` entry.
-
-- [ ] **Step 3: Point Paddle's webhook at admin**
-
-In the sandbox dashboard, create a notification destination at
-`https:///api/paddle/webhook`, subscribed to exactly:
-
-`subscription.created`, `subscription.updated`, `subscription.canceled`,
-`subscription.past_due`, `transaction.completed`, `transaction.payment_failed`,
-`customer.updated`.
-
-Copy the signing secret into `PADDLE_WEBHOOK_SECRET` and restart `admin`.
-
-Confirm the destination is live by sending a test event from the dashboard:
-Expected: `200` in Paddle's log, and a row in `paddle_events` with
-`status: "ignored"` (a test event is not one of the seven).
-
-- [ ] **Step 4: Cloud upgrade, end to end**
-
-As a customer with a Free cloud instance, open the instance and use **Upgrade to
-Professional**, annual, paying with Paddle's test card.
-
-Confirm, in order:
-
-1. `paddle_events` holds `subscription.created` with `status: "handled"`.
-2. `licenses` holds a new `professional` licence whose `expires_at` is the period end plus three days, and the previous Free licence now carries `superseded_by`.
-3. `admin_instances` shows `tier: professional`.
-4. The **control plane** instance document's `license_tier` is `professional` — that is `inject`, and it is what the customer's instance actually reads.
-5. In `web/`, the instance reports a valid Professional licence and the console and OIDC are available.
-
-- [ ] **Step 5: Self-hosted purchase, end to end**
-
-As a customer, `/purchase` → name → pay annually with the test card.
-
-Confirm:
-
-1. `admin_instances` has a `placeholder: true` row, `status: awaiting_link`, and **no licence**.
-2. `admin_audit` holds `billing.awaiting_link`.
-3. Pasting a real install's instance ID on `/instances/link` issues the licence, clears `placeholder`, and moves the subscription's `instance_id`.
-4. The licence blob emailed and downloaded are byte-identical, and pasting it into a self-hosted install reports `valid`.
-
-- [ ] **Step 6: Renewal, cancellation and replay**
-
-1. Trigger a renewal in the sandbox dashboard. Expected: a second licence with `reason: renewal`, the first superseded, `relink_count` back to `0`, and the cloud instance re-injected.
-2. Cancel the subscription in Paddle. Expected: `subscriptions.status: canceled`; **`admin_instances.status` still `active`**; licence count unchanged; the cancellation email states the expiry date.
-3. Let a Free instance's licence lapse (or set an expiry by hand) and confirm degradation is unchanged: monitors still executing, mutations refused.
-4. **Replay every event** from Paddle's dashboard notification log. Expected: every replay answers `200`, `paddle_events` gains no rows, and `licenses.countDocuments({})` is **identical before and after**. This is the single most important check in the plan.
-5. Simulate a failure: stop MongoDB's admin database briefly and replay one event. Expected: `500` to Paddle, `paddle_events.status: "failed"`, a `billing.webhook_failed` row in `admin_audit`, the event on the staff dashboard's Failed webhooks queue — and Paddle's own retry then succeeding, because a failed event is reclaimable.
-
-- [ ] **Step 7: Record the outcome**
-
-Append a short "verified on against Paddle sandbox" note to the spec
-index row for spec 5, listing anything the pass could not cover. Commit that
-alone.
-
----
-
-## Done when
-
-- A customer upgrades a Free cloud instance to Professional in the portal and their instance is serving a Professional licence within seconds, with the whole chain — event, licence, injection, control-plane document — agreeing.
-- A customer buys Self Hosted, links their install, and the licence they download verifies on it.
-- Every one of the seven subscribed event types produces the action the spec's table names, and nothing else does.
-- Replaying every event from Paddle's dashboard creates **zero** duplicate licences.
-- A bad signature is `401` and leaves no row in `paddle_events`.
-- `subscription.canceled` and `subscription.past_due` change no licence and no expiry, anywhere.
-- A handler failure is `500` to Paddle, is visible on the staff dashboard, and is fixed by Paddle's own retry once the cause is gone.
-- `grep -n "PADDLE" deploy/docker-compose.yml` returns nothing.
-- `grep -rn "pri_" admin/ adminsite/ --include=*.go --include=*.ts --include=*.tsx` returns nothing outside documentation.
-- Boot refuses without `PADDLE_WEBHOOK_SECRET`, and refuses an unrecognised `PADDLE_ENV`.
-
-**Not proven by this plan:** production Paddle. Everything is verified in sandbox, and promotion is pasting production price IDs into the staff screen, setting `PADDLE_ENV=production` on `admin`, and rebuilding `adminsite` with the production client token. Do the first real production purchase yourself and watch `paddle_events` while you do it.
-
-Also not proven: the three new emails, because the verification harness has no SMTP. `SendCancelled`, `SendPastDue` and `SendLinkReminder` are selected and their recipients resolved, but nothing is delivered. Watch the first real send.
-
-## Deployment order
-
-1. Deploy `admin` with `PADDLE_ENV=sandbox`, the API key and the webhook secret set. Nothing can be bought yet — no price IDs exist, so `checkout/options` serves an empty plan list and every checkout button is disabled with a reason.
-2. Create the sandbox catalog and paste the IDs (task 10, steps 1–2).
-3. Create the webhook destination and restart `admin` with its secret (task 10, step 3).
-4. Rebuild and deploy `adminsite` with `PADDLE_CLIENT_TOKEN` and `PADDLE_ENV`. **Because a repo variable pushes no commit, run the workflow manually** — this is exactly the gap `CLAUDE.md` names.
-5. Run task 10's verification.
-6. Only then repeat 1–4 with production values, in the same order.
-
-## Not in this plan
-
-Discounts and coupons, seat-based or metered pricing, proration arithmetic of
-our own, invoice display inside the portal, a self-service downgrade path, and
-tax handling — all of which are either Paddle's or deliberately out of scope per
-the spec's non-goals. Free stays outside Paddle; if that is ever revisited, it is
-a spec change, not a follow-up task.
diff --git a/docs/superpowers/plans/2026-07-27-paddle-billing.md b/docs/superpowers/plans/2026-07-27-paddle-billing.md
deleted file mode 100644
index 438a867..0000000
--- a/docs/superpowers/plans/2026-07-27-paddle-billing.md
+++ /dev/null
@@ -1,1800 +0,0 @@
-# Paddle Billing Implementation Plan (regenerated against shipped spec 7)
-
-> **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.
-
-**Supersedes** [`2026-07-26-paddle-billing.md`](2026-07-26-paddle-billing.md), which was written before spec 7 shipped and whose task bodies reference deleted shapes (`Plan.PaddlePriceIDs`, `models.ResolvePriceID`, a single price per subscription). Read that file only for history.
-
-**Goal:** connect Paddle's subscription lifecycle to the shipped licence issuer and metered entitlement model — a customer configures a plan, checks out through Paddle, and a webhook promotes their entitlement and issues the licence, with renewals and cancellation handled as Paddle reports them.
-
-**Architecture:** all Paddle SDK usage is isolated behind one `paddle.Client` interface in `admin/internal/paddle`, so the rest of the code depends on our types, not the SDK's. `admin/internal/billing` claims each webhook event idempotently in `paddle_events`, then dispatches. Subscription events resolve their line items back to a plan and configuration through the already-shipped `catalogue.ResolveItems`, promote the entitlement's `desired` into `granted`, and call `licensing.Issue` — the only signer. Nothing here shortens or revokes a licence.
-
-**Tech Stack:** Go 1.26, gin, MongoDB driver v2, `github.com/PaddleHQ/paddle-go-sdk` (server) + `@paddle/paddle-js` (browser), Next.js 16, TanStack Query, Tailwind 3.
-
-Spec: [`docs/superpowers/specs/2026-07-24-paddle-billing-design.md`](../specs/2026-07-24-paddle-billing-design.md) — but note the deviations in "What spec 7 already settled" below; the spec predates specs 6 and 7.
-
-## Global Constraints
-
-- **No automated Go tests.** Verify by compiler, `grep`, `curl`, and running built images against scratch databases. **Do not add `*_test.go` files.**
-- **Never run `go` or `npm` on the host.** Use the container wrappers:
- ```sh
- # /tmp/gorun.sh
- 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" golang:1.26 "$@"
- ```
- ```sh
- # /tmp/npmrun.sh
- DIR="$1"; shift
- MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \
- -w "/src/$DIR" node:26-alpine "$@"
- ```
-- **`MSYS_NO_PATHCONV=1` on every `docker` call.** **Run `go mod tidy` with `GOWORK=off`.**
-- **No price ID, product ID or Paddle URL is hard-coded**, in Go or TypeScript. Price IDs live in the `catalogue` collection (shipped), keyed environment→term, edited through the staff catalogue screen (shipped). The only Paddle literals allowed are the SDK's own base URLs and the two public env vars the browser build bakes in.
-- **Licences are offline-verified: nothing Paddle says revokes one early.** `subscription.canceled` and `subscription.past_due` take **no licence action**. Never write an earlier `expires_at`.
-- **Self-hosted is annual only.** A resolved self-hosted price whose term is not `annual` is a configuration error and must fail the handler loudly. `catalogue` already refuses to store such a price and `ResolveItems`/`LineItems` already return `ErrTermNotSold`.
-- **Every webhook is idempotent.** The event ID is claimed in `paddle_events` before processing. A duplicate of a _handled_ event returns `200` and does nothing; a retry of a _failed_ event is reprocessed.
-- **`licensing.Issue` stays the only signer.** Billing calls it; billing never touches `licenses` or signs anything.
-- **Admin's control-plane writes stay confined to `inject` and `cloudprov`.** This plan adds no third write path. Cloud delivery is `inject.Deliver`; self-hosted delivery is `mail.SendLicense`.
-- **Customer endpoints answer 404, never 403,** for another account's resource — every instance handler goes through `ownedInstance`.
-- **A licence is signed only from `granted`, never `desired`.** A webhook that confirms payment promotes `desired`→`granted` _then_ issues. Nothing else promotes.
-- **Free stays outside Paddle entirely.** No £0 subscription, no Paddle customer at signup. The shipped self-serve Free flow (`POST /api/instances`, `/renew`, `/claim-free`, the lifecycle notices, the reaper) is untouched. `paddle_customer_id` is learned from the first real subscription webhook.
-
-## What spec 7 already settled (do NOT rebuild)
-
-- **Catalogue + price IDs.** `catalogue` collection, `models.CatalogueRow.PriceID(env, term)`, `SeedCatalogue`, the staff catalogue editor at `adminsite/app/(staff)/staff/catalogue/`. Old spec-5 task 8 is **done**.
-- **Both folds.** `catalogue.LineItems(ctx, env, term, plan, cfg) ([]catalogue.Item, error)` builds subscription line items; `catalogue.ResolveItems(ctx, env, items) (catalogue.Match, error)` maps a full item list back to `{Deployment, Tier, Term, Servers, Features}`. `catalogue.Item{PriceID string; Quantity int}`. Errors: `ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced`.
-- **Entitlements.** `models.Entitlement{Desired, Granted models.Config, ResolvedLimits, ScheduledChangeAt, ...}`, `models.Config{Servers int; Features models.Features}`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.ErrNoEntitlement`, `Entitlement.Pending()`. `models.ReasonEntitlementChange`.
-- **The configurator component** `adminsite/components/PlanConfigurator.tsx` (`PlanChoice{tier,term,servers,features}`) and the adminsite api client types `Plan`, `CatalogueRow`, `Entitlement`, `Term`, `Deployment`, plus `api.staff.entitlement/setEntitlement`.
-- **Issuing from an entitlement.** `licensing.Issue` snapshots the instance's `granted` entitlement (falls back to plan base when none). `licensing.ErrFreeLimit`, `models.GracePeriod` (3 days; add yourself when passing `ExpiresAt`).
-- **Signup migration.** Done in spec 6 — signup/verify live in admin, sitesvc has only contact. The spec's "Signup migration off sitesvc" section is **obsolete; skip it entirely.**
-- **Two products, not three.** Free is not a Paddle product, in either deployment.
-
-## Deferred — cannot be done in this environment
-
-Live Paddle sandbox work is **out of this plan's automated scope**: creating the sandbox catalog, a real `PADDLE_API_KEY`/`PADDLE_WEBHOOK_SECRET`, a public webhook URL, and test-card checkout. Task 9 documents that manual pass; it is verified by the operator, not here. Everything in tasks 1–8 is written and **compiled**, and exercised by hand-crafted payloads through `curl` against a scratch database where possible.
-
----
-
-## File Structure
-
-**Created:**
-
-| Path | Responsibility |
-| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
-| `admin/internal/paddle/client.go` | the `Client` interface, our own request/response types, and `Init` |
-| `admin/internal/paddle/sdk.go` | the one adapter binding `Client` to `github.com/PaddleHQ/paddle-go-sdk` — the ONLY file that imports the SDK |
-| `admin/internal/paddle/webhook.go` | signature verification of a raw request body |
-| `admin/internal/billing/events.go` | idempotent claim of an event ID and the dispatch switch |
-| `admin/internal/billing/subscription.go` | `subscription.*` → entitlement promotion + reissue, as a function of current state |
-| `admin/internal/billing/transaction.go` | renewal and payment-failure handling |
-| `admin/internal/billing/deliver.go` | inject for cloud, email the blob for self-hosted, from a background context |
-| `admin/internal/api/paddle.go` | `POST /api/paddle/webhook`: read raw body, verify, claim, dispatch |
-| `admin/internal/api/checkout.go` | `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `PUT /api/instances/:id/entitlement`, `POST /api/billing/portal` |
-| `adminsite/lib/paddle.ts` | memoised `initializePaddle` |
-| `adminsite/components/CheckoutButton.tsx` | opens the overlay with the resolved line items and `custom_data` |
-| `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx` | self-hosted purchase: configure, checkout, then link |
-
-**Modified:**
-
-| Path | Change |
-| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
-| `admin/go.mod`, `admin/go.sum` | the Paddle SDK |
-| `admin/internal/config/config.go` | `PaddleEnv`, `PaddleAPIKey`, `PaddleWebhookSecret` (all required), read into `Config` |
-| `admin/internal/models/models.go` | `PaddleEvent`, term + subscription-status constants, `Subscription` gains `Items`, `Instance` gains `Placeholder` |
-| `admin/internal/db/db.go` | unique index on `paddle_events.event_id` |
-| `admin/internal/mail/mail.go` | `SendCancelled`, `SendPastDue`, `SendLinkReminder` |
-| `admin/internal/lifecycle/lifecycle.go` | awaiting-link sweep: backstop issuance from the entitlement, plus 24h/72h reminders |
-| `admin/internal/api/customer.go` | `linkInstance` claims a placeholder + issues when a subscription is already recorded |
-| `admin/internal/api/staff.go` | `staffBillingHealth` |
-| `admin/internal/api/routes.go` | the webhook route, three customer routes, one staff route |
-| `admin/cmd/main.go` | `paddle.Init` before the server starts |
-| `adminsite/package.json`, `adminsite/Dockerfile` | `@paddle/paddle-js`; `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + `NEXT_PUBLIC_PADDLE_ENV` build args |
-| `adminsite/lib/api.ts` | checkout-options + self-hosted-create + entitlement + portal calls and types |
-| `adminsite/components/InstanceRecord.tsx` | an upgrade/configure entry on a cloud instance |
-| `CLAUDE.md`, `docs/superpowers/specs/README.md` | the webhook, the new collections and env vars, spec status |
-
----
-
-### Task 1: The Paddle client, config, event model, and index
-
-**Files:**
-
-- Create: `admin/internal/paddle/client.go`, `admin/internal/paddle/sdk.go`, `admin/internal/paddle/webhook.go`
-- Modify: `admin/internal/config/config.go`, `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`, `admin/go.mod`
-
-**Interfaces:**
-
-- Produces:
- - `paddle.Client` interface: `UpdateSubscriptionItems(ctx, paddleSubID string, items []paddle.LineItem) error`, `PortalSession(ctx, paddleCustomerID string) (string, error)`, `Env() string`
- - `paddle.LineItem{PriceID string; Quantity int}`
- - `paddle.Init(cfg) (Client, error)` and package var access via `paddle.Get()`
- - `paddle.VerifySignature(secret string, header string, body []byte) bool`
- - `models.PaddleEvent`, `models.ClaimEvent(ctx, eventID, eventType string) (claimed bool, err error)`
- - subscription-status constants `models.SubActive`, `SubCanceled`, `SubPastDue`, `SubTrialing`; term constants `models.TermMonthly = "monthly"`, `TermAnnual = "annual"`
- - `models.Subscription.Items []models.SubItem` where `SubItem{PriceID string; Quantity int}`
- - `models.Instance.Placeholder bool`
- - `config.Config` fields `PaddleEnv`, `PaddleAPIKey`, `PaddleClientToken` (browser token is adminsite build-time, not read here), `PaddleWebhookSecret`
-
-- [ ] **Step 1: Add config fields**
-
-In `admin/internal/config/config.go`, add to `Config`:
-
-```go
- PaddleEnv string // "sandbox" or "production"
- PaddleAPIKey string
- PaddleWebhookSecret string
-```
-
-In `Load`, read them:
-
-```go
- PaddleEnv: envOr("PADDLE_ENV", "sandbox"),
- PaddleAPIKey: os.Getenv("PADDLE_API_KEY"),
- PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"),
-```
-
-Add both secrets to the existing `missing` required-var check (follow the file's pattern for appending to `missing`), because an unverified webhook endpoint is one anyone can issue licences through:
-
-```go
- if c.PaddleAPIKey == "" {
- missing = append(missing, "PADDLE_API_KEY")
- }
- if c.PaddleWebhookSecret == "" {
- missing = append(missing, "PADDLE_WEBHOOK_SECRET")
- }
-```
-
-- [ ] **Step 2: The client interface and our own types**
-
-Create `admin/internal/paddle/client.go`:
-
-```go
-// Package paddle is the only place that talks to Paddle. Everything outside it
-// depends on the Client interface and our own types, never on the SDK — so a
-// change to the SDK surface is confined to sdk.go, and the billing package can
-// be reasoned about without knowing Paddle exists.
-package paddle
-
-import "context"
-
-// LineItem is one price at a quantity, the shape both a checkout and a
-// subscription update are built from.
-type LineItem struct {
- PriceID string
- Quantity int
-}
-
-// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
-// the browser via paddle-js; the server only updates an existing subscription
-// and mints a portal session.
-type Client interface {
- // UpdateSubscriptionItems replaces a subscription's items, prorated
- // immediately by Paddle. This is the one outbound mutation, used when a
- // customer changes their server count or features on an existing plan.
- UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
- // PortalSession returns a customer-portal URL for managing billing.
- PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
- // Env is "sandbox" or "production", the same value catalogue price lookups
- // are keyed on.
- Env() string
-}
-
-var current Client
-
-// Init constructs the client from config and stores it. Called once at boot.
-func Init(apiKey, env string) (Client, error) {
- c, err := newSDKClient(apiKey, env)
- if err != nil {
- return nil, err
- }
- current = c
- return c, nil
-}
-
-// Get returns the client initialised at boot. Panics if unset, which can only
-// happen if a caller runs before Init — a programming error, not a runtime one.
-func Get() Client {
- if current == nil {
- panic("paddle.Get before paddle.Init")
- }
- return current
-}
-```
-
-- [ ] **Step 3: The SDK adapter (the only file importing the SDK)**
-
-Create `admin/internal/paddle/sdk.go`. **The exact SDK call shapes below must be verified against `github.com/PaddleHQ/paddle-go-sdk`'s current docs during implementation** — this file is deliberately the only place that risk lives. Structure it so the interface it satisfies never changes even if the calls do:
-
-```go
-package paddle
-
-import (
- "context"
- "fmt"
-
- paddlesdk "github.com/PaddleHQ/paddle-go-sdk/v4"
-)
-
-type sdkClient struct {
- sdk *paddlesdk.SDK
- env string
-}
-
-func newSDKClient(apiKey, env string) (Client, error) {
- base := paddlesdk.SandboxBaseURL
- if env == "production" {
- base = paddlesdk.ProductionBaseURL
- }
- sdk, err := paddlesdk.New(apiKey, paddlesdk.WithBaseURL(base))
- if err != nil {
- return nil, fmt.Errorf("paddle sdk: %w", err)
- }
- return &sdkClient{sdk: sdk, env: env}, nil
-}
-
-func (c *sdkClient) Env() string { return c.env }
-
-func (c *sdkClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
- reqItems := make([]paddlesdk.UpdateSubscriptionItems, 0, len(items))
- for _, it := range items {
- reqItems = append(reqItems, paddlesdk.NewUpdateSubscriptionItemsCatalogItem(&paddlesdk.CatalogItem{
- PriceID: it.PriceID,
- Quantity: it.Quantity,
- }))
- }
- _, err := c.sdk.UpdateSubscription(ctx, &paddlesdk.UpdateSubscriptionRequest{
- SubscriptionID: subID,
- Items: reqItems,
- ProrationBillingMode: ptr(paddlesdk.ProrationBillingModeProratedImmediately),
- })
- if err != nil {
- return fmt.Errorf("update subscription %s: %w", subID, err)
- }
- return nil
-}
-
-func (c *sdkClient) PortalSession(ctx context.Context, customerID string) (string, error) {
- res, err := c.sdk.CreateCustomerPortalSession(ctx, &paddlesdk.CreateCustomerPortalSessionRequest{
- CustomerID: customerID,
- })
- if err != nil {
- return "", fmt.Errorf("portal session for %s: %w", customerID, err)
- }
- return res.URLs.General.Overview, nil
-}
-
-func ptr[T any](v T) *T { return &v }
-```
-
-If a symbol above does not exist under that exact name in the installed SDK version, adjust _this file only_ until `go build` passes; the `Client` interface must not change.
-
-- [ ] **Step 4: Webhook signature verification**
-
-Create `admin/internal/paddle/webhook.go`. Paddle signs with an HMAC-SHA256 over `ts:body`, carried in the `Paddle-Signature` header as `ts=;h1=`:
-
-```go
-package paddle
-
-import (
- "crypto/hmac"
- "crypto/sha256"
- "encoding/hex"
- "strings"
-)
-
-// VerifySignature checks a raw webhook body against the Paddle-Signature header.
-//
-// It uses a constant-time compare and never logs the secret. A false return is
-// always a 401 with nothing processed — an unverified body could be anyone
-// claiming a subscription was paid for.
-func VerifySignature(secret, header string, body []byte) bool {
- if secret == "" || header == "" {
- return false
- }
- var ts, h1 string
- for _, part := range strings.Split(header, ";") {
- k, v, ok := strings.Cut(part, "=")
- if !ok {
- continue
- }
- switch k {
- case "ts":
- ts = v
- case "h1":
- h1 = v
- }
- }
- if ts == "" || h1 == "" {
- return false
- }
- mac := hmac.New(sha256.New, []byte(secret))
- mac.Write([]byte(ts))
- mac.Write([]byte(":"))
- mac.Write(body)
- want := hex.EncodeToString(mac.Sum(nil))
- return hmac.Equal([]byte(want), []byte(h1))
-}
-```
-
-- [ ] **Step 5: The event, subscription, term and instance model changes**
-
-In `admin/internal/models/models.go`, add constants:
-
-```go
-// Subscription statuses, mirrored from Paddle. Ours, not the SDK's, so the
-// billing package does not import the SDK.
-const (
- SubActive = "active"
- SubCanceled = "canceled"
- SubPastDue = "past_due"
- SubTrialing = "trialing"
-)
-
-// Billing terms. These match catalogue price-ID keys and license.TermsFor.
-const (
- TermMonthly = "monthly"
- TermAnnual = "annual"
-)
-```
-
-Add `PaddleEvent` and `SubItem`:
-
-```go
-// PaddleEvent is the idempotency record for one webhook delivery. The unique
-// index on EventID is what makes a retry a no-op rather than a second licence.
-type PaddleEvent struct {
- ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
- EventID string `bson:"event_id" json:"event_id"`
- EventType string `bson:"event_type" json:"event_type"`
- ReceivedAt time.Time `bson:"received_at" json:"received_at"`
- ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
- Error string `bson:"error,omitempty" json:"error,omitempty"`
-}
-
-// SubItem is one line of a subscription: a price and its quantity, the shape
-// catalogue.ResolveItems reads back into a plan and configuration.
-type SubItem struct {
- PriceID string `bson:"price_id" json:"price_id"`
- Quantity int `bson:"quantity" json:"quantity"`
-}
-```
-
-Replace the `Subscription.PaddlePriceID` single-price field with the item list (a metered subscription has several):
-
-```go
- // Items is the full line-item list. Spec 7 made a subscription several
- // prices — a base, a per-server unit at quantity N, an item per paid
- // feature — so a single price ID can no longer describe it.
- Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
-```
-
-Add to `Instance`:
-
-```go
- // Placeholder is true while a self-hosted instance row exists only so a
- // checkout has something to attach custom_data to, before the customer has
- // pasted their install's real UUID. Cleared by ClaimPlaceholder.
- Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
-```
-
-- [ ] **Step 6: ClaimEvent and the index**
-
-Create `admin/internal/models/paddle_events.go`:
-
-```go
-package models
-
-import (
- "context"
- "errors"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo"
-)
-
-// ClaimEvent records an event ID before it is processed and reports whether THIS
-// call is the one that claimed it.
-//
-// The unique index on event_id turns a duplicate insert into a duplicate-key
-// error, which is the signal that another delivery of the same event already
-// owns it — so this returns (false, nil) and the caller answers 200 without
-// acting. A genuine error returns (false, err).
-func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
- _, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
- EventID: eventID,
- EventType: eventType,
- ReceivedAt: time.Now().UTC(),
- })
- if err == nil {
- return true, nil
- }
- if mongo.IsDuplicateKeyError(err) {
- return false, nil
- }
- return false, err
-}
-
-// MarkEventProcessed stamps success, or records the error for staff visibility.
-// A failed event keeps no processed_at, so a retry re-runs it.
-func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
- set := bson.M{}
- if procErr != nil {
- set["error"] = procErr.Error()
- } else {
- now := time.Now().UTC()
- set["processed_at"] = now
- set["error"] = ""
- }
- _, err := db.Admin("paddle_events").UpdateOne(ctx,
- bson.M{"event_id": eventID}, bson.M{"$set": set})
- return err
-}
-
-// ErrEventClaimed is returned by callers that want to distinguish a benign
-// duplicate from a failure.
-var ErrEventClaimed = errors.New("event already claimed")
-```
-
-In `admin/internal/db/db.go`, add `paddle_events.event_id` to the `unique` slice in `EnsureIndexes`:
-
-```go
- {"paddle_events", "event_id"},
-```
-
-- [ ] **Step 7: Init at boot**
-
-In `admin/cmd/main.go`, after config load and before `api.Routes`, add:
-
-```go
- if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil {
- log.Fatalf("paddle init: %v", err)
- }
-```
-
-Add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"`.
-
-- [ ] **Step 8: Add the SDK and confirm it builds**
-
-```bash
-GOWORK=off /tmp/gorun.sh admin sh -c "go get github.com/PaddleHQ/paddle-go-sdk/v4 && go mod tidy"
-GOWORK=off /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output. If the SDK's major version or symbol names differ, fix `sdk.go` only.
-
-- [ ] **Step 9: Commit**
-
-```bash
-git add admin/internal/paddle admin/internal/config admin/internal/models admin/internal/db admin/cmd/main.go admin/go.mod admin/go.sum
-git commit -m "feat(admin): Paddle client behind an interface, config, and the event idempotency record"
-```
-
----
-
-### Task 2: The webhook endpoint — verify, claim, dispatch
-
-**Files:**
-
-- Create: `admin/internal/billing/events.go`, `admin/internal/api/paddle.go`
-- Modify: `admin/internal/api/routes.go`
-
-**Interfaces:**
-
-- Consumes: `paddle.VerifySignature`, `models.ClaimEvent`, `models.MarkEventProcessed`.
-- Produces:
- - `billing.Event` — the decoded envelope `{EventID, EventType string; Data json.RawMessage; OccurredAt time.Time}`
- - `billing.Dispatch(ctx context.Context, ev billing.Event) error`
-
-- [ ] **Step 1: The envelope and dispatch switch**
-
-Create `admin/internal/billing/events.go`:
-
-```go
-// Package billing turns verified Paddle webhooks into licence actions. It never
-// verifies signatures (that is paddle.VerifySignature at the edge) and never
-// signs (that is licensing.Issue); it decides what a subscription's current
-// state means and calls the issuer.
-package billing
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "time"
-)
-
-// Event is the decoded Paddle webhook envelope. Data is left raw so each handler
-// decodes only the shape it needs.
-type Event struct {
- EventID string `json:"event_id"`
- EventType string `json:"event_type"`
- OccurredAt time.Time `json:"occurred_at"`
- Data json.RawMessage `json:"data"`
-}
-
-// Dispatch routes one event to its handler. Unknown event types are a no-op
-// success: Paddle sends many we do not care about, and 200 stops it retrying.
-func Dispatch(ctx context.Context, ev Event) error {
- switch ev.EventType {
- case "subscription.created", "subscription.updated", "subscription.activated":
- return handleSubscription(ctx, ev)
- case "subscription.canceled":
- return handleCanceled(ctx, ev)
- case "subscription.past_due":
- return handlePastDue(ctx, ev)
- case "transaction.completed":
- return handleTransactionCompleted(ctx, ev)
- case "transaction.payment_failed":
- return handlePaymentFailed(ctx, ev)
- case "customer.updated":
- return handleCustomerUpdated(ctx, ev)
- default:
- return nil
- }
-}
-
-// decode is a small helper so every handler decodes Data the same way.
-func decode[T any](ev Event) (T, error) {
- var v T
- if err := json.Unmarshal(ev.Data, &v); err != nil {
- return v, fmt.Errorf("decode %s: %w", ev.EventType, err)
- }
- return v, nil
-}
-```
-
-(The handler functions `handleSubscription`, `handleCanceled`, `handlePastDue`, `handleTransactionCompleted`, `handlePaymentFailed`, `handleCustomerUpdated` are written in tasks 3 and 4. This task stubs them to compile — see step 2.)
-
-- [ ] **Step 2: Temporary compiling stubs**
-
-At the bottom of `events.go`, add stubs so this task builds independently; tasks 3–4 replace them:
-
-```go
-// Stubs replaced in tasks 3 and 4.
-func handleSubscription(ctx context.Context, ev Event) error { return nil }
-func handleCanceled(ctx context.Context, ev Event) error { return nil }
-func handlePastDue(ctx context.Context, ev Event) error { return nil }
-func handleTransactionCompleted(ctx context.Context, ev Event) error { return nil }
-func handlePaymentFailed(ctx context.Context, ev Event) error { return nil }
-func handleCustomerUpdated(ctx context.Context, ev Event) error { return nil }
-```
-
-- [ ] **Step 3: The endpoint**
-
-Create `admin/internal/api/paddle.go`:
-
-```go
-package api
-
-import (
- "encoding/json"
- "io"
- "log"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
-)
-
-// paddleWebhook is the ingress for every Paddle event.
-//
-// Order is load-bearing: read the RAW body first (signature is over the exact
-// bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature is
-// 401 and processes nothing; a duplicate of a handled event is 200 and does
-// nothing; a handler error is 500 so Paddle retries, and is recorded for staff.
-func paddleWebhook(cfg config.Config) gin.HandlerFunc {
- return func(c *gin.Context) {
- body, err := io.ReadAll(c.Request.Body)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
- return
- }
- if !paddle.VerifySignature(cfg.PaddleWebhookSecret,
- c.GetHeader("Paddle-Signature"), body) {
- log.Printf("paddle webhook: bad signature from %s", c.ClientIP())
- c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
- return
- }
-
- var ev billing.Event
- if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"})
- return
- }
-
- ctx := c.Request.Context()
- claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
- return
- }
- if !claimed {
- // Already handled (or in flight). 200 so Paddle stops retrying.
- c.JSON(http.StatusOK, gin.H{"duplicate": true})
- return
- }
-
- if err := billing.Dispatch(ctx, ev); err != nil {
- log.Printf("paddle webhook: handler %s failed for %s: %v",
- ev.EventType, ev.EventID, err)
- _ = models.MarkEventProcessed(ctx, ev.EventID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"})
- return
- }
- _ = models.MarkEventProcessed(ctx, ev.EventID, nil)
- c.JSON(http.StatusOK, gin.H{"ok": true})
- }
-}
-```
-
-- [ ] **Step 4: Mount the route (public, unsigned bodies allowed in)**
-
-In `admin/internal/api/routes.go`, beside the other `r.POST("/auth/...` public routes, add:
-
-```go
- r.POST("/api/paddle/webhook", paddleWebhook(cfg))
-```
-
-It must NOT be under the `cust` (`/api`) group's session middleware — Paddle carries no session cookie; its signature is its auth.
-
-- [ ] **Step 5: Confirm**
-
-```bash
-GOWORK=off /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-Against admin running on a scratch database, a bad signature is rejected and a duplicate is a no-op:
-
-```bash
-curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8083/api/paddle/webhook \
- -H 'Paddle-Signature: ts=1;h1=deadbeef' -d '{"event_id":"evt_x","event_type":"customer.updated","data":{}}'
-```
-
-Expected: `401`.
-
-(A correctly-signed duplicate check needs the real secret; it is exercised in task 9's sandbox pass. The idempotency LOGIC is unit-visible: `ClaimEvent` twice against the scratch DB returns `true` then `false` — confirm with a tiny throwaway `curl` once a valid signature path exists, or by inserting the same `event_id` twice with `mongosh` and watching the second collide.)
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/internal/billing/events.go admin/internal/api/paddle.go admin/internal/api/routes.go
-git commit -m "feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch"
-```
-
----
-
-### Task 3: Subscription events → entitlement promotion and reissue
-
-**Files:**
-
-- Modify: `admin/internal/billing/subscription.go` (create), `admin/internal/billing/events.go` (remove the three subscription stubs)
-- Create: `admin/internal/billing/deliver.go`
-
-**Interfaces:**
-
-- Consumes: `catalogue.ResolveItems`, `catalogue.Match`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.Config`, `licensing.Issue`, `paddle.Get().Env()`, `inject.Deliver`, `mail.SendLicense`.
-- Produces:
- - `billing.deliver(ctx, inst *models.Instance, lic *models.License)`
- - the real `handleSubscription`, `handleCanceled`, `handlePastDue`
-
-- [ ] **Step 1: Background delivery**
-
-Create `admin/internal/billing/deliver.go`:
-
-```go
-package billing
-
-import (
- "context"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
- "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"
-)
-
-// deliver sends a freshly issued licence where it belongs. Cloud is injected;
-// self-hosted is emailed the blob (their database is theirs). This mirrors the
-// api-side deliver helper but takes no gin context — webhooks have none, and the
-// customer is not on the other end of the request.
-func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) {
- if inst.Deployment == license.DeploymentCloud {
- inject.Deliver(ctx, lic)
- return
- }
- if to != "" && mail.Enabled() {
- _ = mail.SendLicense(to, inst.Name, lic.Blob)
- }
-}
-```
-
-- [ ] **Step 2: The subscription payload shape and resolution**
-
-Create `admin/internal/billing/subscription.go`. Paddle's `subscription.*` data carries `id`, `customer_id`, `status`, `current_billing_period.ends_at`, `custom_data` and `items[]` each with `price.id` and `quantity`:
-
-```go
-package billing
-
-import (
- "context"
- "fmt"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
- "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/admin/internal/paddle"
- "go.mongodb.org/mongo-driver/v2/bson"
- "go.mongodb.org/mongo-driver/v2/mongo/options"
-)
-
-// subscriptionData is the slice of Paddle's subscription payload we read. Fields
-// we ignore are simply absent — encoding/json drops them.
-type subscriptionData struct {
- ID string `json:"id"`
- CustomerID string `json:"customer_id"`
- Status string `json:"status"`
- CustomData struct {
- AccountID string `json:"account_id"`
- InstanceID string `json:"instance_id"`
- } `json:"custom_data"`
- CurrentBillingPeriod struct {
- EndsAt time.Time `json:"ends_at"`
- } `json:"current_billing_period"`
- Items []struct {
- Price struct {
- ID string `json:"id"`
- } `json:"price"`
- Quantity int `json:"quantity"`
- } `json:"items"`
-}
-
-func (d subscriptionData) lineItems() []catalogue.Item {
- items := make([]catalogue.Item, 0, len(d.Items))
- for _, it := range d.Items {
- items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
- }
- return items
-}
-
-// handleSubscription is created/updated/activated — all three are folded into
-// "make the world match the subscription's CURRENT state", which is what keeps
-// out-of-order delivery correct: an updated arriving before its created still
-// carries the full item list, so reading all of it is reading current state.
-func handleSubscription(ctx context.Context, ev Event) error {
- d, err := decode[subscriptionData](ev)
- if err != nil {
- return err
- }
- if d.CustomData.InstanceID == "" {
- return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
- }
-
- match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
- if err != nil {
- // A price we cannot map is a configuration error, not a customer error.
- // Fail loudly so it is retried and surfaced rather than guessed.
- return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
- }
-
- // Record the subscription first, as current state. This is idempotent: the
- // same event replayed writes the same row.
- sub := models.Subscription{
- AccountID: d.CustomData.AccountID,
- InstanceID: d.CustomData.InstanceID,
- PaddleSubscriptionID: d.ID,
- Tier: match.Tier,
- Term: match.Term,
- Status: d.Status,
- CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt,
- Items: toSubItems(d.lineItems()),
- }
- if err := upsertSubscription(ctx, sub); err != nil {
- return err
- }
-
- // Learn the Paddle customer ID onto the account the first time we see it.
- if d.CustomerID != "" && d.CustomData.AccountID != "" {
- _, _ = db.Admin("accounts").UpdateOne(ctx,
- bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
- bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
- }
-
- // Load the instance. A self-hosted placeholder that has not been linked yet
- // gets its subscription recorded but NO licence — there is no UUID to bind
- // to. The link endpoint (task 6) issues when the customer pastes it.
- var inst models.Instance
- if err := db.Admin("admin_instances").FindOne(ctx,
- bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
- return fmt.Errorf("subscription %s names unknown instance %s: %w",
- d.ID, d.CustomData.InstanceID, err)
- }
- if inst.Placeholder {
- return nil // awaiting link; nothing to issue yet
- }
-
- return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange)
-}
-
-// promoteAndIssue promotes desired→granted from the resolved match, then signs a
-// licence from granted. This is the ONLY promotion path other than the staff
-// grant, and it exists because a webhook is a confirmed payment.
-func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
- plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
- if err != nil {
- return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
- }
- granted := models.Config{
- Servers: match.Servers,
- Features: models.Features(match.Features).OrEmpty(),
- }
- limits, _, err := catalogue.Resolve(ctx, plan, granted)
- if err != nil {
- return err
- }
- if err := models.UpsertEntitlement(ctx, models.Entitlement{
- InstanceID: inst.InstanceID,
- AccountID: inst.AccountID,
- Deployment: inst.Deployment,
- Tier: match.Tier,
- Term: match.Term,
- Desired: granted,
- Granted: granted,
- ResolvedLimits: limits,
- }); err != nil {
- return err
- }
-
- lic, err := licensing.Issue(ctx, licensing.IssueInput{
- InstanceID: inst.InstanceID,
- Tier: match.Tier,
- Term: match.Term,
- Reason: reason,
- IssuedBy: "paddle",
- })
- if err != nil {
- return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
- }
- deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
- return nil
-}
-
-func toSubItems(items []catalogue.Item) []models.SubItem {
- out := make([]models.SubItem, 0, len(items))
- for _, it := range items {
- out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
- }
- return out
-}
-
-func upsertSubscription(ctx context.Context, sub models.Subscription) error {
- _, err := db.Admin("subscriptions").UpdateOne(ctx,
- bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
- bson.M{"$set": bson.M{
- "account_id": sub.AccountID,
- "instance_id": sub.InstanceID,
- "tier": sub.Tier,
- "term": sub.Term,
- "status": sub.Status,
- "current_period_end": sub.CurrentPeriodEnd,
- "items": sub.Items,
- }, "$setOnInsert": bson.M{
- "subscription_id": newSubID(),
- "paddle_subscription_id": sub.PaddleSubscriptionID,
- }},
- options.UpdateOne().SetUpsert(true))
- return err
-}
-
-// billingEmailFor reads the account's billing email for self-hosted delivery.
-func billingEmailFor(ctx context.Context, accountID string) string {
- var acc models.Account
- if err := db.Admin("accounts").FindOne(ctx,
- bson.M{"account_id": accountID}).Decode(&acc); err != nil {
- return ""
- }
- return acc.BillingEmail
-}
-```
-
-Add a `newSubID` helper (uuid) if one does not already exist in the package; reuse the existing generator the codebase uses for subscription IDs if there is one — check `staff.go`/`customer.go` for how `subscription_id` is currently minted and match it.
-
-- [ ] **Step 3: Cancel and past-due — no licence action**
-
-Append to `subscription.go`:
-
-```go
-// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
-//
-// The instance stays active until its licence expires, at which point the
-// existing lifecycle sweep lapses it. Flipping the instance to cancelled here
-// would stop inject.Reconcile and the sweep repairing a licence that is still
-// valid — the opposite of "keeps working until it expires".
-func handleCanceled(ctx context.Context, ev Event) error {
- d, err := decode[subscriptionData](ev)
- if err != nil {
- return err
- }
- if _, err := db.Admin("subscriptions").UpdateOne(ctx,
- bson.M{"paddle_subscription_id": d.ID},
- bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
- return err
- }
- if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
- _ = sendCancelled(to, d.CustomData.InstanceID)
- }
- return nil
-}
-
-// handlePastDue flags the subscription and notifies, but leaves the licence
-// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
-func handlePastDue(ctx context.Context, ev Event) error {
- d, err := decode[subscriptionData](ev)
- if err != nil {
- return err
- }
- if _, err := db.Admin("subscriptions").UpdateOne(ctx,
- bson.M{"paddle_subscription_id": d.ID},
- bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
- return err
- }
- if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
- _ = sendPastDue(to, d.CustomData.InstanceID)
- }
- return nil
-}
-```
-
-`sendCancelled`/`sendPastDue` are thin wrappers over the mail functions added in task 6; declare them there. For this task to compile, add the mail functions in task 6 _first_, or add temporary local wrappers — the plan orders task 6's mail additions before this compiles cleanly, so add the three mail funcs now (they are small; see task 6 step 1) if executing strictly in order.
-
-- [ ] **Step 4: `customer.updated`**
-
-Append:
-
-```go
-// handleCustomerUpdated syncs the billing email onto the account.
-func handleCustomerUpdated(ctx context.Context, ev Event) error {
- d, err := decode[struct {
- ID string `json:"id"`
- Email string `json:"email"`
- }](ev)
- if err != nil {
- return err
- }
- if d.ID == "" || d.Email == "" {
- return nil
- }
- _, err = db.Admin("accounts").UpdateOne(ctx,
- bson.M{"paddle_customer_id": d.ID},
- bson.M{"$set": bson.M{"billing_email": d.Email}})
- return err
-}
-```
-
-Remove the corresponding stubs from `events.go`.
-
-- [ ] **Step 5: Confirm**
-
-```bash
-GOWORK=off /tmp/gorun.sh admin go build ./...
-GOWORK=off /tmp/gorun.sh admin go vet ./...
-```
-
-Expected: no output.
-
-`grep` the two guard rails:
-
-```bash
-grep -rn "expires_at\|ExpiresAt" admin/internal/billing/
-```
-
-Expected: no assignment of an earlier expiry — cancel/past-due touch only `status`.
-
-```bash
-grep -rn "\.Desired" admin/internal/billing/
-```
-
-Expected: no read of `Desired` — billing signs from `granted` only (`promoteAndIssue` writes both from the confirmed match).
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/internal/billing
-git commit -m "feat(admin): subscription webhooks promote the entitlement and reissue; cancel and past-due take no licence action"
-```
-
----
-
-### Task 4: Renewals and payment failures
-
-**Files:**
-
-- Modify: `admin/internal/billing/transaction.go` (create), `admin/internal/billing/events.go` (remove the two transaction stubs)
-
-**Interfaces:**
-
-- Consumes: `catalogue.ResolveItems`, `licensing.Issue` with `Reason: models.ReasonRenewal`, `models.GetEntitlement`, `models.UpsertEntitlement`.
-- Produces: `handleTransactionCompleted`, `handlePaymentFailed`.
-
-- [ ] **Step 1: Renewal**
-
-Create `admin/internal/billing/transaction.go`. A renewal `transaction.completed` carries `subscription_id` and `origin` (`subscription_recurring` marks a renewal rather than the first charge):
-
-```go
-package billing
-
-import (
- "context"
- "fmt"
- "time"
-
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
- "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/admin/internal/paddle"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-type transactionData struct {
- ID string `json:"id"`
- SubscriptionID string `json:"subscription_id"`
- Origin string `json:"origin"`
- CustomData struct {
- AccountID string `json:"account_id"`
- InstanceID string `json:"instance_id"`
- } `json:"custom_data"`
- Items []struct {
- Price struct {
- ID string `json:"id"`
- } `json:"price"`
- Quantity int `json:"quantity"`
- } `json:"items"`
- BillingPeriod struct {
- EndsAt time.Time `json:"ends_at"`
- } `json:"billing_period"`
-}
-
-// handleTransactionCompleted issues the next term's licence on a renewal.
-//
-// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's
-// desired (smaller) configuration becomes granted, and only now — mid-term
-// reductions never shrink a live licence. On a first charge (origin not
-// recurring) the subscription.created/updated handler already issued, so this is
-// a no-op to avoid a double issue.
-func handleTransactionCompleted(ctx context.Context, ev Event) error {
- d, err := decode[transactionData](ev)
- if err != nil {
- return err
- }
- if d.Origin != "subscription_recurring" {
- return nil
- }
- if d.SubscriptionID == "" {
- return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID)
- }
-
- var sub models.Subscription
- if err := db.Admin("subscriptions").FindOne(ctx,
- bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil {
- return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err)
- }
-
- var inst models.Instance
- if err := db.Admin("admin_instances").FindOne(ctx,
- bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil {
- return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err)
- }
-
- // Prefer the transaction's own item list (authoritative for this period);
- // fall back to the subscription's recorded items.
- items := make([]catalogue.Item, 0, len(d.Items))
- for _, it := range d.Items {
- items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
- }
- if len(items) == 0 {
- for _, it := range sub.Items {
- items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
- }
- }
- match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
- if err != nil {
- return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err)
- }
-
- // Promote a scheduled reduction: desired becomes granted, and the pending
- // marker is cleared, since a new term has begun. This is the only place a
- // licence ever gets a smaller cap.
- if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil {
- return err
- }
-
- // Issue the next term. Renewal resets relink_count inside licensing.Issue.
- if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil {
- return err
- }
-
- // Clear lifecycle notices so the next term starts the sequence fresh (mirrors
- // the self-serve renew path).
- _, _ = db.Admin("admin_instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID},
- bson.M{"$unset": bson.M{"notices_sent": ""}})
- return nil
-}
-
-// promoteScheduledReduction collapses a pending reduction into granted at
-// renewal and clears scheduled_change_at. If there is no pending reduction it is
-// a no-op — the match resolved from the renewal's items is authoritative either
-// way, so this only matters for the entitlement's own bookkeeping.
-func promoteScheduledReduction(ctx context.Context, instanceID string) error {
- ent, err := models.GetEntitlement(ctx, instanceID)
- if err != nil {
- return nil // no entitlement to reconcile
- }
- if ent.ScheduledChangeAt == nil {
- return nil
- }
- ent.Granted = ent.Desired
- ent.ScheduledChangeAt = nil
- return models.UpsertEntitlement(ctx, *ent)
-}
-
-// handlePaymentFailed records the failure for staff visibility. No licence
-// action — the licence runs to its (already grace-padded) expiry and Paddle
-// retries the charge.
-func handlePaymentFailed(ctx context.Context, ev Event) error {
- d, err := decode[transactionData](ev)
- if err != nil {
- return err
- }
- if d.SubscriptionID == "" {
- return nil
- }
- _, err = db.Admin("subscriptions").UpdateOne(ctx,
- bson.M{"paddle_subscription_id": d.SubscriptionID},
- bson.M{"$set": bson.M{"status": models.SubPastDue}})
- return err
-}
-```
-
-Remove the two transaction stubs from `events.go`.
-
-- [ ] **Step 2: Confirm**
-
-```bash
-GOWORK=off /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-```bash
-grep -rn "ReasonRenewal" admin/internal/billing/transaction.go
-```
-
-Expected: one hit — the renewal issue. A renewal that used `ReasonEntitlementChange` would not reset `relink_count`.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add admin/internal/billing/transaction.go admin/internal/billing/events.go
-git commit -m "feat(admin): renewals issue the next term and collapse a scheduled reduction; payment-failed flags only"
-```
-
----
-
-### Task 5: Checkout options, self-hosted placeholder, entitlement update, portal
-
-**Files:**
-
-- Create: `admin/internal/api/checkout.go`
-- Modify: `admin/internal/api/routes.go`
-
-**Interfaces:**
-
-- Consumes: `models.AllCatalogue`/`CatalogueFor`, `models.GetPlan`, `catalogue.LineItems`, `paddle.Get()`, `ownedInstance`, `auth.Current`, `shared/provision` slug rules (reuse whatever `createInstance` uses to mint an instance id), `models.UpsertEntitlement`, `models.GetEntitlement`.
-- Produces:
- - `GET /api/checkout/options` → `{plans: []Plan, catalogue: []CatalogueRow, env: string, client_token_note}` (client token is baked into the browser build, not served)
- - `POST /api/instances/self-hosted` → creates a placeholder instance, returns `{instance_id}`
- - `PUT /api/instances/:id/entitlement` → sets `desired`, computes line items, calls `paddle.UpdateSubscriptionItems`, returns the entitlement
- - `POST /api/billing/portal` → `{url}`
-
-- [ ] **Step 1: Checkout options**
-
-Create `admin/internal/api/checkout.go`:
-
-```go
-package api
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
- "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
- "go.mongodb.org/mongo-driver/v2/bson"
-)
-
-// checkoutOptions serves everything the browser configurator needs to price a
-// plan: the six plans (base allowances), the full catalogue (component prices in
-// the running environment), and the environment name so the client can refuse a
-// mismatch. The client token itself is baked into the adminsite build, never
-// served from here.
-func checkoutOptions(c *gin.Context) {
- ctx := c.Request.Context()
- plans := []models.Plan{}
- if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
- _ = cur.All(ctx, &plans)
- }
- rows, err := models.AllCatalogue(ctx)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{
- "plans": plans,
- "catalogue": rows,
- "env": paddle.Get().Env(),
- })
-}
-```
-
-- [ ] **Step 2: Self-hosted placeholder creation**
-
-Append. Reuse the instance-id minting the shipped `linkInstance`/`createInstance` use — **read `customer.go` and match it exactly** rather than inventing a UUID scheme:
-
-```go
-// createSelfHostedPlaceholder makes an instance row that exists only so a
-// checkout has something to put in custom_data. It carries no licence and is
-// flagged Placeholder until the customer pastes their install's real UUID
-// (linkInstance, task 6). Status awaiting_link, deployment self_hosted.
-func createSelfHostedPlaceholder(c *gin.Context) {
- s := auth.Current(c)
- var body struct {
- Name string `json:"name"`
- }
- if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
- return
- }
- ctx := c.Request.Context()
- inst := models.Instance{
- InstanceID: newPlaceholderID(), // match the id scheme used elsewhere
- AccountID: s.AccountID,
- Name: body.Name,
- Deployment: license.DeploymentSelfHosted,
- Status: models.StatusAwaitingLink,
- Placeholder: true,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
- Target: inst.InstanceID, IP: c.ClientIP()})
- c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
-}
-```
-
-Define `newPlaceholderID()` to match the codebase's existing instance-id generator (find it in `customer.go`/`cloudprov`). Add imports `time`, `license`, `auth`, `audit`, `models`, `db`.
-
-- [ ] **Step 3: Entitlement update — admin's one outbound Paddle call**
-
-Append. This is the only place admin mutates a live subscription:
-
-```go
-// updateEntitlement sets an instance's DESIRED configuration and pushes the
-// resulting line items to Paddle. It does NOT issue — the resulting
-// subscription.updated webhook does, from granted. An increase is prorated
-// immediately by Paddle; a reduction is recorded as desired and takes effect at
-// renewal (promoteScheduledReduction), so this never shrinks a live licence.
-func updateEntitlement(c *gin.Context) {
- inst, ok := ownedInstance(c, c.Param("id"))
- if !ok {
- return
- }
- ctx := c.Request.Context()
- var body struct {
- Tier string `json:"tier"`
- Term string `json:"term"`
- Servers int `json:"servers"`
- Features []string `json:"features"`
- }
- if err := c.ShouldBindJSON(&body); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
- return
- }
-
- plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
- return
- }
- if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
- return
- }
-
- desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
- items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Find the live subscription to update. No subscription yet means this
- // instance has never been paid for — that is a checkout, not an update.
- var sub models.Subscription
- if err := db.Admin("subscriptions").FindOne(ctx,
- bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
- c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
- return
- }
-
- pItems := make([]paddle.LineItem, 0, len(items))
- for _, it := range items {
- pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
- }
- if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
- c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
- return
- }
-
- // Record desired now; the webhook that Paddle sends back promotes to granted
- // and reissues. Recording here makes the portal reflect the intent instantly
- // rather than waiting on the webhook round-trip.
- limits, _, _ := catalogue.Resolve(ctx, plan, desired)
- ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
- next := models.Entitlement{
- InstanceID: inst.InstanceID, AccountID: inst.AccountID,
- Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
- Desired: desired, ResolvedLimits: limits,
- }
- if ent != nil {
- next.Granted = ent.Granted
- next.GrantedAt = ent.GrantedAt
- if desired.Servers < ent.Granted.Servers {
- now := time.Now().UTC()
- next.ScheduledChangeAt = &now
- }
- } else {
- next.Granted = desired
- }
- if err := models.UpsertEntitlement(ctx, next); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- audit.Write(ctx, models.AuditEntry{
- Actor: auth.Current(c).Email, Action: "entitlement.requested",
- AccountID: inst.AccountID, Target: inst.InstanceID})
- c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
-}
-```
-
-- [ ] **Step 4: Billing portal**
-
-Append:
-
-```go
-// billingPortal mints a Paddle customer-portal URL. The account must already
-// have a paddle_customer_id, which it learns from its first subscription webhook.
-func billingPortal(c *gin.Context) {
- s := auth.Current(c)
- ctx := c.Request.Context()
- var acc models.Account
- if err := db.Admin("accounts").FindOne(ctx,
- bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
- return
- }
- if acc.PaddleCustomerID == "" {
- c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
- return
- }
- url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
- if err != nil {
- c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"url": url})
-}
-```
-
-- [ ] **Step 5: Routes**
-
-In `routes.go`, in the `cust` group:
-
-```go
- cust.GET("/checkout/options", checkoutOptions)
- cust.POST("/instances/self-hosted",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- createSelfHostedPlaceholder)
- cust.PUT("/instances/:id/entitlement",
- auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
- updateEntitlement)
- cust.POST("/billing/portal", billingPortal)
-```
-
-- [ ] **Step 6: Confirm**
-
-```bash
-GOWORK=off /tmp/gorun.sh admin go build ./...
-```
-
-Expected: no output.
-
-Against a scratch DB with a staff session, `GET /api/checkout/options` returns six plans and sixteen catalogue rows; `POST /api/instances/self-hosted` returns an `instance_id` and leaves a `placeholder:true` row:
-
-```bash
-curl -s -b /tmp/cj localhost:8083/api/checkout/options | python -m json.tool | head
-curl -s -b /tmp/cj -X POST localhost:8083/api/instances/self-hosted \
- -H 'Content-Type: application/json' -d '{"name":"box"}' | python -m json.tool
-```
-
-The Paddle-touching paths (`updateEntitlement`, `billingPortal`) are exercised in task 9's sandbox pass.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add admin/internal/api/checkout.go admin/internal/api/routes.go
-git commit -m "feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints"
-```
-
----
-
-### Task 6: The awaiting-link sweep, reminders, and placeholder claim
-
-**Files:**
-
-- Modify: `admin/internal/mail/mail.go`, `admin/internal/lifecycle/lifecycle.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/api/routes.go`, `admin/internal/billing/subscription.go` (wire `sendCancelled`/`sendPastDue`)
-
-**Interfaces:**
-
-- Produces: `mail.SendCancelled`, `mail.SendPastDue`, `mail.SendLinkReminder`; `staffBillingHealth`; `linkInstance` claiming a placeholder and issuing when a subscription exists.
-
-- [ ] **Step 1: Mail functions**
-
-In `admin/internal/mail/mail.go`, following the existing `Send*` style (subject + body, `send(to, ...)`):
-
-```go
-func SendCancelled(to, instanceName string) error {
- return send(to, "Your Vantage subscription is cancelled",
- "Your subscription for "+instanceName+" is cancelled. Your instance keeps "+
- "working until the current licence expires; after that, monitors keep "+
- "running but changes are disabled.")
-}
-
-func SendPastDue(to, instanceName string) error {
- return send(to, "Payment failed for your Vantage subscription",
- "A payment for "+instanceName+" failed. Your instance is unaffected while "+
- "the card is retried. Update your payment method from the billing portal.")
-}
-
-func SendLinkReminder(to, instanceName string) error {
- return send(to, "Finish setting up your Vantage instance",
- "Your subscription for "+instanceName+" is active, but the instance is not "+
- "linked yet. Paste your install's ID in the portal to receive your licence.")
-}
-```
-
-Wire the billing wrappers in `subscription.go`:
-
-```go
-func sendCancelled(to, instanceName string) error { return mail.SendCancelled(to, instanceName) }
-func sendPastDue(to, instanceName string) error { return mail.SendPastDue(to, instanceName) }
-```
-
-(Import `mail` in `subscription.go`. `instanceName` — pass the instance name, not the id; adjust the call sites in task 3 to look it up, or pass the id if the name is not to hand. Prefer the name.)
-
-- [ ] **Step 2: The awaiting-link sweep and reminders**
-
-In `admin/internal/lifecycle/lifecycle.go`, add a pass that finds placeholder/awaiting-link instances with a recorded active subscription and (a) backstops issuance if the webhook never linked, (b) sends 24h/72h reminders. Follow the existing sweep's shape and its `notices_sent` bookkeeping. Reminder keys `link_24` / `link_72`:
-
-```go
-// sweepAwaitingLink chases self-hosted instances that were paid for but never
-// linked. The subscription exists; the instance is still a placeholder. At 24h
-// and 72h it emails a reminder; the staff dashboard already flags 48h.
-func sweepAwaitingLink(ctx context.Context) {
- cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
- "deployment": license.DeploymentSelfHosted,
- "placeholder": true,
- "status": models.StatusAwaitingLink,
- })
- if err != nil {
- return
- }
- var instances []models.Instance
- if err := cur.All(ctx, &instances); err != nil {
- return
- }
- now := time.Now().UTC()
- for _, inst := range instances {
- age := now.Sub(inst.CreatedAt)
- to := billingEmailForAccount(ctx, inst.AccountID)
- if to == "" {
- continue
- }
- if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, "link_72") {
- _ = mail.SendLinkReminder(to, inst.Name)
- markNotice(ctx, inst.InstanceID, "link_72")
- } else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, "link_24") {
- _ = mail.SendLinkReminder(to, inst.Name)
- markNotice(ctx, inst.InstanceID, "link_24")
- }
- }
-}
-```
-
-Reuse or add `billingEmailForAccount` and `markNotice` helpers matching the file's existing patterns (the Free lifecycle sweep already reads the account email and records `notices_sent`; factor to one helper if duplicated). Call `sweepAwaitingLink` from the same ticker `lifecycle.Run` uses.
-
-- [ ] **Step 3: linkInstance claims a placeholder and issues**
-
-In `admin/internal/api/customer.go`'s `linkInstance`, after the placeholder's UUID is validated, if the instance being linked is a placeholder with a recorded active subscription, set the real `instance_id`, clear `placeholder`, flip to active, and issue from the subscription's resolved items. **Read the current `linkInstance` first and splice in** — it already creates/links; the addition is: when a subscription row exists for this placeholder, resolve its items and call the same `promoteAndIssue` path (expose a small exported `billing.IssueForInstance(ctx, instanceID) error` that loads the sub, resolves, promotes and issues, so the endpoint does not import catalogue directly).
-
-Add to `admin/internal/billing/subscription.go`:
-
-```go
-// IssueForInstance issues from an instance's recorded subscription. Called when
-// a self-hosted customer finally links a placeholder they have already paid for.
-func IssueForInstance(ctx context.Context, instanceID string) error {
- var sub models.Subscription
- if err := db.Admin("subscriptions").FindOne(ctx,
- bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
- return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
- }
- var inst models.Instance
- if err := db.Admin("admin_instances").FindOne(ctx,
- bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
- return err
- }
- items := make([]catalogue.Item, 0, len(sub.Items))
- for _, it := range sub.Items {
- items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
- }
- match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
- if err != nil {
- return err
- }
- return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
-}
-```
-
-- [ ] **Step 4: Staff billing health**
-
-In `admin/internal/api/staff.go`, add `staffBillingHealth` returning failed `paddle_events` (unprocessed with an error) and placeholder instances older than 48h, for the staff dashboard. Mount `staff.GET("/health/billing", staffBillingHealth)` in `routes.go`.
-
-```go
-func staffBillingHealth(c *gin.Context) {
- ctx := c.Request.Context()
- failed := []models.PaddleEvent{}
- if cur, err := db.Admin("paddle_events").Find(ctx,
- bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
- _ = cur.All(ctx, &failed)
- }
- c.JSON(http.StatusOK, gin.H{"failed_events": failed, "count": len(failed)})
-}
-```
-
-- [ ] **Step 5: Confirm**
-
-```bash
-GOWORK=off /tmp/gorun.sh admin go build ./...
-GOWORK=off /tmp/gorun.sh admin go vet ./...
-```
-
-Expected: no output.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add admin/internal/mail admin/internal/lifecycle admin/internal/api admin/internal/billing
-git commit -m "feat(admin): awaiting-link reminders, placeholder claim-and-issue, and billing health"
-```
-
----
-
-### Task 7: The customer purchase and upgrade UI
-
-**Files:**
-
-- Create: `adminsite/lib/paddle.ts`, `adminsite/components/CheckoutButton.tsx`, `adminsite/app/(customer)/purchase/page.tsx`, `adminsite/app/(customer)/purchase/PurchaseForm.tsx`
-- Modify: `adminsite/package.json`, `adminsite/lib/api.ts`, `adminsite/components/InstanceRecord.tsx`
-
-**Interfaces:**
-
-- Consumes: shipped `PlanConfigurator`/`PlanChoice`, `api.staff.plans` shape (reuse types), new `api.checkoutOptions`, `api.createSelfHosted`, `api.updateEntitlement`, `api.billingPortal`.
-- Produces: `initPaddle()`, ``.
-
-- [ ] **Step 1: Add the dependency and the loader**
-
-```bash
-sh /tmp/npmrun.sh adminsite npm install @paddle/paddle-js --no-audit --no-fund
-```
-
-Create `adminsite/lib/paddle.ts`:
-
-```ts
-import { initializePaddle, type Paddle } from "@paddle/paddle-js";
-
-let cached: Promise | null = null;
-
-/* One Paddle instance for the app. The token and environment are baked into the
- * build (NEXT_PUBLIC_*), never fetched, so a production build can never load a
- * sandbox token by accident. */
-export function initPaddle(): Promise {
- if (!cached) {
- cached = initializePaddle({
- environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
- token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
- });
- }
- return cached;
-}
-```
-
-- [ ] **Step 2: The checkout button**
-
-Create `adminsite/components/CheckoutButton.tsx`:
-
-```tsx
-"use client";
-
-import { useState } from "react";
-import { initPaddle } from "@/lib/paddle";
-
-/* Opens the Paddle overlay with the resolved line items and custom_data. The
- * items come from the configurator via catalogue pricing; custom_data is what
- * lets the webhook route without a lookup table. */
-export function CheckoutButton({
- items,
- customData,
- disabled,
- label = "Continue to payment",
-}: {
- items: { priceId: string; quantity: number }[];
- customData: { account_id: string; instance_id: string };
- disabled?: boolean;
- label?: string;
-}) {
- const [busy, setBusy] = useState(false);
- async function open() {
- setBusy(true);
- const paddle = await initPaddle();
- setBusy(false);
- paddle?.Checkout.open({
- items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
- customData,
- });
- }
- return (
-
- );
-}
-```
-
-- [ ] **Step 3: API client additions**
-
-In `adminsite/lib/api.ts`, add types and calls (reuse shipped `Plan`, `CatalogueRow`, `Entitlement`, `PlanChoice`-equivalent). Add a client-side line-item builder mirroring `catalogue.LineItems` so the button has items without a round-trip, OR add a `GET`-backed resolver — prefer computing client-side from the catalogue already fetched:
-
-```ts
-export interface CheckoutOptions {
- plans: Plan[];
- catalogue: CatalogueRow[];
- env: "sandbox" | "production";
-}
-
-// on `api`:
- checkoutOptions: () => req("/api/checkout/options"),
- createSelfHosted: (name: string) =>
- post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
- updateEntitlement: (
- id: string,
- body: { tier: Tier; term: Term; servers: number; features: string[] },
- ) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
- billingPortal: () => post<{ url: string }>("/api/billing/portal"),
-```
-
-Add a helper that turns a `PlanChoice` + catalogue into `{priceId, quantity}[]` for the running env, replicating `catalogue.LineItems`' base-included subtraction (base qty 1; per-server qty = servers − base; feature items only when the row has a price). Keep the subtraction in exactly one TS function, commented to point at the Go `billable`.
-
-- [ ] **Step 4: The self-hosted purchase page**
-
-Create `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx`: name field → `createSelfHosted` → mount `PlanConfigurator` (deployment `self_hosted`) → `CheckoutButton` with computed items and `custom_data {account_id, instance_id}`. After checkout, instruct the customer to paste their install UUID on the instance page (the existing link flow, now claim-and-issue). Match the shell (`PageHeader`/`PageFrame`) of the other customer pages.
-
-- [ ] **Step 5: Cloud upgrade entry on InstanceRecord**
-
-In `adminsite/components/InstanceRecord.tsx`, for a cloud instance add a "Change plan" control that opens `PlanConfigurator` and either `updateEntitlement` (when an active subscription exists) or `CheckoutButton` (first purchase). Show a pending-reduction line when `entitlement.scheduled_change_at` is set, with the date. A "Manage billing" button calls `billingPortal` and opens the returned URL. Keep the cancellation wording panel here (there is no cancellation screen of ours).
-
-- [ ] **Step 6: Dockerfile build args**
-
-In `adminsite/Dockerfile`, add build args and env for `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` and `NEXT_PUBLIC_PADDLE_ENV`, matching how `NEXT_PUBLIC_ADMIN_API_URL` is already threaded.
-
-- [ ] **Step 7: Confirm build and no hex**
-
-```bash
-sh /tmp/npmrun.sh adminsite npm run build
-grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/CheckoutButton.tsx adminsite/app/\(customer\)/purchase/
-grep -rn "pri_\|sandbox\|production" adminsite/ --include=*.tsx --include=*.ts | grep -v "NEXT_PUBLIC\|process.env\|\"sandbox\"\|\"production\"" | grep -v "pri_…"
-```
-
-Expected: successful build; no hex; no hard-coded price IDs.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add adminsite
-git commit -m "feat(adminsite): self-hosted purchase, cloud upgrade, checkout overlay and billing portal"
-```
-
----
-
-### Task 8: Deployment configuration and docs
-
-**Files:**
-
-- Modify: `deploy/docker-compose.site.yml`, `CLAUDE.md`, `docs/superpowers/specs/README.md`
-
-- [ ] **Step 1: Compose env**
-
-In `deploy/docker-compose.site.yml`, add to the `admin` service: `PADDLE_ENV`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`. Add the two `NEXT_PUBLIC_PADDLE_*` build args to the `adminsite` service build. Note in a comment that `PADDLE_WEBHOOK_SECRET` is boot-required.
-
-- [ ] **Step 2: CLAUDE.md**
-
-Add `paddle_events` to admin's collection list. Add `POST /api/paddle/webhook` (unauthenticated, signature-verified) to the admin route table, and the four customer routes + `GET /api/staff/health/billing`. Add a short "Billing (Paddle)" paragraph: Free is outside Paddle; paid plans check out in the browser; the webhook is the only issuer path and is idempotent via `paddle_events`; cancel/past-due never touch a licence; the entitlement is promoted `desired`→`granted` only by a confirmed webhook. Add the three `PADDLE_*` vars and the two `NEXT_PUBLIC_PADDLE_*` build args to the env tables.
-
-- [ ] **Step 3: Spec index**
-
-Mark spec 5 shipped (code) in `docs/superpowers/specs/README.md`, noting sandbox verification is the operator's step.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add deploy/docker-compose.site.yml CLAUDE.md docs/superpowers/specs/README.md
-git commit -m "docs+deploy: Paddle env, webhook route, collections, and spec status"
-```
-
----
-
-### Task 9: Sandbox catalog and the end-to-end pass (operator-run, deferred)
-
-This task **cannot be automated here** — it needs a real Paddle sandbox, an API key, a webhook secret, and a public webhook URL. It is the operator's, documented so it can be followed exactly.
-
-- [ ] **Step 1: Create the sandbox catalog.** Eight products — a base and an additional-server product for each of the four paid plans — giving twelve prices (cloud monthly+annual, self-hosted annual only), plus feature prices where a feature is to be charged. Free is not a product.
-- [ ] **Step 2: Paste the sandbox price IDs** into the staff catalogue screen (shipped). Confirm a self-hosted monthly cell is absent and refused.
-- [ ] **Step 3: Set `PADDLE_ENV=sandbox`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`** on admin; register the webhook URL in Paddle pointing at `/api/paddle/webhook`.
-- [ ] **Step 4: Cloud purchase** with a test card. Confirm `subscription.created`/`transaction.completed` issue a licence, the instance reports `valid`, and `max_servers`/features match the configuration.
-- [ ] **Step 5: Server increase** on an active subscription via the portal → `updateEntitlement` → confirm the reissued licence grows.
-- [ ] **Step 6: Scheduled reduction** → confirm `granted` unchanged until a triggered sandbox renewal, which then collapses it. Confirm expiry is period-end + 3 days.
-- [ ] **Step 7: Self-hosted purchase** → placeholder `awaiting_link`, no licence → link the UUID → licence issued and emailed.
-- [ ] **Step 8: Cancel** → licence keeps working to expiry, then the instance degrades (monitors still running).
-- [ ] **Step 9: Replay every event** from Paddle's dashboard → no duplicate licences (idempotency).
-- [ ] **Step 10: Bad signature** → 401, nothing processed.
-
----
-
-## Done when
-
-- The webhook verifies signatures, is idempotent via `paddle_events`, and dispatches every documented event type.
-- A confirmed subscription webhook promotes `desired`→`granted` and issues from `granted`; `grep` finds no `.Desired` read and no earlier-expiry write in `admin/internal/billing/`.
-- Cancel and past-due change only `status`; the licence is untouched.
-- A renewal issues the next term with `ReasonRenewal` and collapses any scheduled reduction.
-- `GET /api/checkout/options` serves six plans and the catalogue; the self-hosted placeholder creates an `awaiting_link` row with no licence.
-- All Paddle SDK usage lives only in `admin/internal/paddle/sdk.go`.
-- `admin`, `adminsite` build clean; adminsite carries no hex and no hard-coded price IDs.
-- The sandbox end-to-end pass (task 9) is the operator's and is not claimed complete by this plan.
-
-## Not in this plan
-
-Discounts, coupons, our own proration arithmetic, invoice/tax display, and anything that revokes or shortens a licence. Metered pricing and self-service downgrade ARE in scope (spec 7 put them there) and are implemented above.
diff --git a/docs/superpowers/plans/2026-07-27-web-mobile-responsive.md b/docs/superpowers/plans/2026-07-27-web-mobile-responsive.md
deleted file mode 100644
index 0095bcf..0000000
--- a/docs/superpowers/plans/2026-07-27-web-mobile-responsive.md
+++ /dev/null
@@ -1,928 +0,0 @@
-# Control plane mobile responsiveness — 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:** Make `web/` (the Vantage control plane UI) usable on a phone — the sidebar becomes a hamburger-driven offcanvas below 1024px, tables become card stacks below 640px, and every fixed desktop layout collapses.
-
-**Architecture:** A new client component `AppShell` owns the responsive chrome so `app/(app)/layout.tsx` stays a server component. `Sidebar.tsx` splits into a shared `SidebarContent` plus two containers (permanent aside, offcanvas drawer) so the nav exists in exactly one copy. The table card-stack lives in the `ui/Table.tsx` primitives via Tailwind `max-sm:` variants, so pages keep one markup tree and opt in with a `label` prop per cell.
-
-**Tech Stack:** Next.js 16 (App Router), React 18, Tailwind 3.4, `clsx`. **No new dependencies.**
-
-## Global Constraints
-
-- **Scope is `web/` only.** Do not touch `site/`, `adminsite/`, `server/`, `admin/` or any Go code.
-- **No hex colours anywhere.** Tailwind maps `var(--…)` tokens only. Use `bg-surface`, `border-border`, `text-text-secondary` etc. A literal `#` in a class is a defect. (`bg-black/60` is the one existing exception, already used by `Modal.tsx` for its backdrop — reuse it, do not introduce others.)
-- **Breakpoints:** sidebar collapses below `lg` (1024px). Tables card-stack below `sm` (640px). Do not invent other breakpoints.
-- **No new dependencies.** No headless-ui, no framer-motion.
-- **Presentation only.** No API, route, query-key or data-shape changes.
-- **Radius:** `rounded`, `rounded-lg`, `rounded-md` and `rounded-xl` all resolve to 4–6px via `tailwind.config.ts`. Prefer `rounded` in new code.
-- Use `dvh`, not `vh`, for any new viewport-height value — mobile browser chrome makes `vh` overshoot.
-- Indentation follows the file you are editing. `web/` is mixed: some files use 4 spaces (`Sidebar.tsx`, `keys/page.tsx`), others 2 (`servers/page.tsx`, `ui/*`). Match the file, do not reformat it.
-- **There is no test framework in this repo.** No jest, no vitest, no playwright. Verification is `npx next lint`, `npx next build`, and targeted `grep` audits. Do not add a test framework.
-- Run all commands from `d:\Development\Websites\vantage\web`.
-
----
-
-### Task 1: Responsive table primitives
-
-The card stack goes in the primitives, not the pages. Six pages render tables; giving each one a second markup tree would double the markup and drift on the first edit.
-
-**Files:**
-- Modify: `web/components/ui/Table.tsx` (whole file)
-
-**Interfaces:**
-- Consumes: nothing.
-- Produces: `Td` gains an optional prop `label?: string`. Below `sm`, a `Td` with a `label` renders `{label}` before its children; a `Td` without one renders children alone, right-aligned. `Table`, `Thead`, `Tbody`, `Tr`, `Th` keep their existing signatures. Task 4 consumes `label`.
-
-- [ ] **Step 1: Rewrite `web/components/ui/Table.tsx`**
-
-Replace the entire file with:
-
-```tsx
-import { clsx } from "clsx";
-import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
-
-/*
- * Below sm the table stops being a table: the head is hidden, each row becomes
- * a bordered card and each cell becomes a label/value pair. That lives here
- * rather than in the six pages that render tables — two markup trees per page
- * would drift apart on the first edit, and every one of those trees would mean
- * the same thing.
- *
- * The mobile label uses Th's exact keyed-label idiom (mono, small, widely
- * tracked, dimmed) because a key beside a value on a phone is the same device
- * as a column head above it on a desktop.
- */
-
-export function Table({ className, children, ...props }: HTMLAttributes) {
- return (
-
- );
-}
-
-interface TdProps extends TdHTMLAttributes {
- /**
- * The column head this cell belongs to, shown beside the value below sm
- * where the real head is hidden. Omit on a trailing action cell — an action
- * needs no key, and the button then sits alone on its own row in the card.
- */
- label?: string;
-}
-
-export function Td({ className, label, children, ...props }: TdProps) {
- return (
-
- {label && (
-
- {label}
-
- )}
- {children}
-
- );
-}
-```
-
-- [ ] **Step 2: Verify it compiles and lints**
-
-```bash
-npx tsc --noEmit
-npx next lint
-```
-
-Expected: both clean. `tsc` may take ~30s. If `tsc --noEmit` errors on pre-existing issues unrelated to `Table.tsx`, note them and move on — only new errors matter.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add web/components/ui/Table.tsx
-git commit -m "feat(web): card-stack tables below sm"
-```
-
----
-
-### Task 2: Offcanvas sidebar
-
-**Files:**
-- Modify: `web/components/Sidebar.tsx` (whole file)
-- Create: `web/components/AppShell.tsx`
-- Modify: `web/app/(app)/layout.tsx` (whole file)
-
-**Interfaces:**
-- Consumes: `useAuth()` from `@/components/AuthProvider` returning `{ user, instance, isAdmin }`; `auth.logout()` from `@/lib/api`; `Logo` from `@/components/Logo`.
-- Produces:
- - `Sidebar.tsx` exports `SidebarContent({ onNavigate }: { onNavigate?: () => void })`, `Sidebar()` (permanent aside) and `SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void })`.
- - `AppShell.tsx` exports `AppShell({ children }: { children: React.ReactNode })`.
- - No later task depends on these names.
-
-- [ ] **Step 1: Rewrite `web/components/Sidebar.tsx`**
-
-Keep every icon component and the `navItems` array **exactly as they are** — do not retype the SVG path data, it is long and easy to corrupt. Change only from `export function Sidebar()` (line 135) to the end of the file, replacing it with the following. The file uses 4-space indentation.
-
-```tsx
-/** Shared by the permanent aside and the offcanvas drawer — one copy of the nav. */
-export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
- const pathname = usePathname();
- const { user, instance, isAdmin } = useAuth();
-
- const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
-
- const activeHref = visibleItems.reduce((best, item) => {
- const matches = pathname === item.href || pathname.startsWith(item.href + "/");
- if (!matches) return best;
- return best === null || item.href.length > best.length ? item.href : best;
- }, null);
-
- async function handleLogout() {
- try {
- await auth.logout();
- } catch {}
- window.location.href = "/login";
- }
-
- return (
- <>
-
- >
- );
-}
-
-/** The permanent sidebar. Below lg the drawer takes over. */
-export function Sidebar() {
- return (
-
- );
-}
-
-/**
- * The offcanvas below lg. Always mounted so the slide runs in both directions;
- * closed it is inert (invisible + pointer-events-none) rather than unmounted.
- */
-export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
- const panelRef = useRef(null);
-
- useEffect(() => {
- if (!open) return;
-
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", onKey);
-
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
-
- panelRef.current?.focus();
-
- return () => {
- window.removeEventListener("keydown", onKey);
- document.body.style.overflow = previousOverflow;
- };
- }, [open, onClose]);
-
- return (
-
-
-
-
-
-
- );
-}
-```
-
-Then update the import line at the top of the file (currently line 4) so `useEffect` and `useRef` are available:
-
-```tsx
-import { usePathname } from "next/navigation";
-import { useEffect, useRef } from "react";
-```
-
-- [ ] **Step 2: Create `web/components/AppShell.tsx`**
-
-```tsx
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { usePathname } from "next/navigation";
-import { LicenseBanner } from "@/components/LicenseBanner";
-import { Logo } from "@/components/Logo";
-import { Sidebar, SidebarDrawer } from "@/components/Sidebar";
-import { useAuth } from "@/components/AuthProvider";
-
-function MenuIcon() {
- return (
-
- );
-}
-
-/**
- * Owns the responsive chrome so app/(app)/layout.tsx can stay a server
- * component. Above lg this is the layout it always was; below lg the sidebar
- * becomes an offcanvas behind the top bar's hamburger.
- */
-export function AppShell({ children }: { children: React.ReactNode }) {
- const [open, setOpen] = useState(false);
- const pathname = usePathname();
- const buttonRef = useRef(null);
- const { instance } = useAuth();
-
- // A drawer that survives navigation would cover the page you just asked for.
- useEffect(() => {
- setOpen(false);
- }, [pathname]);
-
- function close() {
- setOpen(false);
- buttonRef.current?.focus();
- }
-
- return (
-
- );
-}
-```
-
-- [ ] **Step 3: Rewrite `web/app/(app)/layout.tsx`**
-
-```tsx
-import { AuthProvider } from "@/components/AuthProvider";
-import { AppShell } from "@/components/AppShell";
-
-export default function AppLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
-```
-
-`LicenseBanner` and `Sidebar` are no longer imported here — `AppShell` renders both.
-
-- [ ] **Step 4: Verify**
-
-```bash
-npx tsc --noEmit
-npx next lint
-npx next build
-```
-
-Expected: all three succeed. `next build` is the one that matters — it catches a client component imported into a server component boundary.
-
-- [ ] **Step 5: Sanity-check the scroll container**
-
-Read `web/app/(app)/servers/[id]/console/page.tsx` around line 153 and 168. It uses `h-full`, which now resolves against `` rather than the old ``. Confirm the console page still has a height to fill; if `h-full` no longer resolves, change those two wrappers to `flex-1` instead. Task 7 revisits this file, so a note is acceptable here if you prefer to fix it there — but write the note down.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add web/components/Sidebar.tsx web/components/AppShell.tsx "web/app/(app)/layout.tsx"
-git commit -m "feat(web): offcanvas sidebar with hamburger below lg"
-```
-
----
-
-### Task 3: Page padding and header rows
-
-**Files:**
-- Modify: all 21 files under `web/app` and `web/components` containing `p-8`
-- Modify: the title-plus-action header rows listed below
-
-**Interfaces:**
-- Consumes: nothing. Produces: nothing. Pure class edits.
-
-- [ ] **Step 1: List every occurrence**
-
-```bash
-cd web && grep -rn "p-8" app components
-```
-
-Expected: 30 occurrences across 21 files.
-
-- [ ] **Step 2: Replace each page-level `p-8` with `p-4 sm:p-6 lg:p-8`**
-
-Apply to every occurrence **except** these two, which Task 6 and Task 7 handle and which need different values:
-
-- `app/(app)/workflows/[id]/page.tsx:331` (the canvas ``) — leave for Task 6.
-- `app/(app)/servers/[id]/console/page.tsx:168` — leave for Task 7.
-
-The inline loading states (`
Loading…
`) get the same treatment: `className="p-4 text-text-secondary sm:p-6 lg:p-8"`.
-
-Do this file by file with `Edit`. A blind `sed` would also hit `p-8` inside strings or unrelated contexts — check each match.
-
-- [ ] **Step 3: Make title-plus-action header rows stack**
-
-In each of these, change `className="mb-6 flex items-center justify-between"` to
-`className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"`:
-
-- `app/(app)/servers/page.tsx:75`
-- `app/(app)/keys/page.tsx:112`
-- `app/(app)/monitors/page.tsx:35`
-- `app/(app)/workflows/page.tsx:32`
-- `app/(app)/secrets/page.tsx:105`
-- `app/(app)/secrets/[group]/page.tsx:251`
-- `app/(app)/settings/notifications/page.tsx:153`
-
-Leave `flex items-center justify-between` rows that are *inside* a card header or a table cell — those hold two small items and are fine at 390px. Only the page-top title/action rows change.
-
-- [ ] **Step 4: Verify no unprefixed `p-8` survives**
-
-```bash
-cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
-```
-
-Expected: exactly two lines — the two deferred to Tasks 6 and 7.
-
-- [ ] **Step 5: Verify**
-
-```bash
-npx next lint && npx next build
-```
-
-Expected: both succeed.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add web/app web/components
-git commit -m "feat(web): responsive page padding and stacking page headers"
-```
-
----
-
-### Task 4: Label every table cell
-
-**Files:**
-- Modify: `web/app/(app)/servers/page.tsx:116-145`
-- Modify: `web/app/(app)/keys/page.tsx:149-169`
-- Modify: `web/app/(app)/monitors/page.tsx:79-98`
-- Modify: `web/app/(app)/secrets/page.tsx:142-157`
-- Modify: `web/app/(app)/secrets/[group]/page.tsx:141-150`
-- Modify: `web/app/(app)/workflows/page.tsx:73-86`
-- Modify: `web/app/(app)/workflows/[id]/runs/page.tsx:54-68`
-- Modify: `web/app/(app)/audit/page.tsx:80-93`
-- Modify: `web/app/(app)/keys/[id]/page.tsx:388-420`
-- Modify: `web/app/(app)/servers/[id]/page.tsx:272-280` and `:588-605`
-- Modify: `web/app/(app)/monitors/[id]/page.tsx:183-195`
-- Modify: `web/components/settings/MembersCard.tsx:118-145`
-
-**Interfaces:**
-- Consumes: `Td`'s `label?: string` prop from Task 1.
-- Produces: nothing.
-
-- [ ] **Step 1: Add `label` to each `Td`, matching its `Th`**
-
-For every table, the Nth `
` in a `
` takes the text of the Nth `
`. Where the `Th` is empty (`
` — the trailing action column), the matching `Td` gets **no** `label`.
-
-The mapping, `Th` order per file:
-
-| File | Column labels, in order |
-| --- | --- |
-| `servers/page.tsx` | Hostname · IP Address · OS · Status · Last Seen · *(none)* |
-| `keys/page.tsx` | Label · Fingerprint · Source · Assignments · Created · *(none)* |
-| `monitors/page.tsx` | Name · Type · Target · Status · Latency · Last check |
-| `secrets/page.tsx` | Group · Keys · Last Updated · *(none)* |
-| `secrets/[group]/page.tsx` | Key · Value · Updated · *(none)* |
-| `workflows/page.tsx` | Name · Targets · Steps · *(none)* |
-| `workflows/[id]/runs/page.tsx` | Run · Status · Started · By · Servers |
-| `audit/page.tsx` | Time · Event · Actor · Details |
-| `keys/[id]/page.tsx` | Server · IP Address · Status · Assigned · Revoked · *(none)* |
-| `servers/[id]/page.tsx` (updates table) | Package · Current · Available |
-| `servers/[id]/page.tsx` (keys table) | Label · Fingerprint · Source · Status · Assigned · *(none)* |
-| `monitors/[id]/page.tsx` | Started · Resolved · Cause |
-| `MembersCard.tsx` | Email · Role · Sign-in · Last login · Actions |
-
-Worked example — `servers/page.tsx` lines 116–145 become:
-
-```tsx
-
-```
-
-Note the last `Td` is unchanged — no `label`, so the "View →" button sits alone on its own row at the bottom of the card.
-
-Second worked example — `MembersCard.tsx` line 142–143, where `Td` already carries a `className`. Both props coexist:
-
-```tsx
-
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
-
-```
-
-`MembersCard`'s last column has a real `Th` ("Actions"), so unlike the others it **does** take a label.
-
-- [ ] **Step 2: Verify no `Td` was missed**
-
-```bash
-cd web && grep -rn "
-
-
-```
-
-The `max-w-*` gains an `sm:` prefix so the sheet is full-width on a phone. `dvh` rather than `vh` because mobile browser chrome makes `vh` overshoot.
-
-- [ ] **Step 2: Collapse the grids in `MonitorForm.tsx`**
-
-- Line 76: `grid grid-cols-4 gap-2` → `grid grid-cols-2 gap-2 sm:grid-cols-4`
-- Lines 98, 123, 144: `grid grid-cols-2 gap-4` → `grid grid-cols-1 gap-4 sm:grid-cols-2`
-
-- [ ] **Step 3: Collapse the grids in `StepPickerModal.tsx`**
-
-Lines 132 and 168: `grid grid-cols-2 gap-2.5` → `grid grid-cols-1 gap-2.5 sm:grid-cols-2`
-
-- [ ] **Step 4: Let `CardHeader` wrap**
-
-`web/components/ui/Card.tsx` line 27: `"mb-4 flex items-center justify-between"` → `"mb-4 flex flex-wrap items-center justify-between gap-2"`. Card headers hold a title and an action; at 390px they need to be allowed to wrap rather than crush the title.
-
-- [ ] **Step 5: Verify**
-
-```bash
-npx next lint && npx next build
-```
-
-Expected: both succeed.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add web/components/ui/Modal.tsx web/components/ui/Card.tsx web/components/monitors/MonitorForm.tsx web/components/workflows/StepPickerModal.tsx
-git commit -m "feat(web): bottom-sheet modals and collapsing component grids"
-```
-
----
-
-### Task 6: Workflow builder
-
-**Files:**
-- Modify: `web/app/(app)/workflows/[id]/page.tsx:305-324` (header), `:329` (grid), `:331` (canvas), `:340` (column), `:372` (node), `:403` (inspector)
-
-**Interfaces:**
-- Consumes: nothing. Produces: nothing.
-
-Below `lg` the fixed-height two-column grid is dropped entirely: single column, natural page flow. The `100dvh` arithmetic only makes sense at `lg`, where there is no mobile top bar above it.
-
-- [ ] **Step 1: Let the header wrap (line 305)**
-
-```tsx
-
-```
-
-and on line 312 change `className="ml-auto flex items-center gap-2"` to
-`className="ml-auto flex flex-wrap items-center gap-2"`.
-
-- [ ] **Step 2: Make the shell single-column below lg (line 329)**
-
-```tsx
-
-```
-
-`h-[calc(100vh-53px)]` becomes `lg:h-[calc(100dvh-53px)]` — `lg:` because the mobile top bar changes the arithmetic, and `dvh` because `vh` overshoots on mobile.
-
-- [ ] **Step 3: Canvas padding (line 331)**
-
-```tsx
-
-```
-
-- [ ] **Step 4: Let the node column and nodes be fluid (lines 340 and 372)**
-
-Line 340:
-
-```tsx
-
-```
-
-Line 372 — the node itself. The wrapping `
` on line 349 already constrains it, so the node just fills:
-
-```tsx
- className={`w-full cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`}
-```
-
-- [ ] **Step 5: Turn the inspector into a bottom panel below lg (line 403)**
-
-```tsx
-