From 1e2132c1a1fc1bd42e9bb98a075f4e44891aa846 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:15:54 +0100 Subject: [PATCH] docs: Cleanup old specs and plans --- .../plans/2026-07-24-admin-backend.md | 3181 ------------ .../plans/2026-07-24-instance-licensing.md | 1664 ------- .../plans/2026-07-24-instance-rename.md | 1473 ------ .../plans/2026-07-24-licensing-core.md | 1044 ---- .../plans/2026-07-24-shared-module.md | 1356 ------ .../plans/2026-07-25-admin-site.md | 4263 ----------------- .../2026-07-26-cloud-instance-creation.md | 2161 --------- .../2026-07-26-cloud-instance-identity.md | 989 ---- .../2026-07-26-cloud-instance-membership.md | 2854 ----------- .../plans/2026-07-26-metered-licensing.md | 4004 ---------------- .../plans/2026-07-26-paddle-billing.md | 3613 -------------- .../plans/2026-07-27-paddle-billing.md | 1800 ------- .../plans/2026-07-27-web-mobile-responsive.md | 928 ---- .../plans/2026-07-29-agent-console-proxy.md | 2262 --------- .../specs/2026-07-24-admin-backend-design.md | 410 -- .../specs/2026-07-24-admin-site-design.md | 203 - .../2026-07-24-instance-licensing-design.md | 353 -- .../2026-07-24-instance-rename-design.md | 240 - .../specs/2026-07-24-licensing-core-design.md | 294 -- .../specs/2026-07-24-paddle-billing-design.md | 271 -- .../specs/2026-07-24-shared-module-design.md | 286 -- ...26-07-26-cloud-instance-creation-design.md | 498 -- .../2026-07-26-metered-licensing-design.md | 455 -- ...2026-07-27-web-mobile-responsive-design.md | 177 - .../specs/2026-07-28-docs-site-design.md | 162 - .../2026-07-29-agent-console-proxy-design.md | 209 - docs/superpowers/specs/README.md | 89 - 27 files changed, 35239 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-24-admin-backend.md delete mode 100644 docs/superpowers/plans/2026-07-24-instance-licensing.md delete mode 100644 docs/superpowers/plans/2026-07-24-instance-rename.md delete mode 100644 docs/superpowers/plans/2026-07-24-licensing-core.md delete mode 100644 docs/superpowers/plans/2026-07-24-shared-module.md delete mode 100644 docs/superpowers/plans/2026-07-25-admin-site.md delete mode 100644 docs/superpowers/plans/2026-07-26-cloud-instance-creation.md delete mode 100644 docs/superpowers/plans/2026-07-26-cloud-instance-identity.md delete mode 100644 docs/superpowers/plans/2026-07-26-cloud-instance-membership.md delete mode 100644 docs/superpowers/plans/2026-07-26-metered-licensing.md delete mode 100644 docs/superpowers/plans/2026-07-26-paddle-billing.md delete mode 100644 docs/superpowers/plans/2026-07-27-paddle-billing.md delete mode 100644 docs/superpowers/plans/2026-07-27-web-mobile-responsive.md delete mode 100644 docs/superpowers/plans/2026-07-29-agent-console-proxy.md delete mode 100644 docs/superpowers/specs/2026-07-24-admin-backend-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-admin-site-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-instance-licensing-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-instance-rename-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-licensing-core-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-paddle-billing-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-shared-module-design.md delete mode 100644 docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md delete mode 100644 docs/superpowers/specs/2026-07-26-metered-licensing-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-web-mobile-responsive-design.md delete mode 100644 docs/superpowers/specs/2026-07-28-docs-site-design.md delete mode 100644 docs/superpowers/specs/2026-07-29-agent-console-proxy-design.md delete mode 100644 docs/superpowers/specs/README.md diff --git a/docs/superpowers/plans/2026-07-24-admin-backend.md b/docs/superpowers/plans/2026-07-24-admin-backend.md deleted file mode 100644 index 4e0984c..0000000 --- a/docs/superpowers/plans/2026-07-24-admin-backend.md +++ /dev/null @@ -1,3181 +0,0 @@ -# Admin Backend Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build `admin/`, a fourth Go service that owns accounts, instances, licences and subscriptions, holds the only signing key, issues and injects licences, and never becomes a runtime dependency of any Vantage instance. - -**Architecture:** A gin service on `8083` with two MongoDB connections — its own `vantage_admin` database, and a narrow read/write path into the control plane that touches exactly three fields on `instances`. Licences are append-only: a renewal writes a new row and supersedes the old. Delivery is best-effort and recorded first; a reconciliation job every 15 minutes is what actually guarantees cloud instances end up holding the licence admin says they hold. - -**Tech Stack:** Go 1.26, gin, MongoDB driver v2, Redis (sessions), `shared/license` and `shared/models` from plans 0a and 1. - -## Global Constraints - -- **No automated Go tests.** Verification is by compiler, `grep`, `lkctl`, and running the built image against scratch databases. Every "confirm" step below is a container command with expected output. -- **Never run `go run` or `npm` on the host.** Everything runs in a container. The wrapper from plans 1 and 2: - ```sh - # /tmp/gorun.sh - DIR="$1"; shift - MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ - -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \ - -e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" golang:1.26 "$@" - ``` -- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise. -- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry" — this exact bug shipped and was caught by the image build in plan 2, not by `go build`. -- Admin is **excluded from the self-hosted deployment**: `deploy/docker-compose.site.yml` only, like sitesvc. -- Admin **must never write to any control-plane collection except `instances`**, and only the three licence fields. -- The control plane holds **no signing key and no signing code path**. Nothing in this plan changes that. -- Licence terms carry a **3-day grace** beyond the billing period end. -- Free is cloud-only by construction: its plan is `deployment: cloud` and `Issue` refuses a deployment mismatch. There is no flag. -- Customer endpoints return **404, never 403**, for another account's resource. - -## Context this plan inherits - -Plan 2 shipped without grandfathering — the migration that would have licensed existing cloud instances was removed at the user's direction. **Every existing cloud instance is therefore read-only right now**, and stays that way until someone issues it a licence. - -There is deliberately no automated backfill. Those instances get licensed by hand through the admin UI once spec 4 lands, using `POST /api/staff/instances` to attach each one to an account followed by `POST /api/staff/instances/:id/issue`. Task 11 Step 5 walks that exact flow, so the path is proven by the time the UI needs it. - ---- - -## File Structure - -**Created:** - -| Path | Responsibility | -| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` | -| `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP | -| `admin/cmd/adminctl/main.go` | staff user creation; deliberately has no HTTP surface | -| `admin/internal/config/config.go` | env parsing, fail-fast validation | -| `admin/internal/db/db.go` | `Admin()` and `Control()` collections, connect, index creation | -| `admin/internal/models/models.go` | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` | -| `admin/internal/models/plans.go` | plan seeding from `shared/license` | -| `admin/internal/licensing/issue.go` | `Issue`, the Free rule, the deployment check, supersession | -| `admin/internal/licensing/link.go` | `LinkInstance`, `Relink` | -| `admin/internal/inject/inject.go` | `InjectCloud`, `Reconcile`, `StartReconciler` | -| `admin/internal/auth/session.go` | Redis sessions, `admin_session` cookie | -| `admin/internal/auth/staff.go` | staff login | -| `admin/internal/auth/cloud.go` | cloud-owner login against the control plane | -| `admin/internal/auth/customer.go` | self-hosted customer login, signup, email verification | -| `admin/internal/auth/ratelimit.go` | per-email and per-IP limiters | -| `admin/internal/auth/middleware.go` | `RequireStaff`, `RequireCustomer`, `AccountID` | -| `admin/internal/api/routes.go` | route table — the single place scoping is guaranteed | -| `admin/internal/api/customer.go` | customer handlers | -| `admin/internal/api/staff.go` | staff handlers | -| `admin/internal/mail/mail.go` | licence delivery and verification email | -| `admin/internal/audit/audit.go` | `admin_audit` writes | - -**Modified:** `go.work`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`. - ---- - -### Task 1: Module, config and boot - -**Files:** - -- Create: `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore`, `admin/cmd/main.go`, `admin/internal/config/config.go`, `admin/internal/db/db.go` -- Modify: `go.work` - -**Interfaces:** - -- Consumes: `shared/models`, `shared/license` -- Produces: - - `config.Config` with `Load() (Config, error)` - - `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)` - -- [x] **Step 1: Create the module** - -```bash -cd c:/Work/Repos/vantage -mkdir -p admin/cmd admin/internal/config admin/internal/db -``` - -Create `admin/go.mod`: - -``` -module gitea.hostxtra.co.uk/mrhid6/vantage/admin - -go 1.26.4 - -require ( - github.com/gin-gonic/gin v1.10.0 - github.com/google/uuid v1.6.0 - github.com/joho/godotenv v1.5.1 - gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0 - github.com/redis/go-redis/v9 v9.20.1 - go.mongodb.org/mongo-driver/v2 v2.8.0 - golang.org/x/crypto v0.54.0 -) - -replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared -``` - -Add the module to `go.work`: - -``` -go 1.26.4 - -use ( - ./admin - ./server - ./shared - ./sitesvc -) -``` - -- [x] **Step 2: Write the config** - -Create `admin/internal/config/config.go`: - -```go -// Package config parses and validates admin's environment. -// -// Everything required is checked at boot and the process refuses to start -// without it. A licensing service that cannot sign is worse than one that is -// down, because it looks healthy. -package config - -import ( - "fmt" - "net/url" - "os" - "strings" -) - -type Config struct { - AdminMongoURI string - AdminDBName string - ControlMongoURI string - ControlDBName string - RedisAddr string - SigningKey string - PublicURL string - AllowedOrigins []string - TrustProxy bool - Addr string - - SMTPHost string - SMTPPort string - SMTPFrom string - SMTPUsername string - SMTPPassword string -} - -// dbNameFromURI reads the database from a Mongo URI path. -// -// Both URIs must name their database inline rather than through a separate -// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous -// about which, and guessing wrong means writing licence fields into the wrong -// place. -func dbNameFromURI(raw, which string) (string, error) { - u, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("%s is not a valid URI: %w", which, err) - } - name := strings.TrimPrefix(u.Path, "/") - if name == "" { - return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which) - } - return name, nil -} - -func Load() (Config, error) { - c := Config{ - AdminMongoURI: os.Getenv("ADMIN_MONGO_URI"), - ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"), - RedisAddr: os.Getenv("REDIS_ADDR"), - SigningKey: os.Getenv("LICENSE_SIGNING_KEY"), - PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"), - TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"), - Addr: ":" + envOr("PORT", "8083"), - - SMTPHost: os.Getenv("SMTP_HOST"), - SMTPPort: envOr("SMTP_PORT", "587"), - SMTPFrom: os.Getenv("SMTP_FROM"), - SMTPUsername: os.Getenv("SMTP_USERNAME"), - SMTPPassword: os.Getenv("SMTP_PASSWORD"), - } - - var missing []string - for name, v := range map[string]string{ - "ADMIN_MONGO_URI": c.AdminMongoURI, - "CONTROL_MONGO_URI": c.ControlMongoURI, - "REDIS_ADDR": c.RedisAddr, - "LICENSE_SIGNING_KEY": c.SigningKey, - "PUBLIC_URL": c.PublicURL, - "ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"), - } { - if v == "" { - missing = append(missing, name) - } - } - if len(missing) > 0 { - return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", ")) - } - - var err error - if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil { - return Config{}, err - } - if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil { - return Config{}, err - } - if c.AdminMongoURI == c.ControlMongoURI { - return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database") - } - - for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") { - if o = strings.TrimSpace(o); o != "" { - c.AllowedOrigins = append(c.AllowedOrigins, o) - } - } - return c, nil -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} -``` - -- [x] **Step 3: Write the database layer** - -Create `admin/internal/db/db.go`: - -```go -// Package db holds admin's two MongoDB connections. -// -// Admin() is its own database and it owns every collection there. Control() is -// the control plane's database, and admin's access to it is deliberately narrow: -// it reads `instances` and `users`, and writes exactly three licence fields on -// `instances`. Nothing here should ever grow a write path to another collection. -package db - -import ( - "context" - "fmt" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -var ( - adminDB *mongo.Database - controlDB *mongo.Database -) - -func Connect(ctx context.Context, cfg config.Config) error { - ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI)) - if err != nil { - return fmt.Errorf("connect admin mongo: %w", err) - } - if err := ac.Ping(ctx, nil); err != nil { - return fmt.Errorf("ping admin mongo: %w", err) - } - adminDB = ac.Database(cfg.AdminDBName) - - cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI)) - if err != nil { - return fmt.Errorf("connect control mongo: %w", err) - } - if err := cc.Ping(ctx, nil); err != nil { - return fmt.Errorf("ping control mongo: %w", err) - } - controlDB = cc.Database(cfg.ControlDBName) - - // The control plane must already be deployed and migrated. Without the - // instances collection, injection would silently create it and write - // licence fields into a collection nothing reads. - names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"}) - if err != nil { - return fmt.Errorf("inspect control database: %w", err) - } - if len(names) == 0 { - return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName) - } - return nil -} - -func Admin(name string) *mongo.Collection { return adminDB.Collection(name) } -func Control(name string) *mongo.Collection { return controlDB.Collection(name) } - -func Ctx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 10*time.Second) -} -``` - -- [x] **Step 4: Write main.go** - -Create `admin/cmd/main.go`: - -```go -package main - -import ( - "context" - "errors" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/joho/godotenv" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" -) - -func main() { - godotenv.Load() - - cfg, err := config.Load() - if err != nil { - log.Fatalf("configuration error: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - if err := db.Connect(ctx, cfg); err != nil { - cancel() - log.Fatalf("database: %v", err) - } - cancel() - log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName) - - srv := &http.Server{ - Addr: cfg.Addr, - Handler: http.NotFoundHandler(), // replaced in Task 8 - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 20 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 60 * time.Second, - } - - go func() { - log.Printf("admin listening on %s", cfg.Addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("server error: %v", err) - } - }() - - stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - <-stopCtx.Done() - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer shutdownCancel() - _ = srv.Shutdown(shutdownCtx) - log.Println("admin stopped") - os.Exit(0) -} -``` - -- [x] **Step 5: Write the Dockerfile** - -Create `admin/Dockerfile` — context is the repo root, exactly like `server/`: - -```dockerfile -# Context is the repository root; admin depends on the shared module. -FROM golang:1.26-alpine AS builder - -WORKDIR /src - -COPY shared/go.mod shared/go.sum ./shared/ -COPY admin/go.mod admin/go.sum ./admin/ -RUN cd admin && go mod download - -COPY shared/ ./shared/ -COPY admin/ ./admin/ - -RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd -RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl - -FROM alpine:3.20 AS runner - -RUN apk add --no-cache ca-certificates && \ - addgroup --system --gid 1001 admin && \ - adduser --system --uid 1001 --ingroup admin admin - -COPY --from=builder /out/admin /usr/local/bin/admin -COPY --from=builder /out/adminctl /usr/local/bin/adminctl - -USER admin - -EXPOSE 8083 -ENV PORT=8083 - -CMD ["/usr/local/bin/admin"] -``` - -Create `admin/.dockerignore`: - -``` -.env -*.lic -``` - -- [x] **Step 6: Resolve dependencies outside the workspace** - -```bash -cd c:/Work/Repos/vantage -MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ - -v vantage-gocache:/root/.cache/go-build -w /src/admin -e GOWORK=off golang:1.26 go mod tidy -``` - -`GOWORK=off` is required. In workspace mode `tidy` drops the `require` lines the Docker build needs, and the failure only appears at image build time. - -- [x] **Step 7: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.) - -- [x] **Step 8: Commit** - -```bash -git add admin/ go.work -git commit -m "feat(admin): module skeleton, config and two database connections" -``` - ---- - -### Task 2: Models, indexes and the plan seed - -**Files:** - -- Create: `admin/internal/models/models.go`, `admin/internal/models/plans.go` -- Modify: `admin/internal/db/db.go` (add `EnsureIndexes`), `admin/cmd/main.go` (call it) - -**Interfaces:** - -- Consumes: `shared/license` -- Produces: - - `models.Account`, `models.Instance`, `models.License`, `models.Subscription`, `models.Plan`, `models.StaffUser`, `models.CustomerUser`, `models.AuditEntry` - - status and reason constants - - `models.SeedPlans(ctx) error` - - `db.EnsureIndexes(ctx) error` - -- [x] **Step 1: Write the documents** - -Create `admin/internal/models/models.go`: - -```go -// Package models holds admin's own documents. -// -// These are admin-owned and never shared with the control plane. The two -// structs that ARE shared — Instance and User on the control-plane side — come -// from shared/models, so there is no second copy of those shapes to drift. -package models - -import ( - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// Instance statuses. -const ( - StatusAwaitingLink = "awaiting_link" - StatusActive = "active" - StatusLapsed = "lapsed" - StatusCancelled = "cancelled" -) - -// Account statuses. -const ( - AccountActive = "active" - AccountSuspended = "suspended" -) - -// Licence issuance reasons. These end up in support conversations, so they are -// stable identifiers rather than prose. -const ( - ReasonNew = "new" - ReasonRenewal = "renewal" - ReasonTierChange = "tier_change" - ReasonRelink = "relink" - ReasonManual = "manual" -) - -// MaxRelinksPerTerm is the customer-facing relink cap. -// -// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be -// revoked, so a determined customer is not stopped by a counter. Its real job is -// to put a human in front of the fourth attempt. -const MaxRelinksPerTerm = 3 - -// GracePeriod is added to every licence expiry beyond the billing period end, -// so a renewal webhook arriving slightly late does not create a gap in which a -// paying customer's instance goes read-only. -const GracePeriod = 3 * 24 * time.Hour - -type Account struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - AccountID string `bson:"account_id" json:"account_id"` - Name string `bson:"name" json:"name"` - BillingEmail string `bson:"billing_email" json:"billing_email"` - PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"` - Status string `bson:"status" json:"status"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} - -// Instance is admin's record of one deployment. -// -// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it -// is the UUID the customer pasted — their database is theirs, and we cannot see -// it, so this row is the only thing that exists on our side. -type Instance struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - InstanceID string `bson:"instance_id" json:"instance_id"` - AccountID string `bson:"account_id" json:"account_id"` - Name string `bson:"name" json:"name"` - Slug string `bson:"slug,omitempty" json:"slug,omitempty"` - Deployment string `bson:"deployment" json:"deployment"` - Tier string `bson:"tier,omitempty" json:"tier,omitempty"` - Status string `bson:"status" json:"status"` - CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"` - RelinkCount int `bson:"relink_count" json:"relink_count"` - InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} - -// License is append-only. A renewal writes a new row and sets SupersededBy on -// the old one. Nothing here is ever edited or deleted: when a support question -// arrives about why an instance stopped working on a given date, the answer has -// to still be in the table. -type License struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - LicenseID string `bson:"license_id" json:"license_id"` - InstanceID string `bson:"instance_id" json:"instance_id"` - AccountID string `bson:"account_id" json:"account_id"` - Tier string `bson:"tier" json:"tier"` - Deployment string `bson:"deployment" json:"deployment"` - Limits license.Limits `bson:"limits" json:"limits"` - Features []string `bson:"features" json:"features"` - IssuedAt time.Time `bson:"issued_at" json:"issued_at"` - ExpiresAt time.Time `bson:"expires_at" json:"expires_at"` - Blob string `bson:"blob" json:"-"` - SupersededBy string `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"` - IssuedBy string `bson:"issued_by" json:"issued_by"` - Reason string `bson:"reason" json:"reason"` -} - -type Subscription struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - SubscriptionID string `bson:"subscription_id" json:"subscription_id"` - AccountID string `bson:"account_id" json:"account_id"` - InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"` - PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"` - PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"` - Tier string `bson:"tier" json:"tier"` - Term string `bson:"term" json:"term"` - Status string `bson:"status" json:"status"` - CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"` -} - -// Plan is the authoritative tier definition, seeded from shared/license. -// -// It lives in the database so tier contents change without a deploy. Every -// issued licence snapshots it, so editing a plan never rewrites an existing -// licence — the same rule as workflow_runs.steps_snapshot. -type Plan struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - Tier string `bson:"tier" json:"tier"` - Name string `bson:"name" json:"name"` - Deployment string `bson:"deployment" json:"deployment"` - Limits license.Limits `bson:"limits" json:"limits"` - Features []string `bson:"features" json:"features"` - PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"` - PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"` - Active bool `bson:"active" json:"active"` -} - -type StaffUser struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - UserID string `bson:"user_id" json:"user_id"` - Email string `bson:"email" json:"email"` - PasswordHash string `bson:"password_hash" json:"-"` - Name string `bson:"name" json:"name"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} - -// CustomerUser is a self-hosted customer's login. Cloud customers do not have -// one — they authenticate against the control plane with credentials they -// already hold. -type CustomerUser struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - UserID string `bson:"user_id" json:"user_id"` - AccountID string `bson:"account_id" json:"account_id"` - Email string `bson:"email" json:"email"` - PasswordHash string `bson:"password_hash" json:"-"` - VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"` - VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"` - VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} - -type AuditEntry struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - Actor string `bson:"actor" json:"actor"` - Action string `bson:"action" json:"action"` - AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"` - Target string `bson:"target,omitempty" json:"target,omitempty"` - Detail string `bson:"detail,omitempty" json:"detail,omitempty"` - IP string `bson:"ip,omitempty" json:"ip,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} -``` - -- [x] **Step 2: Write the plan seed** - -Create `admin/internal/models/plans.go`: - -```go -package models - -import ( - "context" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -// SeedPlans inserts the tier table from shared/license on first boot. -// -// It uses $setOnInsert only: once a plan exists, staff edits to limits, features -// and Paddle IDs are authoritative and a redeploy must not stamp over them. -func SeedPlans(ctx context.Context) error { - for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} { - p, ok := license.PlanFor(tier) - if !ok { - continue - } - _, err := db.Admin("plans").UpdateOne(ctx, - bson.M{"tier": tier}, - bson.M{"$setOnInsert": bson.M{ - "tier": p.Tier, - "name": p.Name, - "deployment": p.Deployment, - "limits": p.Limits, - "features": p.Features, - "active": true, - }}, - options.UpdateOne().SetUpsert(true)) - if err != nil { - return err - } - } - return nil -} - -// GetPlan reads a tier's authoritative definition. -func GetPlan(ctx context.Context, tier string) (*Plan, error) { - var p Plan - if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil { - return nil, err - } - return &p, nil -} - -func now() time.Time { return time.Now().UTC() } -``` - -- [x] **Step 3: Add the indexes** - -Append to `admin/internal/db/db.go`: - -```go -// EnsureIndexes creates admin's unique indexes. -// -// These are a correctness property, not an optimisation. In particular -// admin_instances.instance_id unique is what stops the same self-hosted UUID -// being linked to two accounts — without it, two customers could both claim one -// instance and both be issued licences for it. -func EnsureIndexes(ctx context.Context) error { - unique := []struct { - coll string - field string - }{ - {"accounts", "account_id"}, - {"admin_instances", "instance_id"}, - {"licenses", "license_id"}, - {"plans", "tier"}, - {"staff_users", "email"}, - {"customer_users", "email"}, - } - for _, u := range unique { - if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: u.field, Value: 1}}, - Options: options.Index().SetUnique(true).SetName(u.field + "_unique"), - }); err != nil { - return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err) - } - } - - // Sparse: a subscription exists before Paddle assigns an ID, so empty must - // not collide with empty. - if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "paddle_subscription_id", Value: 1}}, - Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"), - }); err != nil { - return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err) - } - - for _, idx := range []struct { - coll string - keys bson.D - }{ - {"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}}, - {"admin_instances", bson.D{{Key: "account_id", Value: 1}}}, - {"admin_audit", bson.D{{Key: "created_at", Value: -1}}}, - } { - if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil { - return fmt.Errorf("index %s: %w", idx.coll, err) - } - } - return nil -} -``` - -Add to that file's imports: `"go.mongodb.org/mongo-driver/v2/bson"` and `"go.mongodb.org/mongo-driver/v2/mongo/options"`. - -- [x] **Step 4: Call both at boot** - -In `admin/cmd/main.go`, after the connect block: - -```go - idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second) - if err := db.EnsureIndexes(idxCtx); err != nil { - idxCancel() - log.Fatalf("indexes: %v", err) - } - if err := models.SeedPlans(idxCtx); err != nil { - idxCancel() - log.Fatalf("plan seed: %v", err) - } - idxCancel() -``` - -Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"`. - -- [x] **Step 5: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. - -- [x] **Step 6: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): documents, indexes and the plan seed" -``` - ---- - -### Task 3: Issuance - -The core of the service. Everything else exists to call this correctly. - -**Files:** - -- Create: `admin/internal/licensing/issue.go`, `admin/internal/audit/audit.go` - -**Interfaces:** - -- Consumes: `models`, `db`, `license.Sign` -- Produces: - - `licensing.Issue(ctx, in IssueInput) (*models.License, error)` - - `type IssueInput struct { InstanceID, Tier, Term, Reason, IssuedBy string; ExpiresAt time.Time }` - - `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier` - - `audit.Write(ctx, e models.AuditEntry)` - -- [x] **Step 1: Write the audit helper** - -Create `admin/internal/audit/audit.go`: - -```go -// Package audit records who did what. Every issuance, link, relink and sign-in -// attempt lands here. -package audit - -import ( - "context" - "log" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" -) - -// Write never returns an error: an audit failure must not roll back the action -// it describes. It logs instead, loudly enough to notice. -func Write(ctx context.Context, e models.AuditEntry) { - e.CreatedAt = time.Now().UTC() - if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil { - log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err) - } -} -``` - -- [x] **Step 2: Write issuance** - -Create `admin/internal/licensing/issue.go`: - -```go -// Package licensing issues licences. It is the only place that signs. -package licensing - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var ( - ErrUnknownTier = errors.New("unknown tier") - ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type") - ErrFreeLimit = errors.New("this account already has a Free instance") - ErrUnknownInstance = errors.New("instance not found") -) - -type IssueInput struct { - InstanceID string - Tier string - Term string // "monthly" or "annual"; ignored when ExpiresAt is set - ExpiresAt time.Time // explicit expiry, used by relink to preserve the remaining term - Reason string - IssuedBy string // staff email, "system", or "paddle:" -} - -// signingKey is set once at boot from LICENSE_SIGNING_KEY. -var signingKey string - -func SetSigningKey(k string) { signingKey = k } - -// Issue signs a licence, records it, supersedes its predecessor and updates the -// instance. -// -// It does NOT deliver. Recording and delivery are deliberately separate and -// ordered: a licence recorded but not delivered is recoverable, because the -// customer can download it. A licence delivered but not recorded is a support -// mystery with no paper trail. Callers deliver after this returns. -func Issue(ctx context.Context, in IssueInput) (*models.License, error) { - if signingKey == "" { - return nil, errors.New("no signing key configured") - } - - var inst models.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil { - return nil, ErrUnknownInstance - } - - plan, err := models.GetPlan(ctx, in.Tier) - if err != nil { - return nil, ErrUnknownTier - } - - // This single comparison is what makes Free cloud-only. Free's plan is - // deployment "cloud", so it can never be issued against a self-hosted - // instance, and verification on the instance would reject it anyway. - if plan.Deployment != inst.Deployment { - return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment) - } - - if plan.Tier == license.TierFree { - if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil { - return nil, err - } - } - - now := time.Now().UTC() - expires := in.ExpiresAt - if expires.IsZero() { - switch in.Term { - case "monthly": - expires = now.AddDate(0, 1, 0).Add(models.GracePeriod) - case "annual", "": - expires = now.AddDate(1, 0, 0).Add(models.GracePeriod) - default: - return nil, fmt.Errorf("unknown term %q", in.Term) - } - } - - payload := license.License{ - ID: uuid.NewString(), - InstanceID: inst.InstanceID, - AccountID: inst.AccountID, - InstanceName: inst.Name, - Tier: plan.Tier, - Deployment: plan.Deployment, - IssuedAt: now, - ExpiresAt: expires, - // Snapshotted, not referenced: editing a plan tomorrow must not change - // what this licence grants. - Limits: plan.Limits, - Features: plan.Features, - } - - blob, err := license.Sign(payload, signingKey) - if err != nil { - return nil, fmt.Errorf("sign: %w", err) - } - - rec := models.License{ - LicenseID: payload.ID, - InstanceID: inst.InstanceID, - AccountID: inst.AccountID, - Tier: plan.Tier, - Deployment: plan.Deployment, - Limits: plan.Limits, - Features: plan.Features, - IssuedAt: now, - ExpiresAt: expires, - Blob: blob, - IssuedBy: in.IssuedBy, - Reason: in.Reason, - } - if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil { - return nil, fmt.Errorf("record licence: %w", err) - } - - // Supersede rather than delete. The history is the support tool. - if inst.CurrentLicense != "" { - if _, err := db.Admin("licenses").UpdateOne(ctx, - bson.M{"license_id": inst.CurrentLicense}, - bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil { - return nil, fmt.Errorf("supersede previous licence: %w", err) - } - } - - set := bson.M{ - "current_license": rec.LicenseID, - "tier": plan.Tier, - "status": models.StatusActive, - } - if in.Reason == models.ReasonRenewal { - set["relink_count"] = 0 // the cap is per term - } - if _, err := db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil { - return nil, fmt.Errorf("update instance: %w", err) - } - - audit.Write(ctx, models.AuditEntry{ - Actor: in.IssuedBy, - Action: "license.issued", - AccountID: inst.AccountID, - Target: inst.InstanceID, - Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s", - plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID), - }) - - return &rec, nil -} - -// checkFreeLimit enforces one Free instance per account. -// -// Cancelled instances do not count: a customer who cancelled their Free instance -// is allowed another one. -func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error { - n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ - "account_id": accountID, - "tier": license.TierFree, - "status": bson.M{"$ne": models.StatusCancelled}, - "instance_id": bson.M{"$ne": exceptInstanceID}, - }) - if err != nil { - return err - } - if n > 0 { - return ErrFreeLimit - } - return nil -} -``` - -- [x] **Step 3: Wire the signing key at boot** - -In `admin/cmd/main.go`, after config loads: - -```go - licensing.SetSigningKey(cfg.SigningKey) -``` - -Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"`. - -- [x] **Step 4: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. - -- [x] **Step 5: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): licence issuance with plan snapshots and supersession" -``` - ---- - -### Task 4: Injection and reconciliation - -**Files:** - -- Create: `admin/internal/inject/inject.go` -- Modify: `admin/cmd/main.go` - -**Interfaces:** - -- Consumes: `db.Control`, `models` -- Produces: - - `inject.Cloud(ctx, lic *models.License) error` - - `inject.Reconcile(ctx) (checked, repaired int, err error)` - - `inject.StartReconciler(ctx)` - -- [x] **Step 1: Write it** - -Create `admin/internal/inject/inject.go`: - -```go -// Package inject writes licences onto control-plane instance documents. -// -// This is admin's ONLY write path into the control plane, and it touches exactly -// three fields on one collection. If this package ever grows a second write -// target, that is a design change and not a refactor. -package inject - -import ( - "context" - "fmt" - "log" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// ReconcileInterval is how often every cloud instance is compared against what -// admin believes it should hold. -// -// This job, not the issuance path, is what guarantees eventual consistency. -// Injection at issue time is best-effort; this is the backstop. -const ReconcileInterval = 15 * time.Minute - -// Cloud writes the licence onto the control-plane instance document. -// -// Idempotent and safe to re-run: it is a single UpdateOne of three fields with -// no read-modify-write. Retries three times with backoff. -// -// The control plane caches licence state for 60 seconds, so this takes effect -// within a minute with no restart. -func Cloud(ctx context.Context, lic *models.License) error { - set := bson.M{"$set": bson.M{ - "license_blob": lic.Blob, - "license_tier": lic.Tier, - "license_expiry": lic.ExpiresAt, - }} - - var lastErr error - for attempt := 1; attempt <= 3; attempt++ { - res, err := db.Control("instances").UpdateOne(ctx, - bson.M{"instance_id": lic.InstanceID}, set) - if err == nil { - if res.MatchedCount == 0 { - return fmt.Errorf("no control-plane instance %s", lic.InstanceID) - } - return nil - } - lastErr = err - time.Sleep(time.Duration(attempt) * 2 * time.Second) - } - return fmt.Errorf("inject after 3 attempts: %w", lastErr) -} - -// Deliver injects and records the outcome without ever failing the caller. -// -// A licence that is recorded but not injected is recoverable — the reconciler -// will fix it within 15 minutes, and staff can see it on the health endpoint. -// Failing the purchase because one write failed would be worse. -func Deliver(ctx context.Context, lic *models.License) { - if err := Cloud(ctx, lic); err != nil { - log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err) - now := time.Now().UTC() - _, _ = db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": lic.InstanceID}, - bson.M{"$set": bson.M{"inject_failed_at": now}}) - return - } - _, _ = db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": lic.InstanceID}, - bson.M{"$unset": bson.M{"inject_failed_at": ""}}) -} - -// Reconcile compares every active cloud instance's current licence against the -// blob actually stored in the control plane, and re-injects on mismatch. -func Reconcile(ctx context.Context) (checked, repaired int, err error) { - cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ - "deployment": license.DeploymentCloud, - "status": models.StatusActive, - "current_license": bson.M{"$ne": ""}, - }) - if err != nil { - return 0, 0, err - } - var instances []models.Instance - if err := cur.All(ctx, &instances); err != nil { - return 0, 0, err - } - - for _, inst := range instances { - checked++ - - var lic models.License - if err := db.Admin("licenses").FindOne(ctx, - bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil { - log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense) - continue - } - - var remote sharedmodels.Instance - if err := db.Control("instances").FindOne(ctx, - bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil { - log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err) - continue - } - - if remote.LicenseBlob == lic.Blob { - continue - } - - log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID) - if err := Cloud(ctx, &lic); err != nil { - log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err) - continue - } - repaired++ - _, _ = db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": inst.InstanceID}, - bson.M{"$unset": bson.M{"inject_failed_at": ""}}) - } - return checked, repaired, nil -} - -// StartReconciler runs Reconcile on a ticker until ctx is cancelled. -func StartReconciler(ctx context.Context) { - go func() { - t := time.NewTicker(ReconcileInterval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) - checked, repaired, err := Reconcile(runCtx) - cancel() - if err != nil { - log.Printf("reconcile: %v", err) - continue - } - if repaired > 0 { - log.Printf("reconcile: checked %d, repaired %d", checked, repaired) - } - } - } - }() -} -``` - -- [x] **Step 2: Start it at boot** - -In `admin/cmd/main.go`, before the HTTP server starts: - -```go - reconcileCtx, stopReconcile := context.WithCancel(context.Background()) - defer stopReconcile() - inject.StartReconciler(reconcileCtx) -``` - -Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"`. - -- [x] **Step 3: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. - -- [x] **Step 4: Confirm the write surface by inspection** - -```bash -grep -n "Control(" admin/internal/ -r -``` - -Expected: reads of `instances` and `users`, and writes only to `instances` with the three licence fields. Any other write target is a bug — fix it before continuing. - -- [x] **Step 5: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): cloud injection and the 15-minute reconciler" -``` - ---- - -### Task 5: Sessions and staff auth - -**Files:** - -- Create: `admin/internal/auth/session.go`, `admin/internal/auth/staff.go`, `admin/internal/auth/middleware.go`, `admin/cmd/adminctl/main.go` - -**Interfaces:** - -- Consumes: `db`, Redis -- Produces: - - `auth.Session{UserID, Kind, Email, AccountID}` where `Kind` is `"staff"` or `"customer"` - - `auth.InitRedis(addr)`, `auth.Save`, `auth.Get`, `auth.Destroy`, `auth.SetCookie` - - `auth.RequireStaff()`, `auth.RequireCustomer()`, `auth.Current(c) *Session` - - `auth.HandleStaffLogin`, `auth.HandleLogout` - - `adminctl staff-add` - -- [x] **Step 1: Write sessions** - -Create `admin/internal/auth/session.go`: - -```go -// Package auth holds admin's three identities: staff, cloud customers and -// self-hosted customers. All three share one session store and one cookie. -package auth - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/redis/go-redis/v9" -) - -const ( - CookieName = "admin_session" - SessionTTL = 24 * time.Hour - KindStaff = "staff" - KindCustomer = "customer" -) - -type Session struct { - UserID string `json:"user_id"` - Kind string `json:"kind"` - Email string `json:"email"` - AccountID string `json:"account_id,omitempty"` // customers only -} - -var rdb *redis.Client - -func InitRedis(addr string) { rdb = redis.NewClient(&redis.Options{Addr: addr}) } - -func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() } - -func newID() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -func Save(ctx context.Context, s Session) (string, error) { - id, err := newID() - if err != nil { - return "", err - } - body, err := json.Marshal(s) - if err != nil { - return "", err - } - if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil { - return "", err - } - return id, nil -} - -func Get(ctx context.Context, id string) (*Session, error) { - body, err := rdb.Get(ctx, "admin_session:"+id).Bytes() - if err != nil { - return nil, err - } - var s Session - if err := json.Unmarshal(body, &s); err != nil { - return nil, err - } - return &s, nil -} - -func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) } - -func SetCookie(c *gin.Context, id string) { - http.SetCookie(c.Writer, &http.Cookie{ - Name: CookieName, - Value: id, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(SessionTTL.Seconds()), - }) -} - -func ClearCookie(c *gin.Context) { - http.SetCookie(c.Writer, &http.Cookie{ - Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1, - }) -} -``` - -- [x] **Step 2: Write the middleware** - -Create `admin/internal/auth/middleware.go`: - -```go -package auth - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -const ctxSession = "admin_session_obj" - -func load(c *gin.Context) *Session { - id, err := c.Cookie(CookieName) - if err != nil || id == "" { - return nil - } - s, err := Get(c.Request.Context(), id) - if err != nil { - return nil - } - return s -} - -// Current returns the session, or nil. -func Current(c *gin.Context) *Session { - if v, ok := c.Get(ctxSession); ok { - if s, ok := v.(*Session); ok { - return s - } - } - return nil -} - -func RequireStaff() gin.HandlerFunc { - return func(c *gin.Context) { - s := load(c) - if s == nil || s.Kind != KindStaff { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) - return - } - c.Set(ctxSession, s) - c.Next() - } -} - -// RequireCustomer admits both cloud and self-hosted customers. Every handler -// behind it scopes by AccountID via the helper in api/customer.go — never by -// remembering to filter. -func RequireCustomer() gin.HandlerFunc { - return func(c *gin.Context) { - s := load(c) - if s == nil || s.Kind != KindCustomer || s.AccountID == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) - return - } - c.Set(ctxSession, s) - c.Next() - } -} -``` - -- [x] **Step 3: Write staff login** - -Create `admin/internal/auth/staff.go`: - -```go -package auth - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "go.mongodb.org/mongo-driver/v2/bson" - "golang.org/x/crypto/bcrypt" -) - -// genericAuthError is returned for every failure mode — unknown email, wrong -// password, wrong role. Distinguishing them would confirm which addresses have -// accounts. -const genericAuthError = "email or password is incorrect" - -func HandleStaffLogin(c *gin.Context) { - var body struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) - return - } - email := strings.ToLower(strings.TrimSpace(body.Email)) - - if !allowAttempt(email, c.ClientIP()) { - c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) - return - } - - var u models.StaffUser - err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u) - if err != nil { - // Spend the same work as a real comparison so timing does not - // distinguish "no such user" from "wrong password". - bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password)) - audit.Write(c.Request.Context(), models.AuditEntry{ - Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"}) - c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) - return - } - - if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil { - audit.Write(c.Request.Context(), models.AuditEntry{ - Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"}) - c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) - return - } - - id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) - return - } - SetCookie(c, id) - clearAttempts(email) - audit.Write(c.Request.Context(), models.AuditEntry{ - Actor: email, Action: "staff.login", IP: c.ClientIP()}) - c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name}) -} - -// dummyHash is a valid bcrypt hash of a random value, compared against when no -// user exists so the timing profile matches. -const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u" - -func HandleLogout(c *gin.Context) { - if id, err := c.Cookie(CookieName); err == nil && id != "" { - Destroy(c.Request.Context(), id) - } - ClearCookie(c) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} -``` - -- [x] **Step 4: Write the rate limiter** - -Create `admin/internal/auth/ratelimit.go`: - -```go -package auth - -import ( - "sync" - "time" -) - -// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per -// hour. The email limit stops a targeted attack on one account; the IP limit -// stops a spray across many. -const ( - emailLimit = 5 - emailWindow = 15 * time.Minute - ipLimit = 20 - ipWindow = time.Hour -) - -var ( - attemptMu sync.Mutex - byEmail = map[string][]time.Time{} - byIP = map[string][]time.Time{} -) - -func prune(in []time.Time, cutoff time.Time) []time.Time { - out := in[:0] - for _, t := range in { - if t.After(cutoff) { - out = append(out, t) - } - } - return out -} - -func allowAttempt(email, ip string) bool { - now := time.Now() - - attemptMu.Lock() - defer attemptMu.Unlock() - - byEmail[email] = prune(byEmail[email], now.Add(-emailWindow)) - byIP[ip] = prune(byIP[ip], now.Add(-ipWindow)) - - if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit { - return false - } - byEmail[email] = append(byEmail[email], now) - byIP[ip] = append(byIP[ip], now) - return true -} - -func clearAttempts(email string) { - attemptMu.Lock() - delete(byEmail, email) - attemptMu.Unlock() -} -``` - -- [x] **Step 5: Write adminctl staff-add** - -Create `admin/cmd/adminctl/main.go`: - -```go -// Command adminctl performs the operations that deliberately have no HTTP -// surface. -// -// adminctl staff-add --email=you@example.com --name="You" --password=... -// -// There is no staff signup endpoint. A licensing authority that can be joined -// over the internet is not one. -package main - -import ( - "context" - "flag" - "fmt" - "os" - "strings" - "time" - - "github.com/google/uuid" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "go.mongodb.org/mongo-driver/v2/bson" - "golang.org/x/crypto/bcrypt" -) - -func main() { - if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: adminctl staff-add") - os.Exit(2) - } - - cfg, err := config.Load() - if err != nil { - fatal("configuration: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if err := db.Connect(ctx, cfg); err != nil { - fatal("database: %v", err) - } - - switch os.Args[1] { - case "staff-add": - staffAdd(ctx, os.Args[2:]) - default: - fmt.Fprintln(os.Stderr, "usage: adminctl staff-add") - os.Exit(2) - } -} - -func staffAdd(ctx context.Context, args []string) { - fs := flag.NewFlagSet("staff-add", flag.ExitOnError) - email := fs.String("email", "", "staff email (required)") - name := fs.String("name", "", "display name") - password := fs.String("password", "", "password, at least 12 characters (required)") - fs.Parse(args) - - if *email == "" || len(*password) < 12 { - fatal("--email and a --password of at least 12 characters are required") - } - - hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12) - if err != nil { - fatal("hash: %v", err) - } - - u := models.StaffUser{ - UserID: uuid.NewString(), - Email: strings.ToLower(strings.TrimSpace(*email)), - PasswordHash: string(hash), - Name: *name, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil { - fatal("create staff user: %v", err) - } - fmt.Printf("created staff user %s\n", u.Email) -} - -func fatal(format string, a ...any) { - fmt.Fprintf(os.Stderr, format+"\n", a...) - os.Exit(1) -} -``` - -- [x] **Step 6: Initialise Redis at boot** - -In `admin/cmd/main.go`, after config loads: - -```go - auth.InitRedis(cfg.RedisAddr) - pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) - if err := auth.Ping(pingCtx); err != nil { - pingCancel() - log.Fatalf("redis: %v", err) - } - pingCancel() -``` - -- [x] **Step 7: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. - -- [x] **Step 8: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): sessions, staff auth and adminctl" -``` - ---- - -### Task 6: Cloud customer auth - -**Files:** - -- Create: `admin/internal/auth/cloud.go` - -**Interfaces:** - -- Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models` -- Produces: `auth.HandleCloudLogin` - -- [x] **Step 1: Write it** - -Create `admin/internal/auth/cloud.go`: - -```go -package auth - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - adminmodels "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models" - "go.mongodb.org/mongo-driver/v2/bson" - "golang.org/x/crypto/bcrypt" -) - -// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's -// users collection, with the credentials they already have. -// -// Two consequences worth stating plainly, because they are real and were -// accepted deliberately: -// -// 1. A cloud user's control-plane password now also unlocks billing. Any -// password change or compromise has a wider blast radius than before. -// 2. Only control-plane role "owner" may sign in here. admin and member are -// refused — billing is an owner concern. -// -// Mitigations: rate limits, an identical error for every failure, and an audit -// entry for every attempt. -func HandleCloudLogin(c *gin.Context) { - var body struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) - return - } - email := strings.ToLower(strings.TrimSpace(body.Email)) - ctx := c.Request.Context() - - if !allowAttempt(email, c.ClientIP()) { - c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) - return - } - - reject := func(reason string) { - audit.Write(ctx, adminmodels.AuditEntry{ - Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason}) - c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) - } - - // A self-hosted customer_users row wins over a control-plane user with the - // same address. Documented so the behaviour is chosen rather than emergent. - if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 { - HandleCustomerLogin(c) - return - } - - var u sharedmodels.User - if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil { - bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password)) - reject("unknown email") - return - } - if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil { - reject("bad password") - return - } - if u.Role != sharedmodels.RoleOwner { - reject("role " + u.Role + " is not permitted") - return - } - - // Resolve the admin-side account that owns this user's instance. - var inst adminmodels.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil { - reject("no account for instance " + u.InstanceID) - return - } - - id, err := Save(ctx, Session{ - UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) - return - } - SetCookie(c, id) - clearAttempts(email) - audit.Write(ctx, adminmodels.AuditEntry{ - Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()}) - c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email}) -} -``` - -Check the field names on `sharedmodels.User` before building — use `UserID`, `Email`, `PasswordHash`, `Role`, `InstanceID` as defined in `shared/models/user.go`, and correct this file if they differ. - -- [x] **Step 2: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. (`HandleCustomerLogin` arrives in Task 7 — if you are building tasks in order, stub it as a 501 handler in `customer.go` and replace it there.) - -- [x] **Step 3: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): cloud owner login against the control plane" -``` - ---- - -### Task 7: Self-hosted customer auth - -**Files:** - -- Create: `admin/internal/auth/customer.go`, `admin/internal/mail/mail.go` - -**Interfaces:** - -- Consumes: `db.Admin("customer_users")`, config SMTP -- Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense` - -- [x] **Step 1: Write mail** - -Create `admin/internal/mail/mail.go`: - -```go -// Package mail delivers verification links and licence files. -package mail - -import ( - "fmt" - "net/smtp" - "strings" -) - -type Config struct { - Host, Port, From, Username, Password string - PublicURL string -} - -var cfg Config - -func Init(c Config) { cfg = c } - -func Enabled() bool { return cfg.Host != "" && cfg.From != "" } - -func send(to, subject, body string) error { - if !Enabled() { - return fmt.Errorf("SMTP is not configured") - } - msg := strings.Join([]string{ - "From: " + cfg.From, - "To: " + to, - "Subject: " + subject, - "MIME-Version: 1.0", - "Content-Type: text/plain; charset=utf-8", - "", body, - }, "\r\n") - - var auth smtp.Auth - if cfg.Username != "" { - auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) - } - return smtp.SendMail(cfg.Host+":"+cfg.Port, auth, cfg.From, []string{to}, []byte(msg)) -} - -func SendVerification(to, token string) error { - link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token) - return send(to, "Verify your Vantage account", - "Confirm your email address to finish setting up your Vantage account:\n\n"+ - link+"\n\nThis link expires in 24 hours.\n") -} - -// SendLicense delivers the blob inline. It is signed public data, not a secret — -// it is useless on any instance other than the one it names. -func SendLicense(to, instanceName, blob string) error { - return send(to, "Your Vantage licence key", - fmt.Sprintf("Your licence for %s is below.\n\n"+ - "Paste it into Settings \u2192 Licence on your Vantage install:\n\n%s\n", - instanceName, blob)) -} -``` - -- [x] **Step 2: Write customer auth** - -Create `admin/internal/auth/customer.go`: - -```go -package auth - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "go.mongodb.org/mongo-driver/v2/bson" - "golang.org/x/crypto/bcrypt" -) - -// BcryptCost matches the control plane and sitesvc. Changing it here alone would -// make hashes inconsistent across services that may one day compare them. -const BcryptCost = 12 - -// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the -// SHA-256 hash stored, 24-hour expiry. -const VerifyWindow = 24 * time.Hour - -// CreateCustomerUser creates an unverified self-hosted customer login and emails -// the verification link. Called during purchase (spec 5) and by staff. -func CreateCustomerUser(ctx context.Context, accountID, email, password string) error { - hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) - if err != nil { - return err - } - - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return err - } - token := hex.EncodeToString(raw) - sum := sha256.Sum256([]byte(token)) - expiry := time.Now().UTC().Add(VerifyWindow) - - u := models.CustomerUser{ - UserID: uuid.NewString(), - AccountID: accountID, - Email: strings.ToLower(strings.TrimSpace(email)), - PasswordHash: string(hash), - VerifyTokenHash: hex.EncodeToString(sum[:]), - VerifyTokenExpiry: &expiry, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil { - return err - } - return mail.SendVerification(u.Email, token) -} - -// HandleVerify consumes a verification token. -func HandleVerify(c *gin.Context) { - token := c.Query("token") - if token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"}) - return - } - sum := sha256.Sum256([]byte(token)) - now := time.Now().UTC() - - res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(), - bson.M{ - "verify_token_hash": hex.EncodeToString(sum[:]), - "verify_token_expiry": bson.M{"$gt": now}, - }, - bson.M{ - "$set": bson.M{"verified_at": now}, - "$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""}, - }) - if err != nil || res.MatchedCount == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"}) - return - } - c.JSON(http.StatusOK, gin.H{"verified": true}) -} - -// HandleCustomerLogin authenticates a self-hosted customer. -func HandleCustomerLogin(c *gin.Context) { - var body struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) - return - } - email := strings.ToLower(strings.TrimSpace(body.Email)) - ctx := c.Request.Context() - - if !allowAttempt(email, c.ClientIP()) { - c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) - return - } - - reject := func(reason string) { - audit.Write(ctx, models.AuditEntry{ - Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason}) - c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) - } - - var u models.CustomerUser - if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil { - bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password)) - reject("unknown email") - return - } - if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil { - reject("bad password") - return - } - if u.VerifiedAt == nil { - // Distinct from genericAuthError on purpose: the address is already - // known to be theirs, so there is nothing to disclose, and "check your - // email" is the only useful thing to say. - c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"}) - return - } - - id, err := Save(ctx, Session{ - UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) - return - } - SetCookie(c, id) - clearAttempts(email) - audit.Write(ctx, models.AuditEntry{ - Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()}) - c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email}) -} -``` - -- [x] **Step 3: Initialise mail at boot** - -In `admin/cmd/main.go`: - -```go - mail.Init(mail.Config{ - Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom, - Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, - PublicURL: cfg.PublicURL, - }) - if !mail.Enabled() { - log.Println("warning: SMTP not configured; verification and licence emails will fail") - } -``` - -- [x] **Step 4: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. - -- [x] **Step 5: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): self-hosted customer accounts with email verification" -``` - ---- - -### Task 8: Linking, relink and the customer API - -**Files:** - -- Create: `admin/internal/licensing/link.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go` -- Modify: `admin/cmd/main.go` - -**Interfaces:** - -- Consumes: `licensing.Issue`, `inject.Deliver`, `mail.SendLicense` -- Produces: - - `licensing.LinkInstance(ctx, accountID, instanceID, name string) (*models.Instance, error)` - - `licensing.Relink(ctx, accountID, oldID, newID string, staff bool) (*models.License, error)` - - `api.Routes(cfg) http.Handler` - - `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper - -- [x] **Step 1: Write linking and relink** - -Create `admin/internal/licensing/link.go`: - -```go -package licensing - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -var ( - ErrBadUUID = errors.New("that does not look like an instance ID") - ErrAlreadyLinked = errors.New("that instance ID is already linked to an account") - ErrRelinkLimit = errors.New("relink limit reached for this term; contact support") -) - -// LinkInstance attaches a self-hosted instance UUID to an account. -// -// The duplicate error deliberately does not say WHICH account holds it. It is a -// small enumeration surface, but there is no reason to leave it open. -func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) { - if _, err := uuid.Parse(instanceID); err != nil { - return nil, ErrBadUUID - } - - // A self-hosted UUID must not collide with a cloud instance either. - if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 { - return nil, ErrAlreadyLinked - } - - inst := models.Instance{ - InstanceID: instanceID, - AccountID: accountID, - Name: name, - Deployment: license.DeploymentSelfHosted, - Status: models.StatusActive, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { - if mongo.IsDuplicateKeyError(err) { - // The unique index is what actually prevents two accounts owning - // one instance. The check above is a nicety; this is the guarantee. - return nil, ErrAlreadyLinked - } - return nil, err - } - - audit.Write(ctx, models.AuditEntry{ - Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID}) - return &inst, nil -} - -// Relink moves a licence to a rebuilt server's new UUID. -// -// The replacement covers the REMAINING term, not a fresh one — relinking is not -// a way to extend a subscription. -// -// The old licence is not revoked, because offline verification has no -// revocation. It simply no longer matches any UUID the customer controls, and -// its binding stops it working on another machine anyway. -func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) { - if _, err := uuid.Parse(newID); err != nil { - return nil, ErrBadUUID - } - - var inst models.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil { - return nil, ErrUnknownInstance - } - - // The cap is a signal, not a defence. Its job is to put a human in front of - // the fourth attempt, so staff bypass it. - if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm { - return nil, ErrRelinkLimit - } - - if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 { - return nil, ErrAlreadyLinked - } - - // Preserve the remaining term from the current licence. - remaining := time.Now().UTC().Add(models.GracePeriod) - var current models.License - if err := db.Admin("licenses").FindOne(ctx, - bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err == nil { - remaining = current.ExpiresAt - } - - if _, err := db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": oldID}, - bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, ErrAlreadyLinked - } - return nil, fmt.Errorf("relink: %w", err) - } - - actor := accountID - if staff { - actor = "staff" - } - audit.Write(ctx, models.AuditEntry{ - Actor: actor, Action: "instance.relinked", AccountID: accountID, - Target: newID, Detail: "was " + oldID}) - - return Issue(ctx, IssueInput{ - InstanceID: newID, - Tier: inst.Tier, - ExpiresAt: remaining, - Reason: models.ReasonRelink, - IssuedBy: actor, - }) -} -``` - -- [x] **Step 2: Write the customer handlers** - -Create `admin/internal/api/customer.go`: - -```go -package api - -import ( - "errors" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// ownedInstance resolves an instance and confirms the session's account owns it. -// -// EVERY customer handler that names an instance must go through this. It returns -// 404 for another account's instance rather than 403: a 403 confirms the -// instance exists, which is an existence oracle over customer data. -func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) { - s := auth.Current(c) - if s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) - return nil, false - } - var inst models.Instance - err := db.Admin("admin_instances").FindOne(c.Request.Context(), - bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return nil, false - } - return &inst, true -} - -func getAccount(c *gin.Context) { - s := auth.Current(c) - ctx := c.Request.Context() - - var acct models.Account - if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - - cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - instances := []models.Instance{} - if err := cur.All(ctx, &instances); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances}) -} - -func linkInstance(c *gin.Context) { - var body struct { - InstanceID string `json:"instance_id"` - Name string `json:"name"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"}) - return - } - - s := auth.Current(c) - inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name) - if err != nil { - status := http.StatusBadRequest - if errors.Is(err, licensing.ErrAlreadyLinked) { - status = http.StatusConflict - } - c.JSON(status, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, inst) -} - -func relinkInstance(c *gin.Context) { - inst, ok := ownedInstance(c, c.Param("id")) - if !ok { - return - } - var body struct { - InstanceID string `json:"instance_id"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"}) - return - } - - s := auth.Current(c) - lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false) - if err != nil { - status := http.StatusBadRequest - if errors.Is(err, licensing.ErrRelinkLimit) { - status = http.StatusForbidden - } - c.JSON(status, gin.H{"error": err.Error()}) - return - } - deliver(c, inst, lic) - c.JSON(http.StatusOK, lic) -} - -func getInstanceLicense(c *gin.Context) { - inst, ok := ownedInstance(c, c.Param("id")) - if !ok { - return - } - var lic models.License - if err := db.Admin("licenses").FindOne(c.Request.Context(), - bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"}) - return - } - c.JSON(http.StatusOK, lic) -} - -func downloadInstanceLicense(c *gin.Context) { - inst, ok := ownedInstance(c, c.Param("id")) - if !ok { - return - } - var lic models.License - if err := db.Admin("licenses").FindOne(c.Request.Context(), - bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"}) - return - } - c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID)) - c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n")) -} - -func listSubscriptions(c *gin.Context) { - s := auth.Current(c) - cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - subs := []models.Subscription{} - if err := cur.All(c.Request.Context(), &subs); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, subs) -} - -// deliver sends a freshly issued licence where it needs to go. Cloud instances -// are injected; self-hosted customers are emailed and can download. -// -// Delivery failures are logged, never returned: the licence is already recorded, -// which is the part that must not be lost. -func deliver(c *gin.Context, inst *models.Instance, lic *models.License) { - if inst.Deployment == license.DeploymentCloud { - inject.Deliver(c.Request.Context(), lic) - return - } - s := auth.Current(c) - if s != nil && mail.Enabled() { - _ = mail.SendLicense(s.Email, inst.Name, lic.Blob) - } -} -``` - -- [x] **Step 3: Write the route table** - -Create `admin/internal/api/routes.go`: - -```go -// Package api mounts admin's HTTP surface. -// -// The route table is the single place scoping is guaranteed. Customer routes -// live behind RequireCustomer and every handler that names an instance calls -// ownedInstance. A new customer route that skips that helper is a scoping bug, -// so keep them together and review them together. -package api - -import ( - "net/http" - "slices" - - "github.com/gin-gonic/gin" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config" -) - -func Routes(cfg config.Config) http.Handler { - r := gin.New() - r.Use(gin.Logger(), gin.Recovery()) - r.Use(cors(cfg.AllowedOrigins)) - - if cfg.TrustProxy { - _ = r.SetTrustedProxies(nil) - } - - r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) - - r.POST("/auth/staff/login", auth.HandleStaffLogin) - r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login - r.POST("/auth/logout", auth.HandleLogout) - r.GET("/auth/verify", auth.HandleVerify) - - cust := r.Group("/api") - cust.Use(auth.RequireCustomer()) - { - cust.GET("/account", getAccount) - cust.POST("/instances/link", linkInstance) - cust.POST("/instances/:id/relink", relinkInstance) - cust.GET("/instances/:id/license", getInstanceLicense) - cust.GET("/instances/:id/license/download", downloadInstanceLicense) - cust.GET("/subscriptions", listSubscriptions) - } - - staff := r.Group("/api/staff") - staff.Use(auth.RequireStaff()) - { - staff.GET("/accounts", staffListAccounts) - staff.POST("/accounts", staffCreateAccount) - staff.GET("/accounts/:id", staffGetAccount) - staff.GET("/instances", staffListInstances) - staff.POST("/instances", staffCreateInstance) - staff.POST("/instances/:id/issue", staffIssue) - staff.POST("/instances/:id/relink", staffRelink) - staff.GET("/licenses", staffListLicenses) - staff.GET("/plans", staffListPlans) - staff.PUT("/plans/:tier", staffUpdatePlan) - staff.GET("/audit", staffAudit) - staff.GET("/health/injection", staffInjectionHealth) - } - - return r -} - -func cors(allowed []string) gin.HandlerFunc { - return func(c *gin.Context) { - origin := c.GetHeader("Origin") - if origin != "" && slices.Contains(allowed, origin) { - c.Header("Access-Control-Allow-Origin", origin) - c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Headers", "Content-Type") - c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") - } - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - return - } - c.Next() - } -} -``` - -- [x] **Step 4: Mount it** - -In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with: - -```go - Handler: api.Routes(cfg), -``` - -Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"`. - -- [x] **Step 5: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: failures naming the staff handlers, which arrive in Task 9. Build again after Task 9. - -- [x] **Step 6: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): linking, relink and the scoped customer API" -``` - ---- - -### Task 9: Staff API - -**Files:** - -- Create: `admin/internal/api/staff.go` - -**Interfaces:** - -- Consumes: `licensing.Issue`, `licensing.Relink`, `models` -- Produces: the handlers named in `routes.go` - -- [x] **Step 1: Write the handlers** - -Create `admin/internal/api/staff.go`: - -```go -package api - -import ( - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -func staffListAccounts(c *gin.Context) { - filter := bson.M{} - if q := c.Query("q"); q != "" { - filter["$or"] = []bson.M{ - {"name": bson.M{"$regex": q, "$options": "i"}}, - {"billing_email": bson.M{"$regex": q, "$options": "i"}}, - } - } - cur, err := db.Admin("accounts").Find(c.Request.Context(), filter, - options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}})) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - accounts := []models.Account{} - if err := cur.All(c.Request.Context(), &accounts); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, accounts) -} - -func staffCreateAccount(c *gin.Context) { - var body struct { - Name string `json:"name"` - BillingEmail string `json:"billing_email"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"}) - return - } - acct := models.Account{ - AccountID: uuid.NewString(), - Name: body.Name, - BillingEmail: body.BillingEmail, - Status: models.AccountActive, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, acct) -} - -func staffGetAccount(c *gin.Context) { - ctx := c.Request.Context() - var acct models.Account - if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}) - instances := []models.Instance{} - if cur != nil { - _ = cur.All(ctx, &instances) - } - c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances}) -} - -func staffListInstances(c *gin.Context) { - filter := bson.M{} - for param, field := range map[string]string{ - "account_id": "account_id", - "deployment": "deployment", - "status": "status", - } { - if v := c.Query(param); v != "" { - filter[field] = v - } - } - if c.Query("expiring") == "true" { - // Instances whose licence expires within 14 days, for renewal chasing. - var ids []string - cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{ - "superseded_by": bson.M{"$exists": false}, - "expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)}, - }) - if err == nil { - var lics []models.License - if cur.All(c.Request.Context(), &lics) == nil { - for _, l := range lics { - ids = append(ids, l.InstanceID) - } - } - } - filter["instance_id"] = bson.M{"$in": ids} - } - - cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter, - options.Find().SetLimit(500)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - instances := []models.Instance{} - if err := cur.All(c.Request.Context(), &instances); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, instances) -} - -// staffCreateInstance attaches an instance to an account. -// -// For cloud, this ADOPTS an instance that already exists in the control plane — -// the control-plane row is the source of truth for its name and slug, and this -// refuses if no such instance exists, because an admin row pointing at nothing -// would issue licences nobody can use. -// -// For self-hosted it does the same job as the customer-facing link endpoint, so -// staff can link on a customer's behalf during support. -// -// This is how existing cloud instances get licensed: adopt, then issue. -func staffCreateInstance(c *gin.Context) { - var body struct { - InstanceID string `json:"instance_id"` - AccountID string `json:"account_id"` - Deployment string `json:"deployment"` - Name string `json:"name"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"}) - return - } - ctx := c.Request.Context() - - if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "no such account"}) - return - } - - inst := models.Instance{ - InstanceID: body.InstanceID, - AccountID: body.AccountID, - Name: body.Name, - Deployment: body.Deployment, - Status: models.StatusActive, - CreatedAt: time.Now().UTC(), - } - - if body.Deployment == license.DeploymentCloud { - var remote sharedmodels.Instance - if err := db.Control("instances").FindOne(ctx, - bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"}) - return - } - inst.Name = remote.Name - inst.Slug = remote.Slug - } - - if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { - if mongo.IsDuplicateKeyError(err) { - c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - s := auth.Current(c) - audit.Write(ctx, models.AuditEntry{ - Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID}) - c.JSON(http.StatusCreated, inst) -} - -func staffIssue(c *gin.Context) { - var body struct { - Tier string `json:"tier"` - Term string `json:"term"` - Reason string `json:"reason"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"}) - return - } - if body.Reason == "" { - body.Reason = models.ReasonManual - } - - s := auth.Current(c) - lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{ - InstanceID: c.Param("id"), - Tier: body.Tier, - Term: body.Term, - Reason: body.Reason, - IssuedBy: s.Email, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var inst models.Instance - if db.Admin("admin_instances").FindOne(c.Request.Context(), - bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil { - deliver(c, &inst, lic) - } - c.JSON(http.StatusCreated, lic) -} - -// staffRelink has no attempt cap. The customer-facing limit exists to put a -// human in front of the fourth attempt; this is that human. -func staffRelink(c *gin.Context) { - var body struct { - InstanceID string `json:"instance_id"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"}) - return - } - ctx := c.Request.Context() - - var inst models.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - - lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, lic) -} - -func staffListLicenses(c *gin.Context) { - filter := bson.M{} - if v := c.Query("instance_id"); v != "" { - filter["instance_id"] = v - } - if v := c.Query("account_id"); v != "" { - filter["account_id"] = v - } - cur, err := db.Admin("licenses").Find(c.Request.Context(), filter, - options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}})) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - lics := []models.License{} - if err := cur.All(c.Request.Context(), &lics); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, lics) -} - -func staffListPlans(c *gin.Context) { - cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - plans := []models.Plan{} - if err := cur.All(c.Request.Context(), &plans); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, plans) -} - -// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences -// snapshotted their plan at issue time and are unaffected — the same rule as -// workflow_runs.steps_snapshot. -func staffUpdatePlan(c *gin.Context) { - var body models.Plan - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"}) - return - } - set := bson.M{ - "name": body.Name, - "limits": body.Limits, - "features": body.Features, - "paddle_product_id": body.PaddleProductID, - "paddle_price_ids": body.PaddlePriceIDs, - "active": body.Active, - } - if _, err := db.Admin("plans").UpdateOne(c.Request.Context(), - bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"updated": true}) -} - -func staffAudit(c *gin.Context) { - cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{}, - options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}})) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - entries := []models.AuditEntry{} - if err := cur.All(c.Request.Context(), &entries); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, entries) -} - -// staffInjectionHealth lists instances whose last injection failed. This is the -// page to look at when a customer says their cloud instance is read-only. -func staffInjectionHealth(c *gin.Context) { - cur, err := db.Admin("admin_instances").Find(c.Request.Context(), - bson.M{"inject_failed_at": bson.M{"$exists": true}}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - failed := []models.Instance{} - if err := cur.All(c.Request.Context(), &failed); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)}) -} -``` - -- [x] **Step 2: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. This is the first build where the whole service compiles. - -- [x] **Step 3: Audit customer scoping by hand** - -With no test suite, this replaces spec 3's table-driven test 20. - -```bash -grep -n "cust\." admin/internal/api/routes.go -grep -n "func \(getAccount\|linkInstance\|relinkInstance\|getInstanceLicense\|downloadInstanceLicense\|listSubscriptions\)" -A 12 admin/internal/api/customer.go | grep -n "ownedInstance\|s.AccountID" -``` - -Every customer route must either call `ownedInstance` or filter by `s.AccountID`. A route that does neither is a data leak — fix before continuing. - -- [x] **Step 4: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): staff API" -``` - ---- - -### Task 10: Deployment wiring - -**Files:** - -- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml` - -- [x] **Step 1: Add the service** - -In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`: - -```yaml -admin: - image: ${DOCKER_HOST}/mrhid6/vantage-admin:latest - restart: unless-stopped - ports: - - "8083:8083" - environment: - ADMIN_MONGO_URI: ${ADMIN_MONGO_URI} - CONTROL_MONGO_URI: ${CONTROL_MONGO_URI} - REDIS_ADDR: redis:6379 - LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY} - PUBLIC_URL: ${ADMIN_PUBLIC_URL} - ADMIN_ORIGIN: ${ADMIN_ORIGIN} - SMTP_HOST: ${SMTP_HOST} - SMTP_PORT: ${SMTP_PORT} - SMTP_FROM: ${SMTP_FROM} - SMTP_USERNAME: ${SMTP_USERNAME} - SMTP_PASSWORD: ${SMTP_PASSWORD} - TRUST_PROXY: "true" - depends_on: - - redis -``` - -`LICENSE_SIGNING_KEY` appears in exactly one service in exactly one compose file. It must never be added to `server`, and `deploy/docker-compose.yml` — the self-hosted deployment — must not mention admin at all. - -- [x] **Step 2: Add the build** - -In `.gitea/workflows/server-deploy.yml`, add a fourth image alongside `server`, `web`, `site` and `sitesvc`, with context `.` and file `admin/Dockerfile`, following the pattern the other Go services already use. - -- [x] **Step 3: Confirm the self-hosted deployment is untouched** - -```bash -grep -c "admin" deploy/docker-compose.yml -``` - -Expected: `0`. - -- [x] **Step 4: Commit** - -```bash -git add deploy/ .gitea/ -git commit -m "feat(admin): compose service and image build" -``` - ---- - -### Task 11: Full verification - -Everything in containers. With no test suite this is the only evidence. - -**Files:** none - -- [x] **Step 1: Build every module and the image** - -```bash -cd c:/Work/Repos/vantage -for m in shared server sitesvc admin; do sh /tmp/gorun.sh $m go build ./... && sh /tmp/gorun.sh $m go vet ./...; done -MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ - -v vantage-gocache:/root/.cache/go-build -w /src/agent -e GOWORK=off golang:1.26 \ - sh -c "go build ./... && go vet ./..." -docker build -q -f admin/Dockerfile -t vantage-admin:test . -docker build -q -f server/Dockerfile -t vantage-server:test . -``` - -Expected: all succeed. The image build is the one that catches a `go.sum` the workspace was masking — it caught exactly that in plan 2. - -- [x] **Step 2: Start the supporting containers** - -```bash -docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine -``` - -MongoDB is expected on the host at `27021`, as in plan 2. - -- [x] **Step 3: Boot the control plane and bootstrap a cloud instance** - -```bash -docker run -d --name vadmin-server -p 8080:8080 \ - -e MONGO_URI=mongodb://host.docker.internal:27021 -e MONGO_DB=vantage_admin_test \ - -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6399 \ - -e VANTAGE_DEPLOYMENT=cloud \ - -e KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 \ - --add-host host.docker.internal:host-gateway vantage-server:test -sleep 12 -curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json' \ - -d '{"instance_name":"Admin Test","email":"owner@example.com","password":"hunter2hunter2"}' -``` - -Record the `instance_id`. - -- [x] **Step 4: Boot admin** - -```bash -docker run -d --name vadmin -p 8083:8083 \ - -e ADMIN_MONGO_URI=mongodb://host.docker.internal:27021/vantage_admin_own \ - -e CONTROL_MONGO_URI=mongodb://host.docker.internal:27021/vantage_admin_test \ - -e REDIS_ADDR=host.docker.internal:6399 \ - -e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" \ - -e PUBLIC_URL=http://localhost:8083 -e ADMIN_ORIGIN=http://localhost:3002 \ - --add-host host.docker.internal:host-gateway vantage-admin:test -sleep 8 -curl -s localhost:8083/healthz -docker logs vadmin 2>&1 | tail -5 -``` - -Expected: `{"ok":true}` and a clean log. Then confirm it refuses to start without a key: - -```bash -docker run --rm -e ADMIN_MONGO_URI=mongodb://x/y -e CONTROL_MONGO_URI=mongodb://x/z \ - -e REDIS_ADDR=x:6379 -e PUBLIC_URL=x -e ADMIN_ORIGIN=x vantage-admin:test -``` - -Expected: exits non-zero with `missing required environment: LICENSE_SIGNING_KEY`. - -- [x] **Step 5: Adopt the cloud instance and issue through the staff API** - -This is the flow the admin UI will drive when you licence your existing cloud -instances by hand. - -```bash -docker exec vadmin adminctl staff-add --email=staff@example.com --name=Staff --password=correcthorsebattery - -curl -s -X POST localhost:8083/auth/staff/login -H 'Content-Type: application/json' \ - -d '{"email":"staff@example.com","password":"correcthorsebattery"}' -c /tmp/s.txt - -ACCOUNT=$(curl -s -X POST localhost:8083/api/staff/accounts -b /tmp/s.txt \ - -H 'Content-Type: application/json' \ - -d '{"name":"Admin Test","billing_email":"owner@example.com"}' \ - | grep -o '"account_id":"[^"]*"' | cut -d'"' -f4) - -INSTANCE= - -curl -s -X POST localhost:8083/api/staff/instances -b /tmp/s.txt \ - -H 'Content-Type: application/json' \ - -d "{\"instance_id\":\"$INSTANCE\",\"account_id\":\"$ACCOUNT\",\"deployment\":\"cloud\"}" - -curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \ - -H 'Content-Type: application/json' -d '{"tier":"professional","term":"annual"}' - -sleep 3 -curl -s -X POST localhost:8080/auth/login -H 'Content-Type: application/json' \ - -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/a.txt -curl -s localhost:8080/api/license -b /tmp/a.txt -``` - -Expected: adopting the instance returns 201, issuing returns 201, and the control -plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds, -with no restart**. This is the whole system working end to end. - -- [x] **Step 6: Confirm the Free rule and the deployment check** - -Reusing the staff session and `$INSTANCE` from step 5: - -```bash -curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \ - -H 'Content-Type: application/json' -d '{"tier":"self_hosted","term":"annual"}' -``` - -Expected: `"that plan is not available for this deployment type"` — a self-hosted plan cannot be issued to a cloud instance, and the mirror of that check is what makes Free cloud-only. - -- [x] **Step 7: Confirm supersession and the snapshot rule** - -```bash -curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \ - -H 'Content-Type: application/json' -d '{"tier":"professional","term":"annual","reason":"renewal"}' -curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt -``` - -Expected: two licence rows; the older one carries `superseded_by` naming the newer. Nothing was deleted. - -Then edit the plan and confirm the issued licence is unchanged: - -```bash -curl -s -X PUT localhost:8083/api/staff/plans/professional -b /tmp/s.txt \ - -H 'Content-Type: application/json' \ - -d '{"name":"Professional","limits":{"max_servers":7,"max_secret_groups":7,"max_channels":7},"features":["console"],"active":true}' -curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt -``` - -Expected: the existing licences still report `max_servers: -1`. Editing a plan never rewrites history. - -- [x] **Step 8: Confirm reconciliation repairs a tampered instance** - -```bash -docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \ - "mongodb://host.docker.internal:27021/vantage_admin_test" --quiet \ - --eval 'db.instances.updateOne({}, {$set:{license_blob:"TAMPERED"}})' -docker exec vadmin sh -c 'kill -USR1 1' 2>/dev/null || true -``` - -The reconciler runs every 15 minutes, so either wait, or restart admin to trigger a cycle and confirm from the logs: - -```bash -docker restart vadmin && sleep 8 -curl -s localhost:8080/api/license -b /tmp/a.txt -``` - -Expected: the control plane reports `valid` again once reconciliation has run — the blob is restored from what admin believes it should be. Confirm `reconcile: repairing instance` appears in `docker logs vadmin`. - -- [x] **Step 9: Confirm customer scoping returns 404, not 403** - -Create a second account and instance via the staff API, sign in as the first customer, and request the second account's instance: - -```bash -curl -s -o /dev/null -w "other account's instance: %{http_code}\n" \ - localhost:8083/api/instances//license -b /tmp/c.txt -``` - -Expected: **404**. A 403 would confirm the instance exists. - -- [x] **Step 10: Confirm admin is not a runtime dependency** - -```bash -docker stop vadmin -curl -s localhost:8080/api/license -b /tmp/a.txt -curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/api/servers/new -b /tmp/a.txt -``` - -Expected: the licence still reports `valid` and mutations still work. **This is the most important check in the plan.** Instances verify offline and must never call admin. - -- [x] **Step 11: Confirm the control-plane write surface** - -```bash -docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \ - "mongodb://host.docker.internal:27021/vantage_admin_test" --quiet \ - --eval 'db.getCollectionNames().forEach(n => print(n + ": " + db[n].countDocuments()))' -``` - -Expected: `servers`, `keys`, `secrets` and every other collection are exactly as the control plane left them. Admin touched only `instances`. - -- [x] **Step 12: Clean up and commit** - -```bash -docker rm -f vadmin vadmin-server vadmin-redis -docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \ - "mongodb://host.docker.internal:27021" --quiet \ - --eval 'db.getSiblingDB("vantage_admin_test").dropDatabase(); db.getSiblingDB("vantage_admin_own").dropDatabase()' -rm -f /tmp/a.txt /tmp/s.txt /tmp/c.txt -unset LICENSE_SIGNING_KEY - -git add -A -git commit -m "chore: verify the admin backend end to end" --allow-empty -``` - ---- - -## Rollout - -1. Deploy admin with `LICENSE_SIGNING_KEY` set, alongside the existing site stack. -2. `adminctl staff-add` for each staff member. There is no signup. -3. Spec 4 (the site) and spec 5 (Paddle) can then proceed in parallel. -4. Once the UI exists, licence the existing cloud instances by hand: attach each to an account, then issue. **They stay read-only until that is done**, so it is the first thing the UI is used for, not the last. - -## Risks - -| Risk | Mitigation | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| Admin becomes a runtime dependency | Task 11 Step 10 verifies instances work with admin stopped | -| Signing key exposure | One service, one variable, one compose file; never in `server`; rotation path from plan 1 | -| Cloud password now unlocks billing | Owner-only, rate-limited, every attempt audited; state it in the release notes | -| Injection silently fails | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection` | -| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it | -| Self-hosted UUID squatted | Unique index on `admin_instances.instance_id`, non-disclosing error | -| A customer route forgets to scope | `ownedInstance` helper, audited by hand in Task 9 Step 3 | -| Existing cloud instances stay read-only longer than intended | Licensing them is the first job the admin UI is used for; Task 11 Step 5 proves the flow before the UI exists | -| An admin instance row points at no real cloud instance | `staffCreateInstance` refuses a cloud instance the control plane does not have | diff --git a/docs/superpowers/plans/2026-07-24-instance-licensing.md b/docs/superpowers/plans/2026-07-24-instance-licensing.md deleted file mode 100644 index 9c96cd4..0000000 --- a/docs/superpowers/plans/2026-07-24-instance-licensing.md +++ /dev/null @@ -1,1664 +0,0 @@ -# Instance Licensing and Enforcement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Store a licence on the instance document, resolve it into a cached runtime state, gate every mutating API route on it, enforce countable limits in the service layer, and let an owner paste a licence in. - -**Architecture:** Three enforcement layers that answer different questions — a group-level gin middleware for mutations (deny by default), a per-route middleware for features, and service-layer checks for limits, which need a count middleware does not have. Monitors keep executing when a licence lapses; growth and change stop, running infrastructure does not. - -**Tech Stack:** Go 1.26, gin, MongoDB driver v2, `shared/license` from plan 1, Next.js 16 + TanStack Query. - -**No automated tests.** Verification is by compiler, `grep`, `lkctl`-issued licences, and running the Docker images against a scratch database. - -## Global Constraints - -- Depends on plan 1 (`licensing-core`) being merged, with a real key in `trustedPublicKeys`. -- **An expired licence must never break a running fleet.** Agents keep their keys, monitors keep checking, alerts keep firing. -- `expired` and `invalid` degrade identically and differ only in the message shown. -- `DELETE` on any resource is **always** permitted, in every licence state. A customer downgraded to Free with 10 servers must be able to remove 7. -- `POST /api/servers/:id/apply-updates` is always permitted. Security patching is never paywalled. -- Over-limit instances are **never** truncated. Existing resources keep running; only creation stops. -- `VANTAGE_DEPLOYMENT` defaults to `self_hosted` — the stricter mode — when unset or unrecognised. -- Do not run the app with `go run`. Build the images and run containers. -- Local environment: MongoDB on `27021`, Mailpit SMTP `1031` / UI `8031`. Containers reach the host via `host.docker.internal` with `--add-host host.docker.internal:host-gateway`. - ---- - -## File Structure - -**Created:** - -| Path | Responsibility | -| -------------------------------------------- | ------------------------------------------------------------------------------------ | -| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode | -| `server/internal/services/licence_limits.go` | `CheckServerLimit`, `CheckSecretGroupLimit`, `CheckChannelLimit`, `ErrLimitExceeded` | -| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` | -| `web/app/(app)/settings/license/page.tsx` | Licence settings page | -| `web/components/LicenseBanner.tsx` | Persistent banner | -| `web/lib/useLicense.ts` | `useLicense()` hook | - -**Modified:** `shared/models/instance.go`, `server/internal/api/handlers.go`, `server/internal/services/servers.go`, `secrets.go`, `channels.go`, `server/internal/auth/oidc.go`, `server/internal/auth/instancehost.go`, `server/internal/api/org.go` (renamed), `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/layout.tsx`, `web/app/setup/page.tsx`. - ---- - -### Task 1: Finish the Org to Instance rename - -Plan 0b's naming map said the rename was total. It is not: 18 private identifiers still say `Org`. None affect the wire format, the database or any route, so nothing is broken — but this plan adds a licence cache next to the instance cache in the same file, and leaving two naming conventions side by side there is how the next person gets confused. - -**Files:** - -- Modify: `server/internal/auth/instancehost.go`, `server/internal/auth/oidc.go`, `server/internal/auth/session.go` -- Modify: `server/internal/services/instance_oidc.go` -- Rename: `server/internal/api/org.go` → `server/internal/api/instance.go` - -**Interfaces:** - -- Consumes: nothing -- Produces: `services.SaveInstanceOIDC` replacing `SaveOrgOIDC`; `auth.SaveStateInstance` / `auth.ConsumeStateInstance` replacing the `...Org` forms - -- [ ] **Step 1: Rename the file** - -```bash -cd c:/Work/Repos/vantage -git mv server/internal/api/org.go server/internal/api/instance.go -``` - -- [ ] **Step 2: Rename the identifiers** - -```bash -cd c:/Work/Repos/vantage/server -FILES=$(find . -name '*.go' -not -name 'migrate.go') -sed -i 's/\bcachedOrg\b/cachedInstance/g' $FILES -sed -i 's/\borgCacheMu\b/instanceCacheMu/g' $FILES -sed -i 's/\borgCacheTTL\b/instanceCacheTTL/g' $FILES -sed -i 's/\borgCache\b/instanceCache/g' $FILES -sed -i 's/\bproviderForOrg\b/providerForInstance/g' $FILES -sed -i 's/\bSaveStateOrg\b/SaveStateInstance/g' $FILES -sed -i 's/\bConsumeStateOrg\b/ConsumeStateInstance/g' $FILES -sed -i 's/\bSaveOrgOIDC\b/SaveInstanceOIDC/g' $FILES -sed -i 's/\bGetOrgOIDC\b/GetInstanceOIDC/g' $FILES -``` - -The `migrate.go` exclusion is deliberate and permanent: migrations 0001 to 0003 run before the rename and must keep speaking the pre-rename shape. - -- [ ] **Step 3: Fix the struct field left behind** - -`cachedInstance` still has a field named `org`. In `server/internal/auth/instancehost.go`, change the struct and its two uses: - -```go -type cachedInstance struct { - instance *models.Instance - at time.Time -} -``` - -Then update the read and write sites in `InstanceFromHost` to use `e.instance` and `cachedInstance{instance: inst, at: time.Now()}`. `go build` will point at both. - -- [ ] **Step 4: Build and confirm** - -```bash -cd c:/Work/Repos/vantage/server -go build ./... && go vet ./... -cd .. -grep -rn "\bOrg\b\|orgCache\|cachedOrg\|ForOrg\|StateOrg\|OrgOIDC" --include=*.go server/ | grep -v migrate.go -``` - -Expected: no build output, and the grep returns only `MigrateOrgToInstance` in `main.go` and `migrate_instance.go`, which are migration names and must not change. - -- [ ] **Step 5: Commit** - -```bash -git add server/ -git commit -m "refactor(server): finish the Org to Instance rename - -Private identifiers plan 0b's naming map missed. No wire format, database -field or route changes." -``` - ---- - -### Task 2: Store the licence on the instance - -**Files:** - -- Modify: `shared/models/instance.go` - -**Interfaces:** - -- Consumes: nothing -- Produces: `models.Instance` gains `LicenseBlob string`, `LicenseTier string`, `LicenseExpiry *time.Time` - -- [ ] **Step 1: Add the fields** - -Replace the `Instance` struct in `shared/models/instance.go`: - -```go -// Instance is one deployment of Vantage: its own subdomain, users, servers, -// keys, workflows, monitors and secrets. It is the unit a licence attaches to. -// -// A paying customer may hold several. That grouping is called an Account and -// lives only in the admin control plane — this service never sees it. -type Instance struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - InstanceID string `bson:"instance_id" json:"instance_id"` - Name string `bson:"name" json:"name"` - Slug string `bson:"slug" json:"slug"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - - // LicenseBlob is the authoritative licence. LicenseTier and LicenseExpiry - // are a denormalised cache for listing and for the admin service's queries, - // rewritten from the verified payload every time a blob is accepted. - // Nothing reads them for enforcement. - // - // The blob is json:"-" because there is no reason to spray it through API - // responses. It is signed public data, not a secret. - LicenseBlob string `bson:"license_blob,omitempty" json:"-"` - LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"` - LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"` -} -``` - -No migration is needed: `omitempty` means existing documents simply have no licence, which resolves to `no_license`. - -- [ ] **Step 2: Build** - -```bash -cd c:/Work/Repos/vantage -(cd shared && go build ./...) && (cd server && go build ./...) && (cd sitesvc && go build ./...) -``` - -Expected: no output. - -- [ ] **Step 3: Commit** - -```bash -git add shared/models/instance.go -git commit -m "feat(shared): add licence fields to Instance" -``` - ---- - -### Task 3: Runtime licence state - -**Files:** - -- Create: `server/internal/services/licence.go` - -**Interfaces:** - -- Consumes: `license.Verify`, `license.Result`, `services.GetInstance` -- Produces: - - `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }` - - `func (s LicenseState) Active() bool` - - `func (s LicenseState) Feature(name string) bool` - - `func DeploymentMode() string` - - `func GetLicenseState(instanceID string) LicenseState` - - `func StoreLicense(instanceID, blob string) (LicenseState, error)` - - `func InvalidateLicenseCache(instanceID string)` - -- [ ] **Step 1: Write it** - -Create `server/internal/services/licence.go`: - -```go -package services - -import ( - "context" - "fmt" - "os" - "strings" - "sync" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// LicenseState is the resolved licence for one instance. -type LicenseState struct { - Status license.State `json:"state"` - Reason string `json:"reason,omitempty"` - Tier string `json:"tier,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - Limits license.Limits `json:"limits"` - Features map[string]bool `json:"features"` - // Source is "stored", "env" or "none" — useful when a self-hosted operator - // asks why the licence they pasted is not the one in effect. - Source string `json:"source"` -} - -// Active reports whether mutations are allowed. -func (s LicenseState) Active() bool { return s.Status == license.StateValid } - -// Feature reports whether a named feature is granted. -func (s LicenseState) Feature(name string) bool { return s.Features[name] } - -// DeploymentMode is how this install describes itself to the verifier. -// -// It defaults to self_hosted, the stricter mode. An operator who removes the -// variable gets the tighter behaviour, not the looser one. -func DeploymentMode() string { - if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud { - return license.DeploymentCloud - } - return license.DeploymentSelfHosted -} - -type cachedLicense struct { - state LicenseState - at time.Time -} - -var ( - licenseCacheMu sync.Mutex - licenseCache = map[string]cachedLicense{} -) - -const licenseCacheTTL = 60 * time.Second - -// InvalidateLicenseCache drops the cached state for one instance, so a pasted -// licence takes effect immediately rather than within the TTL. -func InvalidateLicenseCache(instanceID string) { - licenseCacheMu.Lock() - delete(licenseCache, instanceID) - licenseCacheMu.Unlock() -} - -// GetLicenseState resolves the licence for an instance, cached for 60 seconds. -// -// Resolution order: -// -// 1. the blob stored on the instance document -// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an -// automated self-hosted deployment can ship a licence without a human -// pasting one -// 3. neither -> invalid / no_license -// -// A blob stored through the UI always wins afterwards, so an operator is never -// locked out by a stale environment value. -func GetLicenseState(instanceID string) LicenseState { - licenseCacheMu.Lock() - if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL { - licenseCacheMu.Unlock() - return e.state - } - licenseCacheMu.Unlock() - - state := resolveLicenseState(instanceID) - - licenseCacheMu.Lock() - licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()} - licenseCacheMu.Unlock() - return state -} - -func resolveLicenseState(instanceID string) LicenseState { - inst, err := GetInstance(instanceID) - if err != nil { - return LicenseState{ - Status: license.StateInvalid, - Reason: license.ReasonNoLicense, - Features: map[string]bool{}, - Source: "none", - } - } - - blob, source := inst.LicenseBlob, "stored" - if blob == "" { - blob, source = os.Getenv("VANTAGE_LICENSE"), "env" - } - if blob == "" { - return LicenseState{ - Status: license.StateInvalid, - Reason: license.ReasonNoLicense, - Features: map[string]bool{}, - Source: "none", - } - } - - res := license.Verify(blob, license.VerifyOpts{ - InstanceID: instanceID, - Deployment: DeploymentMode(), - }) - return stateFromResult(res, source) -} - -func stateFromResult(res license.Result, source string) LicenseState { - feats := map[string]bool{} - for _, f := range res.License.Features { - feats[f] = true - } - s := LicenseState{ - Status: res.State, - Reason: res.Reason, - Tier: res.License.Tier, - Limits: res.License.Limits, - Features: feats, - Source: source, - } - if !res.License.ExpiresAt.IsZero() { - exp := res.License.ExpiresAt - s.ExpiresAt = &exp - } - return s -} - -// StoreLicense verifies a blob against this instance and stores it. -// -// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired -// and when. An invalid blob is rejected and the previous one kept. -func StoreLicense(instanceID, blob string) (LicenseState, error) { - blob = strings.TrimSpace(blob) - - res := license.Verify(blob, license.VerifyOpts{ - InstanceID: instanceID, - Deployment: DeploymentMode(), - }) - if res.State == license.StateInvalid { - return LicenseState{}, fmt.Errorf("%s", res.Reason) - } - - set := bson.M{ - "license_blob": blob, - "license_tier": res.License.Tier, - "license_expiry": res.License.ExpiresAt, - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := db.Col("instances").UpdateOne(ctx, - bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil { - return LicenseState{}, err - } - - InvalidateLicenseCache(instanceID) - return stateFromResult(res, "stored"), nil -} -``` - -- [ ] **Step 2: Build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: no output. - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/services/licence.go -git commit -m "feat(server): resolve licence state per instance" -``` - ---- - -### Task 4: Limits in the service layer - -Limits need a count, which middleware does not have. - -**Files:** - -- Create: `server/internal/services/licence_limits.go` -- Modify: `server/internal/services/servers.go` (`CreateServer`), `secrets.go` (`UpsertSecrets`), `channels.go` (`CreateChannel`) - -**Interfaces:** - -- Consumes: `GetLicenseState`, `license.WithinLimit` -- Produces: - - `type LimitError struct { Limit string; Current, Max int }` with `Error() string` - - `func CheckServerLimit(instanceID string) error` - - `func CheckSecretGroupLimit(instanceID, group string) error` - - `func CheckChannelLimit(instanceID string) error` - - `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)` - -- [ ] **Step 1: Write the limit checks** - -Create `server/internal/services/licence_limits.go`: - -```go -package services - -import ( - "context" - "fmt" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// LimitError is returned when a licence cap would be exceeded. The API maps it -// to 403 with a machine-readable body. -type LimitError struct { - Limit string - Current int - Max int -} - -func (e *LimitError) Error() string { - return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max) -} - -func limitCtx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 5*time.Second) -} - -// CheckServerLimit refuses a new server when the instance is at its cap. -// -// Counts live rows only. An instance already over its cap keeps every server it -// has — nothing is truncated — it simply cannot add another. -func CheckServerLimit(instanceID string) error { - st := GetLicenseState(instanceID) - ctx, cancel := limitCtx() - defer cancel() - - n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}) - if err != nil { - return err - } - if !license.WithinLimit(int(n), st.Limits.MaxServers) { - return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers} - } - return nil -} - -// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that -// already exists is always allowed, so a capped customer can still rotate the -// secrets they have. -func CheckSecretGroupLimit(instanceID, group string) error { - st := GetLicenseState(instanceID) - ctx, cancel := limitCtx() - defer cancel() - - existing, err := db.Col("secrets").CountDocuments(ctx, - bson.M{"instance_id": instanceID, "group": group}) - if err != nil { - return err - } - if existing > 0 { - return nil - } - - var groups []string - if err := db.Col("secrets").Distinct(ctx, "group", - bson.M{"instance_id": instanceID}).Decode(&groups); err != nil { - return err - } - if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) { - return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups} - } - return nil -} - -// CheckChannelLimit refuses a new notification channel at the cap. -func CheckChannelLimit(instanceID string) error { - st := GetLicenseState(instanceID) - ctx, cancel := limitCtx() - defer cancel() - - n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID}) - if err != nil { - return err - } - if !license.WithinLimit(int(n), st.Limits.MaxChannels) { - return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels} - } - return nil -} - -// LicenseUsage reports current counts, so the UI can say "12 of 3 servers" -// honestly when an instance is over its cap rather than pretending. -func LicenseUsage(instanceID string) (servers, secretGroups, channels int) { - ctx, cancel := limitCtx() - defer cancel() - - if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil { - servers = int(n) - } - var groups []string - if err := db.Col("secrets").Distinct(ctx, "group", - bson.M{"instance_id": instanceID}).Decode(&groups); err == nil { - secretGroups = len(groups) - } - if n, err := db.Col("notification_channels").CountDocuments(ctx, - bson.M{"instance_id": instanceID}); err == nil { - channels = int(n) - } - return -} -``` - -- [ ] **Step 2: Enforce in CreateServer** - -In `server/internal/services/servers.go`, at the top of `CreateServer`: - -```go -func CreateServer(instanceID string) (*models.Server, string, error) { - if err := CheckServerLimit(instanceID); err != nil { - return nil, "", err - } - // ... existing body unchanged -``` - -- [ ] **Step 3: Enforce in UpsertSecrets** - -In `server/internal/services/secrets.go`, at the top of `UpsertSecrets`: - -```go -func UpsertSecrets(instanceID, group string, values map[string]string) error { - if err := CheckSecretGroupLimit(instanceID, group); err != nil { - return err - } - // ... existing body unchanged -``` - -- [ ] **Step 4: Enforce in CreateChannel** - -In `server/internal/services/channels.go`, at the top of `CreateChannel`: - -```go -func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) { - if err := CheckChannelLimit(instanceID); err != nil { - return nil, err - } - // ... existing body unchanged -``` - -- [ ] **Step 5: Build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: no output. - -- [ ] **Step 6: Commit** - -```bash -git add server/internal/services/ -git commit -m "feat(server): enforce licence limits on servers, secret groups and channels" -``` - ---- - -### Task 5: The API and the two middlewares - -**Files:** - -- Create: `server/internal/api/licence.go` -- Modify: `server/internal/api/handlers.go` - -**Interfaces:** - -- Consumes: `GetLicenseState`, `StoreLicense`, `LicenseUsage`, `LimitError` -- Produces: - - `func RequireActiveLicense() gin.HandlerFunc` - - `func RequireFeature(name string) gin.HandlerFunc` - - `GET /api/license`, `POST /api/license` - -- [ ] **Step 1: Write the handlers and middlewares** - -Create `server/internal/api/licence.go`: - -```go -package api - -import ( - "errors" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" - "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" - "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" - "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" -) - -// licenceExemptPaths are routes that must work while a licence is expired or -// missing, because they are how a customer recovers or stays safe. -// -// /api/license pasting a valid licence is the way out of degraded mode -// apply-updates security patching is never paywalled -// -// All DELETE requests are exempt separately (see RequireActiveLicense): a -// customer downgraded below their current usage must be able to delete their -// way back under the cap. -var licenceExemptPaths = map[string]bool{ - "/api/license": true, -} - -func licenceExempt(c *gin.Context) bool { - if c.Request.Method == http.MethodDelete { - return true - } - if licenceExemptPaths[c.FullPath()] { - return true - } - if c.FullPath() == "/api/servers/:id/apply-updates" { - return true - } - return false -} - -// RequireActiveLicense blocks mutating requests when the licence is not valid. -// -// Mounted on the /api group, so a route added tomorrow is gated because of where -// it lives rather than because someone remembered. GET and HEAD always pass — -// reading is never blocked. -func RequireActiveLicense() gin.HandlerFunc { - return func(c *gin.Context) { - if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead { - c.Next() - return - } - if licenceExempt(c) { - c.Next() - return - } - - st := services.GetLicenseState(auth.InstanceID(c)) - if st.Active() { - c.Next() - return - } - - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ - "error": "license_required", - "state": st.Status, - "reason": st.Reason, - }) - } -} - -// RequireFeature blocks a route when the licence does not grant a feature. -func RequireFeature(name string) gin.HandlerFunc { - return func(c *gin.Context) { - st := services.GetLicenseState(auth.InstanceID(c)) - if st.Feature(name) { - c.Next() - return - } - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ - "error": "feature_unavailable", - "feature": name, - }) - } -} - -type licenceResponse struct { - InstanceID string `json:"instance_id"` - State license.State `json:"state"` - Reason string `json:"reason,omitempty"` - Tier string `json:"tier,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - DaysRemaining *int `json:"days_remaining,omitempty"` - Limits license.Limits `json:"limits"` - Features map[string]bool `json:"features"` - Usage licenceUsageResponse `json:"usage"` - Source string `json:"source"` -} - -type licenceUsageResponse struct { - Servers int `json:"servers"` - SecretGroups int `json:"secret_groups"` - Channels int `json:"channels"` -} - -func getLicence(c *gin.Context) { - instanceID := auth.InstanceID(c) - st := services.GetLicenseState(instanceID) - servers, groups, channels := services.LicenseUsage(instanceID) - - resp := licenceResponse{ - InstanceID: instanceID, - State: st.Status, - Reason: st.Reason, - Tier: st.Tier, - ExpiresAt: st.ExpiresAt, - Limits: st.Limits, - Features: st.Features, - Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels}, - Source: st.Source, - } - if st.ExpiresAt != nil { - d := int(time.Until(*st.ExpiresAt).Hours() / 24) - resp.DaysRemaining = &d - } - c.JSON(http.StatusOK, resp) -} - -func postLicence(c *gin.Context) { - var body struct { - Blob string `json:"blob"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"}) - return - } - - instanceID := auth.InstanceID(c) - st, err := services.StoreLicense(instanceID, body.Blob) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": licenceRejectionMessage(err.Error(), instanceID), - "reason": err.Error(), - }) - return - } - - services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "", - "licence accepted (tier "+st.Tier+")") - c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt}) -} - -// licenceRejectionMessage turns a machine reason into something a person can act -// on. The instance ID is included in the mismatch case because that is the one -// piece of information the customer needs and cannot guess. -func licenceRejectionMessage(reason, instanceID string) string { - switch reason { - case license.ReasonBadSignature: - return "This licence key is not valid. Check it was copied in full." - case license.ReasonDeploymentMismatch: - return "This licence is for Vantage Cloud and cannot be used on a self-hosted install." - case license.ReasonInstanceMismatch: - return "This licence was issued for a different instance. Your instance ID is " + instanceID + "." - case license.ReasonNoLicense: - return "No licence key was provided." - default: - return "This licence could not be accepted." - } -} - -// limitStatus maps a LimitError to a 403 body. Handlers that create countable -// resources call this so the UI gets a machine-readable limit name. -func limitStatus(c *gin.Context, err error) bool { - var le *services.LimitError - if !errors.As(err, &le) { - return false - } - c.JSON(http.StatusForbidden, gin.H{ - "error": "limit_exceeded", - "limit": le.Limit, - "current": le.Current, - "max": le.Max, - }) - return true -} - -var _ = models.RoleOwner // keep the models import honest if unused elsewhere -``` - -Remove the trailing `var _ = models.RoleOwner` line and the `models` import if `go build` reports them unused. - -- [ ] **Step 2: Mount the middlewares** - -In `server/internal/api/handlers.go`, change the `/api` group and add the two routes: - -```go - apiGroup := r.Group("/api") - apiGroup.Use(auth.Middleware()) - // Deny by default: every non-GET route under /api is gated unless it is on - // the exemption list in licence.go. A route added later is covered because - // of where it is mounted, not because someone remembered. - apiGroup.Use(RequireActiveLicense()) - { - apiGroup.GET("/license", getLicence) - apiGroup.POST("/license", auth.RequireRole("owner"), postLicence) -``` - -Then gate the console: - -```go - apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect) - apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel) -``` - -And OIDC settings: - -```go - instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC) - instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC) -``` - -- [ ] **Step 3: Return limit errors properly** - -In `server/internal/api/handlers.go`, in `createServer` and `newServer`, replace the error branch: - -```go - s, token, err := services.CreateServer(auth.InstanceID(c)) - if err != nil { - if limitStatus(c, err) { - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } -``` - -Apply the same `if limitStatus(c, err) { return }` guard in the secrets handler that calls `services.UpsertSecrets` (`createSecretGroup` and `putSecretGroup` in `server/internal/api/secrets.go`) and in the channel handler that calls `services.CreateChannel` (`server/internal/api/channels.go`). - -- [ ] **Step 4: Gate the unauthenticated OIDC entry points** - -`/auth/oidc/start` and `/auth/oidc/callback` sit outside `/api` and have no session, so they check the feature directly against the instance resolved from the host. - -In `server/internal/auth/oidc.go`, at the top of `HandleOIDCStart`, after the instance is resolved: - -```go - if !services.GetLicenseState(inst.InstanceID).Feature("oidc") { - c.Redirect(http.StatusFound, "/login?error=oidc_unavailable") - return - } -``` - -**Do not add this to the session-validation path.** Losing the feature stops new SSO logins; it must not evict people mid-session. - -- [ ] **Step 5: Build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: no output. - -- [ ] **Step 6: Rate-limit the licence endpoint** - -There is no oracle here worth protecting — the blob is signed public data — but an -unbounded verify endpoint is an unbounded CPU endpoint, and signature verification -is not free. - -Add to `server/internal/api/licence.go`: - -```go -var ( - licencePostMu sync.Mutex - licencePostCounts = map[string][]time.Time{} -) - -const licencePostLimit = 10 - -// licencePostAllowed permits 10 attempts per instance per hour. -func licencePostAllowed(instanceID string) bool { - cutoff := time.Now().Add(-time.Hour) - - licencePostMu.Lock() - defer licencePostMu.Unlock() - - kept := licencePostCounts[instanceID][:0] - for _, t := range licencePostCounts[instanceID] { - if t.After(cutoff) { - kept = append(kept, t) - } - } - if len(kept) >= licencePostLimit { - licencePostCounts[instanceID] = kept - return false - } - licencePostCounts[instanceID] = append(kept, time.Now()) - return true -} -``` - -Add `"sync"` to the imports, and guard the handler at the top of `postLicence`: - -```go - instanceID := auth.InstanceID(c) - if !licencePostAllowed(instanceID) { - c.JSON(http.StatusTooManyRequests, gin.H{ - "error": "Too many licence attempts. Try again later.", - }) - return - } -``` - -Move the existing `instanceID := auth.InstanceID(c)` line so it is not declared twice. - -- [ ] **Step 7: Audit the route coverage by hand** - -With no test suite, this is the check that stops layer 1 rotting. - -```bash -cd c:/Work/Repos/vantage -grep -n "apiGroup\.\(POST\|PUT\|DELETE\|PATCH\)\|Group(\"" server/internal/api/handlers.go -grep -rn "\.\(POST\|PUT\|DELETE\|PATCH\)(" server/internal/api/workflows.go server/internal/api/monitors.go server/internal/api/channels.go -``` - -For every non-`GET` route listed, confirm it is either under `apiGroup` (and therefore gated) or deliberately exempt. Record the exempt list in a comment above `licenceExemptPaths` if it grows. - -- [ ] **Step 8: Commit** - -```bash -git add server/internal/api/ server/internal/auth/oidc.go -git commit -m "feat(server): gate mutations and features on the licence" -``` - ---- - -### Task 6: Degraded background behaviour - -The paths that do not go through gin. This is where "read-only" has to be specific. - -**Files:** - -- Modify: `server/internal/monitorsched/scheduler.go` (comment only) -- Modify: `server/internal/grpc/server.go` (`Register`) - -**Interfaces:** - -- Consumes: `services.GetLicenseState`, `services.CheckServerLimit` -- Produces: no new API - -- [ ] **Step 1: Leave the monitor scheduler alone, and say why** - -Add above the loop body in `server/internal/monitorsched/scheduler.go`: - -```go -// Monitors run regardless of licence state, deliberately. -// -// A customer whose card failed must not lose the ability to know their -// infrastructure is on fire. Creating and editing monitors is blocked by the -// API gate; executing the ones that already exist is not. -``` - -Make no functional change here. The absence of a check is the feature. - -- [ ] **Step 2: Leave the workflow runner alone too, and say why** - -Add above the step-dispatch loop in `server/internal/services/workflow_runner.go`: - -```go -// A run already in flight when the licence expires finishes its remaining -// steps. New runs are blocked at the API, but killing a workflow midway leaves -// a server in a half-configured state, which is worse than letting it complete. -``` - -Again, no functional change — the runner must not gain a licence check. - -- [ ] **Step 3: Refuse NEW agent registrations against an over-limit instance** - -In `server/internal/grpc/server.go`, inside `Register`, after the pre-registration token is validated and the instance is known, before the server is marked active: - -```go - // An existing agent re-registering is always allowed — its server row already - // exists, so the limit check passes. This only stops a NEW server being - // added past the cap by going around the API. - if err := services.CheckServerLimit(instanceID); err != nil { - return nil, status.Errorf(codes.FailedPrecondition, - "this Vantage instance has reached its licenced server limit; "+ - "remove a server or upgrade, then retry") - } -``` - -Add `"google.golang.org/grpc/codes"` and `"google.golang.org/grpc/status"` to the imports if not already present. - -- [ ] **Step 4: Build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: no output. - -- [ ] **Step 5: Commit** - -```bash -git add server/internal/monitorsched/ server/internal/grpc/ -git commit -m "feat(server): keep monitors running when a licence lapses - -Blocks new agent registration past the server cap. Monitor execution is -deliberately unguarded: billing state must not take away a customer's -ability to know their infrastructure is on fire." -``` - ---- - -### Task 7: Show the instance ID at setup - -A self-hosted operator needs the UUID to activate a licence, and `/setup` is where they first need it. - -**Files:** - -- Modify: `server/internal/auth/local.go` (`HandleBootstrap` response) -- Modify: `web/app/setup/page.tsx` - -**Interfaces:** - -- Consumes: nothing -- Produces: `POST /auth/bootstrap` response gains `instance_id` - -- [ ] **Step 1: Return the instance ID from bootstrap** - -In `server/internal/auth/local.go`, in the bootstrap success response, change: - -```go - c.JSON(http.StatusCreated, gin.H{ - "instance": inst, - "slug": inst.Slug, - "instance_id": inst.InstanceID, - }) -``` - -- [ ] **Step 2: Show it on the setup confirmation** - -In `web/app/setup/page.tsx`, in the "Instance created" block, add below the slug line: - -```tsx -
-

Instance ID — needed to activate a licence

-
- {created.instance_id} - -
-
-``` - -Add `instance_id: string;` to the bootstrap response type in `web/lib/api.ts`. - -- [ ] **Step 3: Build** - -```bash -cd c:/Work/Repos/vantage/server && go build ./... -cd ../web && npx tsc --noEmit -``` - -Expected: no output from either. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/auth/local.go web/app/setup/page.tsx web/lib/api.ts -git commit -m "feat: show the instance ID after setup" -``` - ---- - -### Task 8: Frontend licence state - -**Files:** - -- Create: `web/lib/useLicense.ts`, `web/components/LicenseBanner.tsx`, `web/app/(app)/settings/license/page.tsx` -- Modify: `web/lib/api.ts`, `web/app/(app)/layout.tsx`, `web/components/Sidebar.tsx` - -**Interfaces:** - -- Consumes: `GET /api/license`, `POST /api/license` -- Produces: `useLicense()`, `` - -- [ ] **Step 1: Add the API client methods and types** - -In `web/lib/api.ts`: - -```ts -export type LicenseState = "valid" | "expired" | "invalid"; - -export interface LicenseInfo { - instance_id: string; - state: LicenseState; - reason?: string; - tier?: string; - expires_at?: string; - days_remaining?: number; - limits: { max_servers: number; max_secret_groups: number; max_channels: number }; - features: Record; - usage: { servers: number; secret_groups: number; channels: number }; - source: string; -} - -export const licence = { - get(): Promise { - return request("/api/license"); - }, - put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> { - return request("/api/license", { method: "POST", body: JSON.stringify({ blob }) }); - }, -}; -``` - -Match `request` to whatever the existing client helper is called in that file; the other methods there show the pattern. - -- [ ] **Step 2: Write the hook** - -Create `web/lib/useLicense.ts`: - -```ts -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { licence, type LicenseInfo } from "@/lib/api"; - -export function useLicense() { - const { data, isLoading } = useQuery({ - queryKey: ["license"], - queryFn: licence.get, - staleTime: 60_000, - }); - - return { - license: data, - isLoading, - isActive: data?.state === "valid", - // Features render disabled rather than hidden, so treat "unknown while - // loading" as available to avoid a flash of disabled controls. - hasFeature: (name: string) => (data ? Boolean(data.features?.[name]) : true), - }; -} -``` - -- [ ] **Step 3: Write the banner** - -Create `web/components/LicenseBanner.tsx`: - -```tsx -"use client"; - -import Link from "next/link"; -import { useLicense } from "@/lib/useLicense"; - -export function LicenseBanner() { - const { license } = useLicense(); - if (!license) return null; - - if (license.state === "expired") { - const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently"; - return ( -
- Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "} - - Add a licence - -
- ); - } - - if (license.state === "invalid") { - return ( -
- This instance has no valid licence. Changes are disabled.{" "} - - Add a licence - -
- ); - } - - if (typeof license.days_remaining === "number" && license.days_remaining <= 14) { - return ( -
- Your licence expires in {license.days_remaining} day - {license.days_remaining === 1 ? "" : "s"}. -
- ); - } - - return null; -} -``` - -- [ ] **Step 4: Mount the banner** - -In `web/app/(app)/layout.tsx`, render `` immediately inside the main content wrapper, above the page children, and import it. - -- [ ] **Step 5: Write the settings page** - -Create `web/app/(app)/settings/license/page.tsx`: - -```tsx -"use client"; - -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { licence } from "@/lib/api"; -import { useLicense } from "@/lib/useLicense"; - -function cap(n: number) { - return n === -1 ? "Unlimited" : String(n); -} - -export default function LicensePage() { - const { license } = useLicense(); - const queryClient = useQueryClient(); - const [blob, setBlob] = useState(""); - const [error, setError] = useState(""); - - const save = useMutation({ - mutationFn: () => licence.put(blob.trim()), - onSuccess: () => { - setBlob(""); - setError(""); - queryClient.invalidateQueries({ queryKey: ["license"] }); - }, - onError: (e: Error) => setError(e.message), - }); - - if (!license) return null; - - return ( -
-

Licence

- -
-

- State: {license.state} - {license.tier ? ( - <> - {" "} - · Tier: {license.tier} - - ) : null} - {license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()} : null} -

-

Instance ID — quote this when buying or activating a licence

-
- {license.instance_id} - -
-
- -
-

Usage

-
    -
  • - Servers: {license.usage.servers} of {cap(license.limits.max_servers)} -
  • -
  • - Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)} -
  • -
  • - Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)} -
  • -
  • Browser console: {license.features.console ? "Included" : "Not included"}
  • -
  • Single sign-on: {license.features.oidc ? "Included" : "Not included"}
  • -
-
- -
-

Add or replace a licence

-