diff --git a/docs/superpowers/plans/2026-07-24-admin-backend.md b/docs/superpowers/plans/2026-07-24-admin-backend.md new file mode 100644 index 0000000..88fa9ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-admin-backend.md @@ -0,0 +1,3207 @@ +# 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.** Task 12's backfill is not a tidy-up; it is what restores those instances to working order, and it must run before admin is considered live. + +--- + +## 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 and licence backfill; no HTTP surface for either | +| `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)` + +- [ ] **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 github.com/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 + github.com/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 github.com/mrhid6/vantage/shared => ../shared +``` + +Add the module to `go.work`: + +``` +go 1.26.4 + +use ( + ./admin + ./server + ./shared + ./sitesvc +) +``` + +- [ ] **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 +} +``` + +- [ ] **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" + + "github.com/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) +} +``` + +- [ ] **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" + "github.com/mrhid6/vantage/admin/internal/config" + "github.com/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) +} +``` + +- [ ] **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 +``` + +- [ ] **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. + +- [ ] **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 12.) + +- [ ] **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` + +- [ ] **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" + + "github.com/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"` +} +``` + +- [ ] **Step 2: Write the plan seed** + +Create `admin/internal/models/plans.go`: + +```go +package models + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/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() } +``` + +- [ ] **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"`. + +- [ ] **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 `"github.com/mrhid6/vantage/admin/internal/models"`. + +- [ ] **Step 5: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **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)` + +- [ ] **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" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/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) + } +} +``` + +- [ ] **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" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/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 +} +``` + +- [ ] **Step 3: Wire the signing key at boot** + +In `admin/cmd/main.go`, after config loads: + +```go + licensing.SetSigningKey(cfg.SigningKey) +``` + +Import `"github.com/mrhid6/vantage/admin/internal/licensing"`. + +- [ ] **Step 4: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **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)` + +- [ ] **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" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + sharedmodels "github.com/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) + } + } + } + }() +} +``` + +- [ ] **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 `"github.com/mrhid6/vantage/admin/internal/inject"`. + +- [ ] **Step 3: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **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. + +- [ ] **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` + +- [ ] **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, + }) +} +``` + +- [ ] **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() + } +} +``` + +- [ ] **Step 3: Write staff login** + +Create `admin/internal/auth/staff.go`: + +```go +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/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}) +} +``` + +- [ ] **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() +} +``` + +- [ ] **Step 5: Write adminctl staff-add** + +Create `admin/cmd/adminctl/main.go`: + +```go +// Command adminctl performs the operations that deliberately have no HTTP +// surface: creating staff users, and backfilling licences issued by hand. +// +// adminctl staff-add --email=you@example.com --name="You" --password=... +// adminctl backfill --from=blobs.json +// +// 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" + "github.com/mrhid6/vantage/admin/internal/config" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/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 | backfill") + 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:]) + case "backfill": + backfill(ctx, cfg, os.Args[2:]) + default: + fmt.Fprintln(os.Stderr, "usage: adminctl staff-add | backfill") + 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) +} + +// backfill is implemented in Task 12. +func backfill(ctx context.Context, cfg config.Config, args []string) { + _ = ctx + _ = cfg + _ = args + _ = bson.M{} + fatal("backfill is implemented in Task 12") +} + +func fatal(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} +``` + +- [ ] **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() +``` + +- [ ] **Step 7: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **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` + +- [ ] **Step 1: Write it** + +Create `admin/internal/auth/cloud.go`: + +```go +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + adminmodels "github.com/mrhid6/vantage/admin/internal/models" + sharedmodels "github.com/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. + +- [ ] **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.) + +- [ ] **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` + +- [ ] **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)) +} +``` + +- [ ] **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" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/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}) +} +``` + +- [ ] **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") + } +``` + +- [ ] **Step 4: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **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 + +- [ ] **Step 1: Write linking and relink** + +Create `admin/internal/licensing/link.go`: + +```go +package licensing + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/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, + }) +} +``` + +- [ ] **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" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/inject" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/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) + } +} +``` + +- [ ] **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" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/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/: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() + } +} +``` + +- [ ] **Step 4: Mount it** + +In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with: + +```go + Handler: api.Routes(cfg), +``` + +Import `"github.com/mrhid6/vantage/admin/internal/api"`. + +- [ ] **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. + +- [ ] **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` + +- [ ] **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" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "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) +} + +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)}) +} +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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` + +- [ ] **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. + +- [ ] **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. + +- [ ] **Step 3: Confirm the self-hosted deployment is untouched** + +```bash +grep -c "admin" deploy/docker-compose.yml +``` + +Expected: `0`. + +- [ ] **Step 4: Commit** + +```bash +git add deploy/ .gitea/ +git commit -m "feat(admin): compose service and image build" +``` + +--- + +### Task 11: Backfill + +Existing cloud instances are read-only right now — plan 2 shipped without grandfathering. This is what fixes them, and it must run before admin is considered live. + +**Files:** +- Modify: `admin/cmd/adminctl/main.go` + +**Interfaces:** +- Consumes: `licensing.Issue`, `inject.Cloud`, `db.Control` +- Produces: `adminctl backfill` + +- [ ] **Step 1: Replace the backfill stub** + +In `admin/cmd/adminctl/main.go`, replace the stub with: + +```go +// backfill creates an account, an admin instance row and a licence for every +// cloud instance in the control plane that does not already have one, then +// injects it. +// +// This is not a tidy-up. Plan 2 shipped without grandfathering, so every +// existing cloud instance is read-only until this runs. +// +// It is idempotent: instances that already have an admin row are skipped. +func backfill(ctx context.Context, cfg config.Config, args []string) { + fs := flag.NewFlagSet("backfill", flag.ExitOnError) + tier := fs.String("tier", "professional", "tier to issue") + term := fs.String("term", "annual", "monthly or annual") + apply := fs.Bool("apply", false, "actually write; without it, only report") + fs.Parse(args) + + licensing.SetSigningKey(cfg.SigningKey) + + cur, err := db.Control("instances").Find(ctx, bson.M{}) + if err != nil { + fatal("list control instances: %v", err) + } + var remote []sharedmodels.Instance + if err := cur.All(ctx, &remote); err != nil { + fatal("decode control instances: %v", err) + } + + for _, r := range remote { + n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": r.InstanceID}) + if err != nil { + fatal("check instance %s: %v", r.InstanceID, err) + } + if n > 0 { + fmt.Printf("skip %s (%s) — already known\n", r.InstanceID, r.Slug) + continue + } + + if !*apply { + fmt.Printf("would %s (%s) — create account + %s licence\n", r.InstanceID, r.Slug, *tier) + continue + } + + acct := models.Account{ + AccountID: uuid.NewString(), + Name: r.Name, + BillingEmail: "", + Status: models.AccountActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil { + fatal("create account for %s: %v", r.InstanceID, err) + } + + inst := models.Instance{ + InstanceID: r.InstanceID, + AccountID: acct.AccountID, + Name: r.Name, + Slug: r.Slug, + Deployment: license.DeploymentCloud, + Status: models.StatusActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { + fatal("create instance row for %s: %v", r.InstanceID, err) + } + + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: r.InstanceID, + Tier: *tier, + Term: *term, + Reason: models.ReasonManual, + IssuedBy: "backfill", + }) + if err != nil { + fatal("issue for %s: %v", r.InstanceID, err) + } + if err := inject.Cloud(ctx, lic); err != nil { + fatal("inject for %s: %v", r.InstanceID, err) + } + fmt.Printf("done %s (%s) — %s licence %s\n", r.InstanceID, r.Slug, lic.Tier, lic.LicenseID) + } + + if !*apply { + fmt.Println("\ndry run — nothing written. Re-run with --apply.") + } +} +``` + +Add to that file's imports: `"github.com/mrhid6/vantage/admin/internal/inject"`, `"github.com/mrhid6/vantage/admin/internal/licensing"`, `"github.com/mrhid6/vantage/shared/license"`, `sharedmodels "github.com/mrhid6/vantage/shared/models"`. + +The default is a **dry run**. A tool that writes to production the first time someone types its name is a tool that gets typed by accident. + +- [ ] **Step 2: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add admin/ +git commit -m "feat(admin): backfill existing cloud instances" +``` + +--- + +### Task 12: Full verification + +Everything in containers. With no test suite this is the only evidence. + +**Files:** none + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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`. + +- [ ] **Step 5: Backfill the cloud instance** + +```bash +docker exec vadmin adminctl backfill # dry run +docker exec vadmin adminctl backfill --apply +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: the dry run reports `would`, the apply reports `done`, and the control plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds, with no restart**. This is the whole system working end to end. + +- [ ] **Step 6: Confirm the Free rule and the deployment check** + +```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 +INSTANCE= +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. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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. `adminctl backfill` dry run, review the list, then `--apply`. **Existing cloud instances are read-only until this runs.** +4. Confirm every instance reports `valid` before announcing anything. +5. Spec 4 (the site) and spec 5 (Paddle) can then proceed in parallel. + +## Risks + +| Risk | Mitigation | +|---|---| +| Admin becomes a runtime dependency | Task 12 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 12 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 | +| Backfill run by accident | Dry run is the default; `--apply` is required to write | diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md index bde49aa..5755f9c 100644 --- a/docs/superpowers/specs/README.md +++ b/docs/superpowers/specs/README.md @@ -8,7 +8,7 @@ Seven specs, designed 2026-07-24. Build in this order. | 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live | | 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | planned | | 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | planned | -| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | — | blocks 4 and 5 | +| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | planned | | 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 | | 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 |