From c4f16843042138391b6c0892870f3d82f5292466 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 16:08:29 +0100 Subject: [PATCH] docs(plan): drop backfill, add staff instance attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing cloud instances get licensed by hand through the admin UI instead of an automated backfill. That needs POST /api/staff/instances, which nothing else provided — without it there is no way to attach an existing cloud instance to an account. --- .../plans/2026-07-24-admin-backend.md | 269 ++++++++---------- 1 file changed, 112 insertions(+), 157 deletions(-) diff --git a/docs/superpowers/plans/2026-07-24-admin-backend.md b/docs/superpowers/plans/2026-07-24-admin-backend.md index 88fa9ef..cf1aaba 100644 --- a/docs/superpowers/plans/2026-07-24-admin-backend.md +++ b/docs/superpowers/plans/2026-07-24-admin-backend.md @@ -30,7 +30,9 @@ ## Context this plan inherits -Plan 2 shipped without grandfathering — the migration that would have licensed existing cloud instances was removed at the user's direction. **Every existing cloud instance is therefore read-only right now.** Task 12's backfill is not a tidy-up; it is what restores those instances to working order, and it must run before admin is considered live. +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. --- @@ -42,7 +44,7 @@ Plan 2 shipped without grandfathering — the migration that would have licensed |---|---| | `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` | | `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP | -| `admin/cmd/adminctl/main.go` | staff user creation and licence backfill; no HTTP surface for either | +| `admin/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` | @@ -429,7 +431,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... ``` -Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 12.) +Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.) - [ ] **Step 8: Commit** @@ -1568,10 +1570,9 @@ Create `admin/cmd/adminctl/main.go`: ```go // Command adminctl performs the operations that deliberately have no HTTP -// surface: creating staff users, and backfilling licences issued by hand. +// surface. // // adminctl staff-add --email=you@example.com --name="You" --password=... -// adminctl backfill --from=blobs.json // // There is no staff signup endpoint. A licensing authority that can be joined // over the internet is not one. @@ -1595,7 +1596,7 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: adminctl staff-add | backfill") + fmt.Fprintln(os.Stderr, "usage: adminctl staff-add") os.Exit(2) } @@ -1612,10 +1613,8 @@ func main() { switch os.Args[1] { case "staff-add": staffAdd(ctx, os.Args[2:]) - case "backfill": - backfill(ctx, cfg, os.Args[2:]) default: - fmt.Fprintln(os.Stderr, "usage: adminctl staff-add | backfill") + fmt.Fprintln(os.Stderr, "usage: adminctl staff-add") os.Exit(2) } } @@ -1649,15 +1648,6 @@ func staffAdd(ctx context.Context, args []string) { fmt.Printf("created staff user %s\n", u.Email) } -// backfill is implemented in Task 12. -func backfill(ctx context.Context, cfg config.Config, args []string) { - _ = ctx - _ = cfg - _ = args - _ = bson.M{} - fatal("backfill is implemented in Task 12") -} - func fatal(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) os.Exit(1) @@ -2452,6 +2442,7 @@ func Routes(cfg config.Config) http.Handler { 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) @@ -2531,11 +2522,15 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" "github.com/mrhid6/vantage/admin/internal/auth" "github.com/mrhid6/vantage/admin/internal/db" "github.com/mrhid6/vantage/admin/internal/licensing" "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + sharedmodels "github.com/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" ) @@ -2642,6 +2637,70 @@ func staffListInstances(c *gin.Context) { 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"` @@ -2881,131 +2940,7 @@ git commit -m "feat(admin): compose service and image build" --- -### Task 11: Backfill - -Existing cloud instances are read-only right now — plan 2 shipped without grandfathering. This is what fixes them, and it must run before admin is considered live. - -**Files:** -- Modify: `admin/cmd/adminctl/main.go` - -**Interfaces:** -- Consumes: `licensing.Issue`, `inject.Cloud`, `db.Control` -- Produces: `adminctl backfill` - -- [ ] **Step 1: Replace the backfill stub** - -In `admin/cmd/adminctl/main.go`, replace the stub with: - -```go -// backfill creates an account, an admin instance row and a licence for every -// cloud instance in the control plane that does not already have one, then -// injects it. -// -// This is not a tidy-up. Plan 2 shipped without grandfathering, so every -// existing cloud instance is read-only until this runs. -// -// It is idempotent: instances that already have an admin row are skipped. -func backfill(ctx context.Context, cfg config.Config, args []string) { - fs := flag.NewFlagSet("backfill", flag.ExitOnError) - tier := fs.String("tier", "professional", "tier to issue") - term := fs.String("term", "annual", "monthly or annual") - apply := fs.Bool("apply", false, "actually write; without it, only report") - fs.Parse(args) - - licensing.SetSigningKey(cfg.SigningKey) - - cur, err := db.Control("instances").Find(ctx, bson.M{}) - if err != nil { - fatal("list control instances: %v", err) - } - var remote []sharedmodels.Instance - if err := cur.All(ctx, &remote); err != nil { - fatal("decode control instances: %v", err) - } - - for _, r := range remote { - n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": r.InstanceID}) - if err != nil { - fatal("check instance %s: %v", r.InstanceID, err) - } - if n > 0 { - fmt.Printf("skip %s (%s) — already known\n", r.InstanceID, r.Slug) - continue - } - - if !*apply { - fmt.Printf("would %s (%s) — create account + %s licence\n", r.InstanceID, r.Slug, *tier) - continue - } - - acct := models.Account{ - AccountID: uuid.NewString(), - Name: r.Name, - BillingEmail: "", - Status: models.AccountActive, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil { - fatal("create account for %s: %v", r.InstanceID, err) - } - - inst := models.Instance{ - InstanceID: r.InstanceID, - AccountID: acct.AccountID, - Name: r.Name, - Slug: r.Slug, - Deployment: license.DeploymentCloud, - Status: models.StatusActive, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { - fatal("create instance row for %s: %v", r.InstanceID, err) - } - - lic, err := licensing.Issue(ctx, licensing.IssueInput{ - InstanceID: r.InstanceID, - Tier: *tier, - Term: *term, - Reason: models.ReasonManual, - IssuedBy: "backfill", - }) - if err != nil { - fatal("issue for %s: %v", r.InstanceID, err) - } - if err := inject.Cloud(ctx, lic); err != nil { - fatal("inject for %s: %v", r.InstanceID, err) - } - fmt.Printf("done %s (%s) — %s licence %s\n", r.InstanceID, r.Slug, lic.Tier, lic.LicenseID) - } - - if !*apply { - fmt.Println("\ndry run — nothing written. Re-run with --apply.") - } -} -``` - -Add to that file's imports: `"github.com/mrhid6/vantage/admin/internal/inject"`, `"github.com/mrhid6/vantage/admin/internal/licensing"`, `"github.com/mrhid6/vantage/shared/license"`, `sharedmodels "github.com/mrhid6/vantage/shared/models"`. - -The default is a **dry run**. A tool that writes to production the first time someone types its name is a tool that gets typed by accident. - -- [ ] **Step 2: Build** - -```bash -sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... -``` - -Expected: no output. - -- [ ] **Step 3: Commit** - -```bash -git add admin/ -git commit -m "feat(admin): backfill existing cloud instances" -``` - ---- - -### Task 12: Full verification +### Task 11: Full verification Everything in containers. With no test suite this is the only evidence. @@ -3073,26 +3008,46 @@ docker run --rm -e ADMIN_MONGO_URI=mongodb://x/y -e CONTROL_MONGO_URI=mongodb:// Expected: exits non-zero with `missing required environment: LICENSE_SIGNING_KEY`. -- [ ] **Step 5: Backfill the cloud instance** +- [ ] **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 backfill # dry run -docker exec vadmin adminctl backfill --apply +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: the dry run reports `would`, the apply reports `done`, and the control plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds, with no restart**. This is the whole system working end to end. +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. - [ ] **Step 6: Confirm the Free rule and the deployment check** +Reusing the staff session and `$INSTANCE` from step 5: + ```bash -docker exec vadmin adminctl staff-add --email=staff@example.com --name=Staff --password=correcthorsebattery -curl -s -X POST localhost:8083/auth/staff/login -H 'Content-Type: application/json' \ - -d '{"email":"staff@example.com","password":"correcthorsebattery"}' -c /tmp/s.txt -INSTANCE= curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \ -H 'Content-Type: application/json' -d '{"tier":"self_hosted","term":"annual"}' ``` @@ -3189,19 +3144,19 @@ git commit -m "chore: verify the admin backend end to end" --allow-empty 1. Deploy admin with `LICENSE_SIGNING_KEY` set, alongside the existing site stack. 2. `adminctl staff-add` for each staff member. There is no signup. -3. `adminctl backfill` dry run, review the list, then `--apply`. **Existing cloud instances are read-only until this runs.** -4. Confirm every instance reports `valid` before announcing anything. -5. Spec 4 (the site) and spec 5 (Paddle) can then proceed in parallel. +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 12 Step 10 verifies instances work with admin stopped | +| 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 12 Step 11 verifies it | +| 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 | -| Backfill run by accident | Dry run is the default; `--apply` is required to write | +| 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 |