diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md new file mode 100644 index 0000000..13873fa --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md @@ -0,0 +1,2116 @@ +# Cloud Instance Creation — Phase 2: Creation, Free Lifecycle and Reclaim + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a verified HQ customer create their own Free cloud instance from the portal, licence it automatically, remind them to renew, and reclaim it if they never do. + +**Architecture:** Admin gains a second, deliberately narrow write path into the control plane (`cloudprov`) that creates instances and users — `inject` keeps owning exactly three licence fields and is not touched. The Free licence runs a month plus the existing three-day grace. Reclaim is split across services on purpose: admin sends the notices because it knows the billing address, and the **control plane** performs the delete because it knows what an instance is made of. + +**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, `shared/provision`, `shared/license`. + +## Global Constraints + +- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files. +- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`: + ```sh + # /tmp/gorun.sh + DIR="$1"; shift + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ + -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \ + golang:1.26 "$@" + ``` +- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise. +- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry". +- **Admin's control-plane writes are confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry` on `instances`. Do not widen `inject`. +- **The reaper defaults OFF.** `FREE_INSTANCE_REAP_AFTER` empty means never delete. It is set only in `deploy/docker-compose.site.yml`, never in `deploy/docker-compose.yml`. +- **Free is one per account** and cloud-only by construction — `licensing.Issue` already refuses a deployment mismatch. Do not add a tier flag. +- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`. +- Paddle is **out of scope**. Accounts have an empty `PaddleCustomerID`. +- Account roles, invitations and instance membership are **phase 3**. In phase 2 an account has exactly one user, so no role checks are needed anywhere. + +## Context this plan inherits + +Phase 1 shipped (commits `da3afca`..`5f35b57`, on `main`, **not yet pushed**): + +- `users` is unique on `(instance_id, email)`, not on `email` alone. The same address may hold a user in several instances. +- Every lookup by email is scoped by instance. `services.GetUserByEmail` no longer exists. +- `shared/models.User` has `HQUserID` (`hq_user_id`) and the constants `AuthLocal`, `AuthOIDC`, `AuthHQ`. **Nothing writes them yet — this phase is the first writer**, setting `AuthHQ` and `HQUserID` on the instance owner it creates. +- `admin/internal/auth/cloud.go` is deleted; `/auth/login` points at `HandleCustomerLogin`. Every customer authenticates against `customer_users`. +- `admin` already has account-first signup at `POST /auth/signup` (`admin/internal/auth/customer.go`) creating an `accounts` row plus an unverified `customer_users` row plus a verification email. **This phase adds no signup code** — it repoints the marketing form at it. + +Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), phase 2. + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `admin/internal/cloudprov/cloudprov.go` | admin's ONLY instance/user write path into the control plane | +| `admin/internal/lifecycle/lifecycle.go` | lapse sweep and the four renewal notices | +| `server/internal/services/reap.go` | the Free-instance purge and its scheduler | +| `adminsite/app/(customer)/instances/new/page.tsx` | create-instance form | +| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component | +| `site/components/AccountForm.tsx` | replaces `InstanceForm.tsx` | + +**Modified:** + +| Path | Change | +|---|---| +| `admin/internal/db/db.go` | `ControlDB()` accessor | +| `admin/internal/config/config.go` | `AppLoginURL` from `APP_LOGIN_URL` | +| `admin/internal/models/models.go` | `StatusDeleted`, `NoticesSent` on `Instance` | +| `admin/internal/mail/mail.go` | four lifecycle emails | +| `admin/internal/api/customer.go` | `createInstance`, `renewInstance` | +| `admin/internal/api/routes.go` | the two new customer routes | +| `admin/internal/inject/inject.go` | reconciler marks vanished instances `deleted` | +| `admin/cmd/main.go` | start the lifecycle sweeper | +| `server/cmd/main.go` | start the reaper | +| `adminsite/lib/api.ts` | `createInstance`, `renewInstance`, `"deleted"` status | +| `adminsite/components/InstanceCard.tsx` | renew action, deletion countdown, monthly-aware bar | +| `adminsite/app/(customer)/page.tsx` | create-instance call to action | +| `site/app/start/page.tsx` | account-signup copy | +| `site/lib/submit.ts` | `submitSignup` posts to admin | +| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed | +| `deploy/docker-compose.site.yml` | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` | +| `.gitea/workflows/server-deploy.yml` | `ADMIN_API_URL` build arg for `site` | +| `CLAUDE.md` | the boundary paragraph, sitesvc's table, the env tables | + +**Deleted:** `sitesvc/internal/api/signup.go`, `site/components/InstanceForm.tsx`. + +--- + +### Task 1: `cloudprov` — admin's instance and user write path + +**Files:** +- Create: `admin/internal/cloudprov/cloudprov.go` +- Modify: `admin/internal/db/db.go`, `admin/internal/config/config.go` + +**Interfaces:** +- Consumes: `shared/provision.CreateInstance`, `CreateUserWithHash`, `RollbackInstance`; `shared/models`. +- Produces: + - `db.ControlDB() *mongo.Database` + - `config.Config.AppLoginURL string` + - `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)` + - `cloudprov.DeleteUser(ctx, instanceID, userID string) error` + - `cloudprov.RollbackInstance(ctx, instanceID string) error` + +- [ ] **Step 1: Add the `ControlDB` accessor** + +In `admin/internal/db/db.go`, after `func Control(name string) *mongo.Collection`, add: + +```go +// ControlDB exposes the control-plane database itself, because shared/provision +// takes a database rather than a collection. +// +// It is used by cloudprov and nothing else. Reach for Control(name) unless you +// are calling into shared/provision. +func ControlDB() *mongo.Database { return controlDB } +``` + +Then update the package doc comment at the top of the file. Replace the sentence beginning "Control() is the control plane's database" with: + +```go +// Control() is the control plane's database. Admin's access to it is narrow and +// lives in exactly two packages: inject writes three licence fields on +// `instances`, and cloudprov creates and rolls back `instances` and `users` when +// a customer provisions a cloud instance. Nothing else may write there, and a +// third write path is a design change rather than a refactor. +``` + +- [ ] **Step 2: Add `APP_LOGIN_URL` to config** + +In `admin/internal/config/config.go`, add `AppLoginURL string` to the `Config` struct after `PublicURL`, and in `Load()` add to the struct literal: + +```go + AppLoginURL: os.Getenv("APP_LOGIN_URL"), +``` + +It is **not** added to the required-variables map: an admin with no `APP_LOGIN_URL` still works, it just omits the sign-in link from the instance-ready email. Boot-failing on a cosmetic value would be worse than the missing link. + +- [ ] **Step 3: Write `cloudprov`** + +Create `admin/internal/cloudprov/cloudprov.go`: + +```go +// Package cloudprov provisions cloud instances in the control plane. +// +// This is admin's second and final write path into the control-plane database, +// alongside inject. It writes `instances` and `users` and nothing else. A third +// write target, or a write to any other collection from here, is a design change +// and not a refactor — see the spec's "Admin's control-plane write boundary". +// +// Every function here is called from a customer request, so each one leaves the +// control plane in a consistent state or not at all: the caller unwinds in +// reverse order on failure, and RollbackInstance refuses to delete an instance +// that has users. +package cloudprov + +import ( + "context" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/db" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "github.com/mrhid6/vantage/shared/provision" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// CreateInstance creates a control-plane instance and its owner. +// +// The owner's password hash is COPIED from the HQ account rather than shared. +// Changing the password on either side does not propagate, and they diverge from +// that moment — accepted deliberately, because propagating a hash across two +// services' databases is a worse problem than two passwords that started equal. +// +// On owner-insert failure the instance is rolled back, so a failed provision +// never leaves a slug permanently occupied by an instance nobody owns. +func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) { + inst, err := provision.CreateInstance(ctx, db.ControlDB(), name) + if err != nil { + return nil, err + } + + u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID, + ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ) + if err != nil { + if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil { + return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr) + } + return nil, err + } + + // hq_user_id is what phase 3 uses to find every row projected from one HQ + // user when its password changes. Set at creation so the owner is not a + // special case later. + if _, err := db.Control("users").UpdateOne(ctx, + bson.M{"user_id": u.UserID}, + bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil { + return nil, fmt.Errorf("set hq_user_id: %w", err) + } + + return inst, nil +} + +// DeleteUser removes one control-plane user. Used only to unwind a failed +// provision. +func DeleteUser(ctx context.Context, instanceID, userID string) error { + _, err := db.Control("users").DeleteOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}) + return err +} + +// RollbackInstance deletes an instance that has no users. +func RollbackInstance(ctx context.Context, instanceID string) error { + return provision.RollbackInstance(ctx, db.ControlDB(), instanceID) +} + +// OwnerUserID returns the control-plane user_id of an instance's owner, so a +// caller can unwind a partial provision without re-deriving it. +func OwnerUserID(ctx context.Context, instanceID string) (string, error) { + var u sharedmodels.User + err := db.Control("users").FindOne(ctx, bson.M{ + "instance_id": instanceID, + "role": sharedmodels.RoleOwner, + }).Decode(&u) + if err != nil { + return "", err + } + return u.UserID, nil +} +``` + +- [ ] **Step 4: Confirm it compiles** + +Run: +```sh +sh /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +- [ ] **Step 5: Confirm the write boundary holds** + +Run: +```sh +grep -rn 'db.Control(' --include=*.go admin/ | grep -v '_test' +``` +Expected: matches only in `admin/internal/inject/inject.go`, `admin/internal/cloudprov/cloudprov.go`, and read-only uses in `admin/internal/api/staff.go` and `admin/internal/licensing/link.go`. Any **write** (`UpdateOne`, `InsertOne`, `DeleteOne`) outside `inject` and `cloudprov` is a boundary violation — report it rather than fixing it silently. + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/cloudprov/ admin/internal/db/db.go admin/internal/config/config.go +git commit -m "feat(admin): cloudprov, the instance provisioning write path + +Admin's second and final write path into the control plane. It creates +instances and users and nothing else; inject still owns exactly three +licence fields and is untouched. + +The owner's password hash is copied from the HQ account, not shared. The +two diverge on the next password change, which is accepted: propagating a +hash across two databases is worse than two passwords that started equal. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Create an instance + +**Files:** +- Modify: `admin/internal/models/models.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go` + +**Interfaces:** +- Consumes: `cloudprov` from Task 1; `licensing.Issue`, `licensing.ErrFreeLimit`; `inject.Deliver`; `auth.Current`. +- Produces: + - `models.StatusDeleted = "deleted"` + - `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty` + - `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error` + - `POST /api/instances` + +- [ ] **Step 1: Add the model fields** + +In `admin/internal/models/models.go`, add to the instance-status constants: + +```go + // StatusDeleted marks an instance the control plane has reaped. The row is + // kept because the licence history references it and support questions + // outlive the instance. + StatusDeleted = "deleted" +``` + +And to the `Instance` struct, after `InjectFailedAt`: + +```go + // NoticesSent holds the lifecycle notice keys already emailed for the + // CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal + // clears it, so the next term starts the sequence again. It is what stops a + // restart re-sending a notice. + NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"` +``` + +- [ ] **Step 2: Add the instance-ready email** + +In `admin/internal/mail/mail.go`, add: + +```go +// SendInstanceReady tells a customer their cloud instance exists, where it is, +// and when its licence runs out. +// +// The expiry is stated here rather than only in a later reminder: a Free licence +// that quietly expires in a month is a surprise, and the first email is the one +// people keep. +func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error { + body := fmt.Sprintf("%s is ready.\n\n", instanceName) + if loginURL != "" { + body += "Sign in here:\n\n" + loginURL + "\n\n" + } + body += fmt.Sprintf( + "Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+ + "Sign in with the same email address and password you use for your Vantage account. "+ + "Changing one does not change the other.\n", + expires.Format("2 January 2006")) + return send(to, instanceName+" is ready", body) +} +``` + +Add `"time"` to the imports. + +- [ ] **Step 3: Add the handler** + +In `admin/internal/api/customer.go`, add: + +```go +// createInstance provisions a Free cloud instance for the calling account. +// +// The ordering matters and each step unwinds the previous one. Licence issuance +// and email are deliberately NOT allowed to fail the request: the instance +// exists and the customer can sign in, they see the licence banner, and staff +// can issue by hand. Rolling back an instance the customer can already see would +// be worse than shipping it unlicensed. +func createInstance(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + name := strings.TrimSpace(body.Name) + ctx := c.Request.Context() + s := auth.Current(c) + + // Pre-check the Free rule so we never create an instance we then cannot + // licence. licensing.Issue enforces it too; this is the friendly refusal. + n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ + "account_id": s.AccountID, + "tier": license.TierFree, + "status": bson.M{"$ne": models.StatusCancelled}, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"}) + return + } + if n > 0 { + c.JSON(http.StatusConflict, gin.H{ + "error": "this account already has a Free instance"}) + return + } + + var cu models.CustomerUser + if err := db.Admin("customer_users").FindOne(ctx, + bson.M{"user_id": s.UserID}).Decode(&cu); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"}) + return + } + + inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID) + if err != nil { + if errors.Is(err, provision.ErrEmailTaken) { + // users.email is unique per instance, so this means the address + // already owns a user in an instance we are not creating — a legacy + // cloud tenant. Staff have to attach that one by hand. + c.JSON(http.StatusConflict, gin.H{ + "error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"}) + return + } + if errors.Is(err, provision.ErrNameRejected) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"}) + return + } + + rec := models.Instance{ + InstanceID: inst.InstanceID, + AccountID: s.AccountID, + Name: inst.Name, + Slug: inst.Slug, + Deployment: license.DeploymentCloud, + Status: models.StatusActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil { + // Unwind in reverse: the owner first, because RollbackInstance refuses + // an instance that still has users. + if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil { + _ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid) + } + if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil { + log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.created", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()}) + + // Past this point nothing fails the request. + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: license.TierFree, + Term: "monthly", + Reason: models.ReasonNew, + IssuedBy: "self-serve", + }) + if err != nil { + log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err) + c.JSON(http.StatusCreated, rec) + return + } + inject.Deliver(ctx, lic) + + if mail.Enabled() { + if err := mail.SendInstanceReady(s.Email, inst.Name, + loginURLFor(inst.Slug), lic.ExpiresAt); err != nil { + log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err) + } + } + + rec.Tier = lic.Tier + rec.CurrentLicense = lic.LicenseID + c.JSON(http.StatusCreated, rec) +} + +// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template +// yields an empty string, and the email simply omits the link. +func loginURLFor(slug string) string { + if appLoginURL == "" { + return "" + } + return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug)) +} + +// appLoginURL is set once at boot from config. +var appLoginURL string + +// SetAppLoginURL is called from main. +func SetAppLoginURL(v string) { appLoginURL = v } +``` + +Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"github.com/mrhid6/vantage/admin/internal/audit"`, `"github.com/mrhid6/vantage/admin/internal/cloudprov"`, `"github.com/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported. + +- [ ] **Step 4: Wire the route and the config** + +In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.GET("/account", getAccount)`: + +```go + cust.POST("/instances", createInstance) +``` + +In `admin/cmd/main.go`, after the config is loaded and before the HTTP server starts, add: + +```go + api.SetAppLoginURL(cfg.AppLoginURL) +``` + +Import `admin/internal/api` there if it is not already imported (it will be, for `api.Routes`). + +- [ ] **Step 5: Confirm it compiles** + +Run: +```sh +sh /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add admin/ +git commit -m "feat(admin): POST /api/instances creates a Free cloud instance + +Provisions the control-plane instance and its owner, records the +admin_instances row, issues and injects a Free licence, and emails the +customer where it is and when it expires. + +Licence issuance and email cannot fail the request. The instance exists +and the customer can sign in; rolling back something they can already see +would be worse than shipping it unlicensed for staff to fix. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Renew a Free instance + +**Files:** +- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go` + +**Interfaces:** +- Consumes: `ownedInstance`, `licensing.Issue`, `inject.Deliver`. +- Produces: + - `mail.SendRenewed(to, instanceName string, expires time.Time) error` + - `POST /api/instances/:id/renew` + - `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go` + +- [ ] **Step 1: Add the renewal window constant** + +In `admin/internal/models/models.go`, after `GracePeriod`: + +```go +// RenewWindow is how long before expiry a Free instance may be renewed. +// +// Renewal stays available after expiry too, right up until the reaper takes the +// instance, so the same button rescues a lapsed instance instead of needing a +// second mechanism. +const RenewWindow = 7 * 24 * time.Hour +``` + +- [ ] **Step 2: Add the renewed email** + +In `admin/internal/mail/mail.go`: + +```go +// SendRenewed confirms a renewal and states the new date. +func SendRenewed(to, instanceName string, expires time.Time) error { + return send(to, instanceName+" renewed", + fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n", + instanceName, expires.Format("2 January 2006"))) +} +``` + +- [ ] **Step 3: Add the handler** + +In `admin/internal/api/customer.go`: + +```go +// renewInstance extends a Free licence by another term. +// +// Renewal is manual on purpose: it is the entire reclaim signal. An instance +// nobody renews is an instance nobody is using, and that is what makes the +// reaper safe to run at all. +func renewInstance(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + if inst.Tier != license.TierFree { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "only Free instances renew here; paid plans renew through billing"}) + return + } + + ctx := c.Request.Context() + + var current models.License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"}) + return + } + if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("this licence is not due yet; you can renew from %s", + current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))}) + return + } + + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: license.TierFree, + Term: "monthly", + Reason: models.ReasonRenewal, + IssuedBy: "self-serve", + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + inject.Deliver(ctx, lic) + + // Clear the notice log so the next term starts the sequence again. Issue has + // already set status back to active. + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil { + log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err) + } + + s := auth.Current(c) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID, + Target: inst.InstanceID, IP: c.ClientIP()}) + + if mail.Enabled() { + if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil { + log.Printf("renewInstance: renewed email to %s: %v", s.Email, err) + } + } + + c.JSON(http.StatusOK, lic) +} +``` + +Add `"fmt"` to `customer.go`'s imports. + +- [ ] **Step 4: Wire the route** + +In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.POST("/instances/:id/relink", relinkInstance)`: + +```go + cust.POST("/instances/:id/renew", renewInstance) +``` + +- [ ] **Step 5: Confirm it compiles** + +Run: +```sh +sh /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add admin/ +git commit -m "feat(admin): renew a Free instance from the portal + +Available from seven days before expiry and, deliberately, at any point +after it up to deletion, so the same button rescues a lapsed instance. + +Renewal is manual because it is the entire reclaim signal: an instance +nobody renews is one nobody is using, which is what makes reaping safe. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: The lapse sweep and the four notices + +**Files:** +- Create: `admin/internal/lifecycle/lifecycle.go` +- Modify: `admin/internal/mail/mail.go`, `admin/cmd/main.go` + +**Interfaces:** +- Consumes: `models.Instance`, `models.License`, `mail`. +- Produces: + - `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning` + - `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)` + +The notice schedule, all relative to the licence's `ExpiresAt` (which already includes the three-day grace): + +| Key | Sent when | Says | +|---|---|---| +| `expiring` | 7 days before expiry | renew, one click | +| `expired` | at expiry | read-only; deleted in N days | +| `delete_7` | 7 days before deletion | deleted in 7 days | +| `delete_1` | 1 day before deletion | deleted tomorrow | + +- [ ] **Step 1: Add the three emails** + +In `admin/internal/mail/mail.go`: + +```go +// SendExpiring is the renew-now nudge, seven days out. +func SendExpiring(to, instanceName, portalURL string, expires time.Time) error { + return send(to, instanceName+" expires on "+expires.Format("2 January"), + fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+ + "Renew it in one click:\n\n%s\n\n"+ + "If you do nothing, the instance keeps running but stops accepting changes.\n", + instanceName, expires.Format("2 January 2006"), portalURL)) +} + +// SendExpired states plainly what has stopped and what happens next. +// +// It names the deletion date rather than a vague warning: the whole point of the +// sequence is that nobody loses an instance without having been told a date. +func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error { + return send(to, instanceName+" is now read-only", + fmt.Sprintf("%s's Free licence has expired.\n\n"+ + "Your servers and monitors keep running and your agents keep their keys, "+ + "but changes are disabled.\n\n"+ + "Renew it here:\n\n%s\n\n"+ + "If it is not renewed, the instance and everything in it will be deleted on %s.\n", + instanceName, portalURL, deleteOn.Format("2 January 2006"))) +} + +// SendDeletionWarning is the final countdown, sent at seven days and one day. +func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error { + when := fmt.Sprintf("in %d days", daysLeft) + if daysLeft <= 1 { + when = "tomorrow" + } + return send(to, instanceName+" will be deleted "+when, + fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+ + "This cannot be undone. Renew it here to keep it:\n\n%s\n", + instanceName, when, deleteOn.Format("2 January 2006"), portalURL)) +} +``` + +- [ ] **Step 2: Write the lifecycle sweeper** + +Create `admin/internal/lifecycle/lifecycle.go`: + +```go +// Package lifecycle marks lapsed Free instances and sends the renewal notices. +// +// It sends; it never deletes. Deletion belongs to the control plane, which is +// the only service that knows what an instance is made of. The two are kept +// apart on purpose: a bug here sends a wrong email, a bug there loses data. +package lifecycle + +import ( + "context" + "log" + "slices" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Interval is how often the sweep runs. Hourly is far finer than the daily +// granularity of the notices, which means a notice goes out within an hour of +// becoming due rather than up to a day late. +const Interval = time.Hour + +// Notice keys, recorded on the instance so a restart cannot re-send one. +const ( + noticeExpiring = "expiring" + noticeExpired = "expired" + noticeDelete7 = "delete_7" + noticeDelete1 = "delete_1" +) + +// portalURL is the customer portal address used in notice emails. +var portalURL string + +// SetPortalURL is called once at boot. +func SetPortalURL(v string) { portalURL = v } + +// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails +// can name the real deletion date. Zero means the reaper is off, and the +// deletion notices are then suppressed — promising a deletion that will never +// happen would be a lie, and a scarier one than saying nothing. +var reapAfter time.Duration + +// Run performs one sweep: mark lapsed instances, then send whatever notices are +// due. Errors on one instance never stop the others. +func Run(ctx context.Context) error { + now := time.Now().UTC() + + cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ + "deployment": license.DeploymentCloud, + "tier": license.TierFree, + "status": bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}}, + }) + if err != nil { + return err + } + var instances []models.Instance + if err := cur.All(ctx, &instances); err != nil { + return err + } + + for _, inst := range instances { + var lic models.License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil { + continue // no licence yet; nothing to expire + } + + // Flip active -> lapsed once the licence is past its expiry. + if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive { + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil { + log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err) + } + } + + due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent) + if due == "" { + continue + } + if err := sendNotice(ctx, inst, lic, due); err != nil { + log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err) + continue + } + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil { + log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err) + } + } + return nil +} + +// dueNotice returns the most urgent unsent notice, or "". +// +// Most urgent first, so an instance that was missed for a week — because admin +// was down — sends the one that matters now rather than working through a +// backlog of stale warnings. +func dueNotice(now, expires time.Time, sent []string) string { + deleteOn := expires.Add(reapAfter) + + if reapAfter > 0 { + if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) { + return noticeDelete1 + } + if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) { + return noticeDelete7 + } + } + if now.After(expires) && !slices.Contains(sent, noticeExpired) { + return noticeExpired + } + if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) { + return noticeExpiring + } + return "" +} + +func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error { + if !mail.Enabled() { + return nil + } + var acct models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil { + return err + } + to := acct.BillingEmail + deleteOn := lic.ExpiresAt.Add(reapAfter) + + switch key { + case noticeExpiring: + return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt) + case noticeExpired: + return mail.SendExpired(to, inst.Name, portalURL, deleteOn) + case noticeDelete7: + return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7) + case noticeDelete1: + return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1) + } + return nil +} + +// Start runs the sweep on a ticker until ctx is cancelled. +// +// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they +// disagree, the emails name a date the reaper does not honour — so they are +// documented as a pair in CLAUDE.md and set together in the compose file. +func Start(ctx context.Context, reapAfterDur time.Duration) { + reapAfter = reapAfterDur + go func() { + runOnce(ctx) + t := time.NewTicker(Interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + runOnce(ctx) + } + } + }() +} + +func runOnce(ctx context.Context) { + runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + if err := Run(runCtx); err != nil { + log.Printf("lifecycle: %v", err) + } +} +``` + +- [ ] **Step 3: Start it at boot** + +In `admin/internal/config/config.go`, add `ReapAfter time.Duration` to `Config`, and in `Load()`, after the origins loop: + +```go + // Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can + // name the real deletion date. An unparseable value is refused rather than + // silently treated as "off": a typo here would quietly stop every deletion + // warning while the control plane still deletes. + if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err) + } + c.ReapAfter = d + } +``` + +Add `"time"` to that file's imports. + +In `admin/cmd/main.go`, next to `inject.StartReconciler(ctx)`: + +```go + lifecycle.SetPortalURL(cfg.PublicURL) + lifecycle.Start(ctx, cfg.ReapAfter) +``` + +- [ ] **Step 4: Confirm it compiles** + +Run: +```sh +sh /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add admin/ +git commit -m "feat(admin): lapse sweep and the four renewal notices + +Hourly sweep marks expired Free instances lapsed and sends at most one +notice per instance per pass, most urgent first, recorded on the document +so a restart cannot re-send. + +Deletion warnings are suppressed when the reaper is off. Promising a +deletion that will never happen is a lie, and a scarier one than silence. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: The reaper + +**Files:** +- Create: `server/internal/services/reap.go` +- Modify: `server/cmd/main.go` + +**Interfaces:** +- Consumes: `db.Col`, `db.Database`. +- Produces: `services.ReapFreeInstances(ctx) (checked, purged int, err error)`, `services.StartReaper(ctx)`, `services.PurgeInstance(ctx, instanceID string) (map[string]int64, error)`. + +This is the only irreversible path in the system. Read the eligibility rule twice. + +- [ ] **Step 1: Write the reaper** + +Create `server/internal/services/reap.go`: + +```go +package services + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ReapInterval is how often eligibility is re-checked. Deletion is measured in +// days, so an hour is ample and keeps the query cheap. +const ReapInterval = time.Hour + +// instanceScopedCollections is every collection carrying instance_id. +// +// This list is the reason the reaper lives in the control plane rather than in +// admin: it is knowledge of what an instance is made of, and it belongs in the +// codebase that defines these documents. A collection added without being added +// here leaks rows that outlive their instance. +// +// `instances` is deliberately absent — it is keyed by instance_id, not scoped by +// it, and is deleted last so a crash mid-purge leaves an instance that will be +// retried rather than orphaned rows with no instance. +var instanceScopedCollections = []string{ + "assignments", + "audit_logs", + "console_sessions", + "incidents", + "instance_oidc", + "keys", + "monitor_rollups", + "monitors", + "notification_channels", + "secrets", + "servers", + "settings", + "users", + "workflow_runs", + "workflow_steps", + "workflows", +} + +// reapAfter reads FREE_INSTANCE_REAP_AFTER. +// +// An empty or unparseable value returns 0, which disables the reaper. Defaulting +// OFF is the whole safety design: a deployment that never heard of this variable +// must never delete a customer's instance. +func reapAfter() time.Duration { + v := os.Getenv("FREE_INSTANCE_REAP_AFTER") + if v == "" { + return 0 + } + d, err := time.ParseDuration(v) + if err != nil { + log.Printf("reaper: FREE_INSTANCE_REAP_AFTER %q is not a duration; reaper stays OFF", v) + return 0 + } + if d <= 0 { + return 0 + } + return d +} + +// PurgeInstance deletes an instance and every document scoped to it. +// +// Idempotent: re-running over a half-deleted instance completes it. The instance +// document goes last, so an interrupted purge is retried on the next sweep +// instead of leaving rows behind with nothing pointing at them. +func PurgeInstance(ctx context.Context, instanceID string) (map[string]int64, error) { + counts := map[string]int64{} + for _, name := range instanceScopedCollections { + res, err := db.Col(name).DeleteMany(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return counts, fmt.Errorf("purge %s: %w", name, err) + } + if res.DeletedCount > 0 { + counts[name] = res.DeletedCount + } + } + res, err := db.Col("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return counts, fmt.Errorf("purge instances: %w", err) + } + if res.DeletedCount > 0 { + counts["instances"] = res.DeletedCount + } + return counts, nil +} + +// ReapFreeInstances deletes Free cloud instances whose licence expired longer +// ago than the configured window. +// +// Eligibility requires ALL of: +// - license_tier == "free" — a paid instance is never eligible +// - license_expiry present — an instance that was never licensed, or whose +// issuance failed, has no expiry and is never eligible whatever its age +// - license_expiry older than now minus the window +// +// Every one of those is a positive assertion. Nothing is eligible by default, +// which is what makes a missing or stale field fail safe. +func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) { + window := reapAfter() + if window == 0 { + return 0, 0, nil + } + cutoff := time.Now().UTC().Add(-window) + + cur, err := db.Col("instances").Find(ctx, bson.M{ + "license_tier": license.TierFree, + "license_expiry": bson.M{"$ne": nil, "$lt": cutoff}, + }) + if err != nil { + return 0, 0, err + } + var doomed []struct { + InstanceID string `bson:"instance_id"` + Name string `bson:"name"` + Slug string `bson:"slug"` + Expiry time.Time `bson:"license_expiry"` + } + if err := cur.All(ctx, &doomed); err != nil { + return 0, 0, err + } + + for _, d := range doomed { + checked++ + + // Logged BEFORE the delete. Afterwards there is nothing left to + // describe, and "why did this instance vanish" is the only question + // anyone will ever ask about this code. + // + // The process log is the durable record, not the audit row: the purge + // deletes this instance's audit_logs along with everything else, so an + // audit entry written here would delete itself moments later. It is + // written anyway, because an operator reading audit during the window + // should see it coming. + log.Printf("REAPING instance %s (%s, slug=%s) — Free licence expired %s, past the %s window", + d.InstanceID, d.Name, d.Slug, d.Expiry.Format(time.RFC3339), window) + LogEvent(d.InstanceID, "instance.reaped", "system", "", "", + fmt.Sprintf("free licence expired %s, window %s", d.Expiry.Format(time.RFC3339), window)) + + counts, err := PurgeInstance(ctx, d.InstanceID) + if err != nil { + log.Printf("reaper: purge of %s failed after %v: %v", d.InstanceID, counts, err) + continue + } + purged++ + log.Printf("reaped instance %s: %v", d.InstanceID, counts) + } + return checked, purged, nil +} + +// StartReaper sweeps once at boot, then on a ticker until ctx is cancelled, and +// logs loudly which mode it is in. +// +// The pass at boot follows inject.StartReconciler's precedent and earns its keep +// the same way: it makes a restart a supported way to force a sweep, which is +// the only way this code can be exercised on demand — the ticker is hourly and +// deletion is measured in days. +func StartReaper(ctx context.Context) { + window := reapAfter() + if window == 0 { + log.Printf("reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)") + return + } + log.Printf("reaper: ENABLED — Free instances are deleted %s after their licence expires", window) + + go func() { + reapOnce(ctx) + + t := time.NewTicker(ReapInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + reapOnce(ctx) + } + } + }() +} + +func reapOnce(ctx context.Context) { + runCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + checked, purged, err := ReapFreeInstances(runCtx) + if err != nil { + log.Printf("reaper: %v", err) + return + } + if purged > 0 { + log.Printf("reaper: checked %d, purged %d", checked, purged) + } +} +``` + +**Note on the audit write:** `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details string)` is the real helper in `server/internal/services/audit.go:14`. It returns nothing, so there is no error to handle — and its row is deleted by the purge moments later anyway. The durable record is the `log.Printf` line above it, which names the instance, slug, expiry and window. Do not invent a new audit helper, and do not try to preserve an audit row for a deleted instance; admin's `admin_audit` is where cross-instance history lives. + +- [ ] **Step 2: Start it at boot** + +In `server/cmd/main.go`, next to `monitorsched.Start(context.Background())`: + +```go + services.StartReaper(context.Background()) +``` + +- [ ] **Step 3: Confirm it compiles** + +Run: +```sh +sh /tmp/gorun.sh server go build ./... +``` +Expected: no output. + +- [ ] **Step 4: Confirm the collection list is complete** + +Run: +```sh +grep -rho 'db\.Col("[a-z_]*"' server/internal/ | sed 's/db.Col("//;s/"//' | sort -u +``` +Expected output, exactly: +``` +assignments +audit_logs +console_sessions +incidents +instance_oidc +instances +keys +migrations +monitor_rollups +monitors +notification_channels +orgs +secrets +servers +settings +users +workflow_runs +workflow_steps +workflows +``` + +Every name there must appear in `instanceScopedCollections` except three: `instances` (deleted last, by `instance_id`), `migrations` (has no `instance_id`) and `orgs` (the pre-rename collection, empty after migration 0004). If the real output contains a name not in that list, add it to `instanceScopedCollections` and say so in your report. + +- [ ] **Step 5: Commit** + +```bash +git add server/ +git commit -m "feat(server): reap Free instances whose licence lapsed + +The control plane owns deletion because it is the only service that knows +what an instance is made of; mirroring that collection list into admin +would drift, and a drift here deletes the wrong rows. + +Defaults OFF. Eligibility is three positive assertions — Free tier, an +expiry that exists, and an expiry past the window — so a missing or stale +field is never eligible. The instance document is deleted last, making an +interrupted purge retryable rather than orphaning rows. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: The reconciler marks reaped instances + +**Files:** +- Modify: `admin/internal/inject/inject.go` + +**Interfaces:** +- Consumes: `models.StatusDeleted` from Task 2. +- Produces: nothing new. + +The reaper deletes the control-plane instance. Admin's `admin_instances` row must stop claiming it is active, or the lifecycle sweep keeps emailing about an instance that no longer exists. + +- [ ] **Step 1: Mark vanished instances deleted** + +In `admin/internal/inject/inject.go`, inside `Reconcile`'s loop, replace: + +```go + var remote sharedmodels.Instance + if err := db.Control("instances").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil { + log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err) + continue + } +``` + +with: + +```go + var remote sharedmodels.Instance + if err := db.Control("instances").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil { + // The control plane's reaper deletes lapsed Free instances. Record + // that here rather than re-logging it every fifteen minutes forever, + // and so the lifecycle sweep stops emailing about it. + if errors.Is(err, mongo.ErrNoDocuments) { + if _, uErr := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil { + log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr) + } else { + log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID) + } + continue + } + log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err) + continue + } +``` + +Add `"errors"` and `"go.mongodb.org/mongo-driver/v2/mongo"` to the imports. + +- [ ] **Step 2: Confirm it compiles** + +Run: +```sh +sh /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add admin/internal/inject/inject.go +git commit -m "feat(admin): reconciler marks reaped instances deleted + +Without this the row stays active forever, the reconciler re-logs the +same miss every fifteen minutes, and the lifecycle sweep keeps emailing +about an instance that no longer exists. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 7: The HQ portal — create and renew + +**Files:** +- Create: `adminsite/app/(customer)/instances/new/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx` +- Modify: `adminsite/lib/api.ts`, `adminsite/components/InstanceCard.tsx`, `adminsite/app/(customer)/page.tsx` + +**Interfaces:** +- Consumes: `POST /api/instances` (Task 2), `POST /api/instances/:id/renew` (Task 3). +- Produces: `api.createInstance(name)`, `api.renewInstance(id)`. + +Design rules from `CLAUDE.md` that bind this task: `adminsite` uses only `var(--…)` Tailwind tokens — **no component may carry a hex value**. Licence state never reads by colour alone; every pill carries a shape and a text label. Light is the default theme and must stay so. + +- [ ] **Step 1: Extend the API client** + +In `adminsite/lib/api.ts`: + +- Change `InstanceStatus` to include `"deleted"`: + ```ts + export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted"; + ``` +- Add `notices_sent?: string[];` to the `Instance` interface. +- Add to the `api` object, after `link`: + ```ts + createInstance: (name: string) => post("/api/instances", { name }), + renewInstance: (id: string) => post(`/api/instances/${id}/renew`, {}), + ``` + +- [ ] **Step 2: Write the create form** + +Create `adminsite/app/(customer)/instances/new/CreateForm.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ApiError, api } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/Field"; + +function slugify(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function CreateForm() { + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const router = useRouter(); + const qc = useQueryClient(); + + const create = useMutation({ + mutationFn: () => api.createInstance(name.trim()), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ["account"] }); + router.push("/"); + }, + onError: (e) => + setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."), + }); + + const slug = slugify(name); + + return ( +
{ + e.preventDefault(); + setError(null); + if (name.trim()) create.mutate(); + }} + > + setName(e.target.value)} + placeholder="Northgate Systems" + required + error={error ?? undefined} + hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`} + /> + +

+ You sign in to it with this same email address and password. Changing one does not + change the other afterwards. +

+ + + + ); +} +``` + +The real signatures, already checked — do not change these components: + +- `Field` takes `React.InputHTMLAttributes` plus `label: string`, `hint?: React.ReactNode`, `error?: string`. **It renders its own `` and takes no children**, which is why the slug preview goes in `hint`. +- `Button` takes `React.ButtonHTMLAttributes` plus `variant?: "solid" | "line"`, and already carries its own classes. Do not pass a `className` of your own. + +- [ ] **Step 3: Write the page** + +Create `adminsite/app/(customer)/instances/new/page.tsx`: + +```tsx +import type { Metadata } from "next"; +import { CreateForm } from "./CreateForm"; + +export const metadata: Metadata = { title: "New instance" }; + +export default function NewInstancePage() { + return ( +
+
+

Create a free instance

+

+ An instance owns its servers, keys, workflows, monitors and secrets. Nothing + inside it is visible to any other instance. Free covers three servers, and the + licence runs for a month at a time — we email you before it needs renewing. +

+
+ +
+ ); +} +``` + +- [ ] **Step 4: Add the call to action** + +In `adminsite/app/(customer)/page.tsx`, replace the "No instances yet" block's contents with a version that offers the action. Keep the existing wrapper classes; change the inner markup to: + +```tsx +
+

No instances yet

+

+ Create a free cloud instance and we host it, with your licence applied + automatically. Or buy a self-hosted licence, install Vantage on your own + server, and link it here to get your licence file. +

+ + Create a free instance + +
+``` + +And below the instances grid, when the account has instances but no Free cloud one, add: + +```tsx + {data.instances.length > 0 && + !data.instances.some( + (i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted", + ) && ( + + Create a free instance + + )} +``` + +- [ ] **Step 5: Renew action and deletion countdown on the card** + +In `adminsite/components/InstanceCard.tsx`: + +- Make it a client component: add `"use client";` as the first line, and import `useMutation`, `useQueryClient` from `@tanstack/react-query` and `api` from `@/lib/api`. +- The progress bar currently divides by 365, which renders a 30-day Free licence as a 8% sliver. Make the denominator depend on the tier: + ```tsx + const termDays = instance.tier === "free" ? 30 : 365; + ``` + and use `(days / termDays) * 100` in the width calculation. +- Add, after the `state === "expired"` paragraph, a deletion countdown driven by props rather than colour: + ```tsx + {state === "expired" && deleteInDays !== null && ( +

+ {deleteInDays <= 0 + ? "Scheduled for deletion." + : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`} +

+ )} + ``` + Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised. +- Add a Renew button for Free instances inside the window: + ```tsx + const qc = useQueryClient(); + const renew = useMutation({ + mutationFn: () => api.renewInstance(instance.instance_id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }), + }); + const canRenew = instance.tier === "free" && license !== undefined && days <= 7; + ``` + and render it beside the existing link when `canRenew`: + ```tsx + {canRenew && ( + + )} + ``` + +`daysRemaining` already exists in `adminsite/lib/format.ts`; check its exact signature before use. + +- [ ] **Step 6: Confirm it builds** + +Run: +```sh +MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \ + sh -c "npm ci --silent && npm run build" +``` +Expected: a successful Next.js build. Type errors here are real — fix them rather than loosening types. + +- [ ] **Step 7: Confirm no hex colours were introduced** + +Run: +```sh +grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/ adminsite/app/ --include=*.tsx +``` +Expected: no matches. `CLAUDE.md` requires `adminsite` components to reference `var(--…)` tokens only. + +- [ ] **Step 8: Commit** + +```bash +git add adminsite/ +git commit -m "feat(adminsite): create and renew a free instance + +Adds the create form with a live slug preview, a renew action inside the +seven-day window, and a deletion countdown that renders only when the +backend has actually promised a date. + +The progress bar denominator now follows the tier; a 30-day Free licence +was rendering as an 8% sliver against the hardcoded 365. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 8: The marketing form creates an account + +**Files:** +- Create: `site/components/AccountForm.tsx` +- Delete: `site/components/InstanceForm.tsx` +- Modify: `site/lib/submit.ts`, `site/app/start/page.tsx` + +**Interfaces:** +- Consumes: `POST /auth/signup` on admin (already exists; unchanged by this phase). +- Produces: `submitAccountSignup(fields)` in `site/lib/submit.ts`. + +The form now creates an HQ **account**, not an instance. The live slug preview must go: there is no instance at this point, and showing `your-instance.vantage.hostxtra.co.uk` would promise something the submission does not create. + +- [ ] **Step 1: Point signup at admin** + +In `site/lib/submit.ts`, add near `SITE_API`: + +```ts +const ADMIN_API = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, ""); +``` + +Replace the whole `submitSignup` function with: + +```ts +/* + * Account signup posts to the admin service, not sitesvc. The two form targets + * are deliberately separate variables rather than one base URL: contact and + * signup are owned by different services, and an implied shared host is how they + * silently end up pointing at the wrong one. + */ +export async function submitAccountSignup(fields: { + name: string; + email: string; + password: string; + website: string; +}): Promise { + if (!ADMIN_API) { + return { + state: "error", + message: `Signup is not connected yet. Email ${FALLBACK_ADDRESS} and we will set you up.`, + }; + } + return post(`${ADMIN_API}/auth/signup`, fields); +} +``` + +- [ ] **Step 2: Write the account form** + +Create `site/components/AccountForm.tsx` by adapting `site/components/InstanceForm.tsx`: + +- Read `site/components/InstanceForm.tsx` first and keep its markup conventions, class names, `Honeypot` usage, error rendering and `MIN_PASSWORD` check. +- Rename the component to `AccountForm`. +- Replace the `instance_name` field with a `name` field labelled **"Your organisation"**, placeholder `Northgate Systems`. +- Delete the `slug` state, the `slugify` helper and the `` preview entirely. +- Call `submitAccountSignup` with `{ name, email, password, website }`. +- Change the success panel text to: + ``` + Check your email. + We sent a confirmation link. Open it and your Vantage account is ready — then you + can create your first instance from the portal. The link works once and expires in + 24 hours. + ``` + +Then delete `site/components/InstanceForm.tsx`. + +- [ ] **Step 3: Update the start page** + +In `site/app/start/page.tsx`: + +- Import `AccountForm` instead of `InstanceForm` and render it. +- Change the `

` to `Create your account.` +- Change the lede to: + ``` + Your account is where instances, licences and billing live. Confirm your email and + you can create a free instance straight away — three servers, hosted by us. + ``` +- Change the "What happens next" specs to four steps, in this order: + 1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before." + 2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner." + 3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once." + 4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds." +- Update `metadata.description` to describe an account rather than an instance. + +- [ ] **Step 4: Confirm it builds** + +Run: +```sh +MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/site":/app -w /app node:26-alpine \ + sh -c "npm ci --silent && npm run build" +``` +Expected: a successful build. + +- [ ] **Step 5: Confirm nothing still imports the deleted component** + +Run: +```sh +grep -rn "InstanceForm\|submitSignup" site/ +``` +Expected: no matches. + +- [ ] **Step 6: Commit** + +```bash +git add -A site/ +git commit -m "feat(site): /start creates an account, not an instance + +The form posts to admin's signup and the slug preview goes: there is no +instance at this point, and previewing one promises something the +submission does not create. Creating the instance is now a step in the +portal, which the page's What happens next panel spells out. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Retire sitesvc's signup + +**Files:** +- Delete: `sitesvc/internal/api/signup.go` +- Modify: `sitesvc/internal/api/api.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models/`, `sitesvc/cmd/main.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: sitesvc serving `POST /api/contact` and nothing else. + +**Do this task last, and do not deploy it until Task 8 is live.** The cutover is staged for a reason: sitesvc's `/api/verify` must keep answering until every outstanding pending signup has expired, or someone's verification link breaks. The 24-hour wait belongs to the deployment, not to this commit — but the commit is what makes the wait necessary, so it goes last. + +- [ ] **Step 1: Remove the handlers** + +- Delete `sitesvc/internal/api/signup.go`. +- In `sitesvc/internal/api/api.go`, remove the `/api/signup` and `/api/verify` route registrations, the `signups` rate limiter field and its initialisation, and the `publicURL` / `appLoginURL` fields if nothing else uses them. Keep `/api/contact` and the health endpoint exactly as they are. + +- [ ] **Step 2: Remove the store's signup half** + +In `sitesvc/internal/store/store.go`, delete `CreatePending`, `Verify`, `EmailTaken`, `randomToken`, `hashToken`, `PendingTTL`, the `ErrEmailTaken` / `ErrBadToken` / `ErrNameRejected` vars, and the `site_pending_signups` index block inside `EnsureIndexes`. + +Keep `Connect`, `DatabaseName`, `col` and `RequireMigratedDatabase`. + +`EnsureIndexes` should now only call `indexes.EnsureCoreIndexes`. Keep that call: it is cheap, it is idempotent, and it means sitesvc does not depend on another service having started first. + +Delete `sitesvc/internal/models/` if `PendingSignup` was its only type; otherwise delete just that type. + +- [ ] **Step 3: Drop the unused config** + +In `sitesvc/cmd/main.go`, remove `APP_LOGIN_URL` and any signup-only wiring. Leave `PUBLIC_URL` if the contact flow still uses it; remove it if not. + +- [ ] **Step 4: Confirm it compiles and the signup surface is gone** + +Run: +```sh +sh /tmp/gorun.sh sitesvc go build ./... +``` +Expected: no output. + +Run: +```sh +grep -rn "site_pending_signups\|CreatePending\|handleSignup\|handleVerify" sitesvc/ +``` +Expected: no matches. + +- [ ] **Step 5: Run `go mod tidy` with GOWORK off** + +Removing code may orphan a dependency: + +```sh +MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ + -w /src/sitesvc -e GOWORK=off golang:1.26 go mod tidy +sh /tmp/gorun.sh sitesvc go build ./... +``` +Expected: build still clean. `GOWORK=off` is mandatory — in workspace mode `tidy` drops `require` lines and the Docker build then fails with "missing go.sum entry". + +- [ ] **Step 6: Commit** + +```bash +git add -A sitesvc/ +git commit -m "refactor(sitesvc): remove signup and verification + +Account creation moved to admin, which owns accounts, and the marketing +form now posts there. sitesvc keeps the contact mailer only. + +DEPLOY LAST: sitesvc's verify endpoint must stay live until every +outstanding pending signup has expired, or an in-flight verification link +breaks. Do not roll this out until the site change has been live 24 hours +and site_pending_signups is empty. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 10: Configuration, docs and end-to-end verification + +**Files:** +- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md` + +This task proves the phase. With no test suite, this transcript is the only evidence — run it in full. + +- [ ] **Step 1: Wire the compose file** + +In `deploy/docker-compose.site.yml`: + +- On `admin`, add `APP_LOGIN_URL: "https://{slug}.vantage.hostxtra.co.uk/login"` and `FREE_INSTANCE_REAP_AFTER: "336h"`. +- On `server`, add `FREE_INSTANCE_REAP_AFTER: "336h"`. +- On `sitesvc`, remove `APP_LOGIN_URL`. + +**Do not add `FREE_INSTANCE_REAP_AFTER` to `deploy/docker-compose.yml`.** A self-hosted deployment must never reap. Confirm after editing: + +```sh +grep -n "FREE_INSTANCE_REAP_AFTER" deploy/docker-compose.yml +``` +Expected: no matches. + +The two values must match: admin uses it to name the deletion date in emails, the server uses it to decide. `CLAUDE.md` documents them as a pair in Step 3. + +- [ ] **Step 2: Add the site build arg** + +In `.gitea/workflows/server-deploy.yml`, wherever the `site` image is built, pass `ADMIN_API_URL` through as a build arg alongside the existing `SITE_API_URL`, mapping to `NEXT_PUBLIC_ADMIN_API_URL`. Read how `SITE_API_URL` is wired and mirror it exactly — including any `site/Dockerfile` `ARG`/`ENV` lines it needs. + +- [ ] **Step 3: Update `CLAUDE.md`** + +- In **Subsystems → Marketing site and sitesvc**, change the table so only Contact remains, and state that account signup posts to admin. +- In the **Signup and verification** section, replace the sitesvc-centred description with the account-first flow: signup creates an HQ account, the instance is created from the portal afterwards, and `site_pending_signups` is gone. +- In **Admin REST API**, add `POST /api/instances` and `POST /api/instances/:id/renew` to the customer-session block. +- In **Environment Variables (server)**, add: + | `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails | +- In **Design Decisions**, add: + - **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows. +- Update the sentence in **Admin REST API** or **Security** that describes admin's control-plane access as read-only apart from three licence fields, to name `cloudprov` as the second write path. + +- [ ] **Step 4: Build every image** + +```sh +MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:p2 . +MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:p2 . +MSYS_NO_PATHCONV=1 docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:p2 . +``` +Expected: three image IDs. A "missing go.sum entry" failure means Step 5 of Task 9 was run in workspace mode. + +- [ ] **Step 5: Start scratch infrastructure** + +```sh +MSYS_NO_PATHCONV=1 docker run -d --name p2-redis -p 6390:6379 redis:7 +MSYS_NO_PATHCONV=1 docker run -d --name p2-mongo -p 27024:27017 mongo:7 +sleep 6 +MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \ + -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \ + -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \ + -e GITEA_HOST=example.invalid \ + --add-host host.docker.internal:host-gateway vantage-server:p2 +sleep 6 +MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper +``` +Expected: `reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)`. **This is the safety default and must appear.** + +- [ ] **Step 6: Start admin with a real signing key** + +`licensing.Issue` actually signs here, so a placeholder will not do. Generate one and keep it for the rest of this task: + +```sh +export LK=$(MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ + -w /src/shared golang:1.26 go run ./cmd/lkctl genkey 2>/dev/null | tail -1) +echo "key length: ${#LK}" +``` + +Check `shared/cmd/lkctl`'s real subcommand names first (`go run ./cmd/lkctl --help`) and use whatever it provides to produce a signing key. If `lkctl` cannot generate one, say so and stop — issuing licences cannot be verified without it. + +```sh +MSYS_NO_PATHCONV=1 docker run -d --name p2-admin -p 8094:8083 \ + -e ADMIN_MONGO_URI=mongodb://host.docker.internal:27024/p2_admin \ + -e CONTROL_MONGO_URI=mongodb://host.docker.internal:27024/p2 \ + -e REDIS_ADDR=host.docker.internal:6390 \ + -e LICENSE_SIGNING_KEY="$LK" \ + -e PUBLIC_URL=http://localhost:8094 -e ADMIN_ORIGIN=http://localhost:3004 \ + -e APP_LOGIN_URL='https://{slug}.vantage.test/login' \ + --add-host host.docker.internal:host-gateway vantage-admin:p2 +sleep 6 +curl -s http://localhost:8094/healthz +``` +Expected: `{"ok":true}`. + +- [ ] **Step 7: Create an account and verify it** + +SMTP is not configured, so signup will refuse to send. Create the account and mark it verified directly: + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval ' + const acct = { account_id: "acct-test", name: "Test Co", + billing_email: "owner@example.com", status: "active", created_at: new Date() }; + db.accounts.insertOne(acct); + // bcrypt cost-12 hash of "hunter2hunter2" + db.customer_users.insertOne({ user_id: "cu-test", account_id: "acct-test", + email: "owner@example.com", + password_hash: "$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyDF2xrjRkPYVe", + verified_at: new Date(), created_at: new Date() }); + print("seeded account + verified user");' +``` + +That hash is a well-known bcrypt test vector; if the sign-in below returns 401, generate a real cost-12 hash instead and say so in your report. + +```sh +curl -s -X POST http://localhost:8094/auth/login -H 'Content-Type: application/json' \ + -c /tmp/p2.jar -d '{"email":"owner@example.com","password":"hunter2hunter2"}' +``` +Expected: `{"kind":"customer","email":"owner@example.com"}`. + +- [ ] **Step 8: Seed the Free plan and create an instance** + +Admin seeds `plans` at boot from `shared/license`; confirm Free is there: + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval \ + 'printjson(db.plans.find({},{tier:1,deployment:1,_id:0}).toArray())' +``` +Expected: includes `{tier:"free", deployment:"cloud"}`. If `plans` is empty, admin's seeding did not run — report it. + +```sh +curl -s -X POST http://localhost:8094/api/instances -b /tmp/p2.jar \ + -H 'Content-Type: application/json' -d '{"name":"Northgate Systems"}' +``` +Expected: `201` with an instance whose `slug` is `northgate-systems`, `deployment` `cloud`, `status` `active`, and a `tier` of `free`. + +- [ ] **Step 9: Confirm the whole chain landed** + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024" --quiet --eval ' + const c = db.getSiblingDB("p2"), a = db.getSiblingDB("p2_admin"); + const inst = c.instances.findOne({slug:"northgate-systems"}); + print("control instance: " + (inst ? inst.instance_id : "MISSING")); + const u = c.users.findOne({instance_id: inst.instance_id}); + print("owner: " + u.email + " role=" + u.role + " auth_source=" + u.auth_source + " hq_user_id=" + u.hq_user_id); + print("license_tier: " + inst.license_tier + " expiry: " + inst.license_expiry); + print("blob present: " + (inst.license_blob ? inst.license_blob.length > 0 : false)); + const ai = a.admin_instances.findOne({instance_id: inst.instance_id}); + print("admin row: status=" + ai.status + " tier=" + ai.tier + " account=" + ai.account_id); + print("licences recorded: " + a.licenses.countDocuments({instance_id: inst.instance_id}));' +``` + +Expected, all of them: +- `owner: owner@example.com role=owner auth_source=hq hq_user_id=cu-test` +- `license_tier: free`, an expiry roughly one month and three days out +- `blob present: true` — this proves injection ran +- `admin row: status=active tier=free account=acct-test` +- `licences recorded: 1` + +- [ ] **Step 10: Confirm the owner can sign in to the new instance** + +```sh +curl -s -X POST http://localhost:8092/auth/login -H 'Host: northgate-systems.vantage.test' \ + -H 'Content-Type: application/json' -c /tmp/p2i.jar \ + -d '{"email":"owner@example.com","password":"hunter2hunter2"}' +curl -s http://localhost:8092/auth/me -H 'Host: northgate-systems.vantage.test' -b /tmp/p2i.jar +``` +Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the payoff of the whole phase — the HQ password works on the instance. + +- [ ] **Step 11: Confirm the Free cap** + +```sh +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8094/api/instances \ + -b /tmp/p2.jar -H 'Content-Type: application/json' -d '{"name":"Second One"}' +``` +Expected: `409`. + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \ + 'print("instances: " + db.instances.countDocuments({}))' +``` +Expected: `instances: 1`. The refusal must leave nothing behind. + +- [ ] **Step 12: Confirm renewal refuses outside the window** + +```sh +INST=$(MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \ + 'print(db.instances.findOne({slug:"northgate-systems"}).instance_id)') +curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar +``` +Expected: `400` with "not due yet". + +- [ ] **Step 13: Confirm renewal works inside the window** + +Move the licence's expiry to two days out, then renew: + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval ' + const soon = new Date(Date.now() + 2*24*3600*1000); + db.licenses.updateMany({}, {$set:{expires_at: soon}}); + print("expiry moved to " + soon.toISOString());' + +curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar +``` +Expected: `200` with a new licence whose `expires_at` is about a month out, and a `reason` of `renewal`. + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024" --quiet --eval ' + const a = db.getSiblingDB("p2_admin"), c = db.getSiblingDB("p2"); + print("licences: " + a.licenses.countDocuments({})); + print("superseded: " + a.licenses.countDocuments({superseded_by:{$exists:true,$ne:""}})); + const inst = c.instances.findOne({slug:"northgate-systems"}); + print("injected expiry: " + inst.license_expiry);' +``` +Expected: 2 licences, 1 superseded, and the injected expiry matching the new one. + +- [ ] **Step 14: Confirm the reaper stays off, then deletes only what it should** + +Restart the server WITH the window set, and an instance whose licence expired long ago: + +```sh +MSYS_NO_PATHCONV=1 docker rm -f p2-server +MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \ + -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \ + -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \ + -e GITEA_HOST=example.invalid -e FREE_INSTANCE_REAP_AFTER=336h \ + --add-host host.docker.internal:host-gateway vantage-server:p2 +sleep 6 +MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper +``` +Expected: `reaper: ENABLED — Free instances are deleted 336h0m0s after their licence expires`. + +Now seed three instances the reaper must treat differently, plus rows scoped to the doomed one: + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval ' + const old = new Date(Date.now() - 400*3600*1000); // past the 336h window + const recent = new Date(Date.now() - 100*3600*1000); // expired, inside the window + db.instances.insertOne({instance_id:"doomed", name:"Doomed", slug:"doomed", + license_tier:"free", license_expiry: old, created_at:new Date()}); + db.instances.insertOne({instance_id:"safe-recent", name:"Recent", slug:"recent", + license_tier:"free", license_expiry: recent, created_at:new Date()}); + db.instances.insertOne({instance_id:"safe-paid", name:"Paid", slug:"paid", + license_tier:"professional", license_expiry: old, created_at:new Date()}); + db.instances.insertOne({instance_id:"safe-nolicence", name:"NoLic", slug:"nolic", + created_at:new Date()}); + db.users.insertOne({user_id:"u-doomed", instance_id:"doomed", email:"d@example.com", + role:"owner", auth_source:"local", created_at:new Date()}); + db.servers.insertOne({instance_id:"doomed", name:"srv"}); + db.secrets.insertOne({instance_id:"doomed", group:"g"}); + print("seeded 4 instances");' +``` + +Now force a sweep. `StartReaper` sweeps once at boot, so a restart runs it immediately: + +```sh +MSYS_NO_PATHCONV=1 docker restart p2-server +sleep 10 +MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i "REAPING\|reaped\|reaper:" +``` +Expected: a `REAPING instance doomed (Doomed, slug=doomed)` line naming the expiry and window, then `reaped instance doomed: map[...]` listing the deleted counts, then `reaper: checked 1, purged 1`. + +**`checked` must be 1.** If it is higher, something ineligible was selected — stop and report it. This is the one bug in this phase that destroys customer data. + +- [ ] **Step 15: Confirm the purge deleted exactly the doomed instance** + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval ' + print("doomed instance: " + db.instances.countDocuments({instance_id:"doomed"})); + print("doomed users: " + db.users.countDocuments({instance_id:"doomed"})); + print("doomed servers: " + db.servers.countDocuments({instance_id:"doomed"})); + print("doomed secrets: " + db.secrets.countDocuments({instance_id:"doomed"})); + print("--- survivors ---"); + print("safe-recent: " + db.instances.countDocuments({instance_id:"safe-recent"})); + print("safe-paid: " + db.instances.countDocuments({instance_id:"safe-paid"})); + print("safe-nolicence: " + db.instances.countDocuments({instance_id:"safe-nolicence"})); + print("northgate: " + db.instances.countDocuments({slug:"northgate-systems"})); + print("northgate users: " + db.users.countDocuments({email:"owner@example.com"}));' +``` + +Expected, every line: +- all four `doomed` counts are `0` +- all four survivor counts are `1` + +A surviving `doomed` row means `instanceScopedCollections` is missing a collection. A missing survivor means the eligibility rule is too broad — either is a stop-and-report. + +- [ ] **Step 15b: Confirm the purge is idempotent** + +Restart again. `doomed` is already gone, so the sweep must find nothing and must not error: + +```sh +MSYS_NO_PATHCONV=1 docker restart p2-server +sleep 10 +MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | tail -20 | grep -i "reaper\|panic\|error" +``` +Expected: `reaper: ENABLED …` and nothing else — no `REAPING`, no panic, no error. + +- [ ] **Step 15c: Confirm the collection list is exhaustive** + +This is the check that catches a leak nothing else would: + +```sh +MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ + mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval ' + const names = db.getCollectionNames().sort(); + print("collections present: " + names.join(", ")); + let orphanable = []; + names.forEach(n => { + if (["instances","migrations","orgs"].includes(n)) return; + const s = db.getCollection(n).findOne({instance_id:{$exists:true}}); + if (s) orphanable.push(n); + }); + print("carry instance_id: " + orphanable.join(", "));' +``` + +Every name in `carry instance_id` must appear in `instanceScopedCollections` in `server/internal/services/reap.go`. Compare them by eye and state the result explicitly in your report. Any collection present there and absent from the list leaks rows that outlive their instance. + +- [ ] **Step 16: Tear down** + +```sh +MSYS_NO_PATHCONV=1 docker rm -f p2-server p2-admin p2-mongo p2-redis +``` + +- [ ] **Step 17: Commit** + +```bash +git add deploy/ .gitea/ CLAUDE.md +git commit -m "docs: phase 2 configuration and the reaper's containment + +FREE_INSTANCE_REAP_AFTER is set only in docker-compose.site.yml, so a +self-hosted deployment can never reap. Admin and server must carry the +same value: one names the deletion date in warnings, the other acts on it. + +Records that admin now has a second control-plane write path, cloudprov, +and that deletion lives in the control plane because that is where the +knowledge of what an instance is made of belongs. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Done when + +- A verified HQ customer can create a Free cloud instance in one request, and the control plane, `admin_instances`, the licence and the injected blob all agree. +- The instance owner signs in to their new instance with their HQ password, and their control-plane row carries `auth_source: hq` and `hq_user_id`. +- A second Free instance is refused `409` and leaves nothing behind. +- Renewal refuses outside the window, works inside it, supersedes the old licence and re-injects. +- The reaper logs `DISABLED` with no configuration, and its eligibility query selects exactly the one instance that is Free, licensed, and past the window. +- Every collection carrying `instance_id` appears in `instanceScopedCollections`. +- `deploy/docker-compose.yml` does not mention `FREE_INSTANCE_REAP_AFTER`. +- `grep -rn "InstanceForm\|submitSignup" site/` and `grep -rn "site_pending_signups" sitesvc/` both return nothing. + +**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice *selection* run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat. + +## Deployment order + +This phase is not safe to roll out in an arbitrary order: + +1. `server` and `admin` first, with `FREE_INSTANCE_REAP_AFTER` **unset**, so creation and renewal work while nothing can be deleted. +2. `site`, pointing at admin's signup. +3. Wait 24 hours with sitesvc's verify still live, until `site_pending_signups` is empty. +4. `sitesvc` without signup. +5. Only then set `FREE_INSTANCE_REAP_AFTER=336h` on both `server` and `admin`, once you have watched a notice email actually send. + +## Not in this phase + +Account roles, invitations, instance membership, per-instance grants, password propagation, and the `web/` read-only treatment for `hq`-sourced users. Those are phase 3.