diff --git a/docs/superpowers/plans/2026-07-24-admin-backend.md b/docs/superpowers/plans/2026-07-24-admin-backend.md index cf1aaba..d19b593 100644 --- a/docs/superpowers/plans/2026-07-24-admin-backend.md +++ b/docs/superpowers/plans/2026-07-24-admin-backend.md @@ -80,7 +80,7 @@ There is deliberately no automated backfill. Those instances get licensed by han - `config.Config` with `Load() (Config, error)` - `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)` -- [ ] **Step 1: Create the module** +- [x] **Step 1: Create the module** ```bash cd c:/Work/Repos/vantage @@ -120,7 +120,7 @@ use ( ) ``` -- [ ] **Step 2: Write the config** +- [x] **Step 2: Write the config** Create `admin/internal/config/config.go`: @@ -237,7 +237,7 @@ func envOr(key, fallback string) string { } ``` -- [ ] **Step 3: Write the database layer** +- [x] **Step 3: Write the database layer** Create `admin/internal/db/db.go`: @@ -305,7 +305,7 @@ func Ctx() (context.Context, context.CancelFunc) { } ``` -- [ ] **Step 4: Write main.go** +- [x] **Step 4: Write main.go** Create `admin/cmd/main.go`: @@ -371,7 +371,7 @@ func main() { } ``` -- [ ] **Step 5: Write the Dockerfile** +- [x] **Step 5: Write the Dockerfile** Create `admin/Dockerfile` — context is the repo root, exactly like `server/`: @@ -415,7 +415,7 @@ Create `admin/.dockerignore`: *.lic ``` -- [ ] **Step 6: Resolve dependencies outside the workspace** +- [x] **Step 6: Resolve dependencies outside the workspace** ```bash cd c:/Work/Repos/vantage @@ -425,7 +425,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod `GOWORK=off` is required. In workspace mode `tidy` drops the `require` lines the Docker build needs, and the failure only appears at image build time. -- [ ] **Step 7: Build** +- [x] **Step 7: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -433,7 +433,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.) -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add admin/ go.work @@ -456,7 +456,7 @@ git commit -m "feat(admin): module skeleton, config and two database connections - `models.SeedPlans(ctx) error` - `db.EnsureIndexes(ctx) error` -- [ ] **Step 1: Write the documents** +- [x] **Step 1: Write the documents** Create `admin/internal/models/models.go`: @@ -628,7 +628,7 @@ type AuditEntry struct { } ``` -- [ ] **Step 2: Write the plan seed** +- [x] **Step 2: Write the plan seed** Create `admin/internal/models/plans.go`: @@ -685,7 +685,7 @@ func GetPlan(ctx context.Context, tier string) (*Plan, error) { func now() time.Time { return time.Now().UTC() } ``` -- [ ] **Step 3: Add the indexes** +- [x] **Step 3: Add the indexes** Append to `admin/internal/db/db.go`: @@ -744,7 +744,7 @@ func EnsureIndexes(ctx context.Context) error { Add to that file's imports: `"go.mongodb.org/mongo-driver/v2/bson"` and `"go.mongodb.org/mongo-driver/v2/mongo/options"`. -- [ ] **Step 4: Call both at boot** +- [x] **Step 4: Call both at boot** In `admin/cmd/main.go`, after the connect block: @@ -763,7 +763,7 @@ In `admin/cmd/main.go`, after the connect block: Import `"github.com/mrhid6/vantage/admin/internal/models"`. -- [ ] **Step 5: Build** +- [x] **Step 5: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -771,7 +771,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add admin/ @@ -795,7 +795,7 @@ The core of the service. Everything else exists to call this correctly. - `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier` - `audit.Write(ctx, e models.AuditEntry)` -- [ ] **Step 1: Write the audit helper** +- [x] **Step 1: Write the audit helper** Create `admin/internal/audit/audit.go`: @@ -823,7 +823,7 @@ func Write(ctx context.Context, e models.AuditEntry) { } ``` -- [ ] **Step 2: Write issuance** +- [x] **Step 2: Write issuance** Create `admin/internal/licensing/issue.go`: @@ -1008,7 +1008,7 @@ func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) err } ``` -- [ ] **Step 3: Wire the signing key at boot** +- [x] **Step 3: Wire the signing key at boot** In `admin/cmd/main.go`, after config loads: @@ -1018,7 +1018,7 @@ In `admin/cmd/main.go`, after config loads: Import `"github.com/mrhid6/vantage/admin/internal/licensing"`. -- [ ] **Step 4: Build** +- [x] **Step 4: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -1026,7 +1026,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add admin/ @@ -1048,7 +1048,7 @@ git commit -m "feat(admin): licence issuance with plan snapshots and supersessio - `inject.Reconcile(ctx) (checked, repaired int, err error)` - `inject.StartReconciler(ctx)` -- [ ] **Step 1: Write it** +- [x] **Step 1: Write it** Create `admin/internal/inject/inject.go`: @@ -1205,7 +1205,7 @@ func StartReconciler(ctx context.Context) { } ``` -- [ ] **Step 2: Start it at boot** +- [x] **Step 2: Start it at boot** In `admin/cmd/main.go`, before the HTTP server starts: @@ -1217,7 +1217,7 @@ In `admin/cmd/main.go`, before the HTTP server starts: Import `"github.com/mrhid6/vantage/admin/internal/inject"`. -- [ ] **Step 3: Build** +- [x] **Step 3: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -1225,7 +1225,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. -- [ ] **Step 4: Confirm the write surface by inspection** +- [x] **Step 4: Confirm the write surface by inspection** ```bash grep -n "Control(" admin/internal/ -r @@ -1233,7 +1233,7 @@ grep -n "Control(" admin/internal/ -r Expected: reads of `instances` and `users`, and writes only to `instances` with the three licence fields. Any other write target is a bug — fix it before continuing. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add admin/ @@ -1256,7 +1256,7 @@ git commit -m "feat(admin): cloud injection and the 15-minute reconciler" - `auth.HandleStaffLogin`, `auth.HandleLogout` - `adminctl staff-add` -- [ ] **Step 1: Write sessions** +- [x] **Step 1: Write sessions** Create `admin/internal/auth/session.go`: @@ -1353,7 +1353,7 @@ func ClearCookie(c *gin.Context) { } ``` -- [ ] **Step 2: Write the middleware** +- [x] **Step 2: Write the middleware** Create `admin/internal/auth/middleware.go`: @@ -1418,7 +1418,7 @@ func RequireCustomer() gin.HandlerFunc { } ``` -- [ ] **Step 3: Write staff login** +- [x] **Step 3: Write staff login** Create `admin/internal/auth/staff.go`: @@ -1502,7 +1502,7 @@ func HandleLogout(c *gin.Context) { } ``` -- [ ] **Step 4: Write the rate limiter** +- [x] **Step 4: Write the rate limiter** Create `admin/internal/auth/ratelimit.go`: @@ -1564,7 +1564,7 @@ func clearAttempts(email string) { } ``` -- [ ] **Step 5: Write adminctl staff-add** +- [x] **Step 5: Write adminctl staff-add** Create `admin/cmd/adminctl/main.go`: @@ -1654,7 +1654,7 @@ func fatal(format string, a ...any) { } ``` -- [ ] **Step 6: Initialise Redis at boot** +- [x] **Step 6: Initialise Redis at boot** In `admin/cmd/main.go`, after config loads: @@ -1668,7 +1668,7 @@ In `admin/cmd/main.go`, after config loads: pingCancel() ``` -- [ ] **Step 7: Build** +- [x] **Step 7: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -1676,7 +1676,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add admin/ @@ -1694,7 +1694,7 @@ git commit -m "feat(admin): sessions, staff auth and adminctl" - Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models` - Produces: `auth.HandleCloudLogin` -- [ ] **Step 1: Write it** +- [x] **Step 1: Write it** Create `admin/internal/auth/cloud.go`: @@ -1797,7 +1797,7 @@ func HandleCloudLogin(c *gin.Context) { Check the field names on `sharedmodels.User` before building — use `UserID`, `Email`, `PasswordHash`, `Role`, `InstanceID` as defined in `shared/models/user.go`, and correct this file if they differ. -- [ ] **Step 2: Build** +- [x] **Step 2: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -1805,7 +1805,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. (`HandleCustomerLogin` arrives in Task 7 — if you are building tasks in order, stub it as a 501 handler in `customer.go` and replace it there.) -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add admin/ @@ -1823,7 +1823,7 @@ git commit -m "feat(admin): cloud owner login against the control plane" - Consumes: `db.Admin("customer_users")`, config SMTP - Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense` -- [ ] **Step 1: Write mail** +- [x] **Step 1: Write mail** Create `admin/internal/mail/mail.go`: @@ -1885,7 +1885,7 @@ func SendLicense(to, instanceName, blob string) error { } ``` -- [ ] **Step 2: Write customer auth** +- [x] **Step 2: Write customer auth** Create `admin/internal/auth/customer.go`: @@ -2033,7 +2033,7 @@ func HandleCustomerLogin(c *gin.Context) { } ``` -- [ ] **Step 3: Initialise mail at boot** +- [x] **Step 3: Initialise mail at boot** In `admin/cmd/main.go`: @@ -2048,7 +2048,7 @@ In `admin/cmd/main.go`: } ``` -- [ ] **Step 4: Build** +- [x] **Step 4: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -2056,7 +2056,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add admin/ @@ -2079,7 +2079,7 @@ git commit -m "feat(admin): self-hosted customer accounts with email verificatio - `api.Routes(cfg) http.Handler` - `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper -- [ ] **Step 1: Write linking and relink** +- [x] **Step 1: Write linking and relink** Create `admin/internal/licensing/link.go`: @@ -2207,7 +2207,7 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m } ``` -- [ ] **Step 2: Write the customer handlers** +- [x] **Step 2: Write the customer handlers** Create `admin/internal/api/customer.go`: @@ -2386,7 +2386,7 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) { } ``` -- [ ] **Step 3: Write the route table** +- [x] **Step 3: Write the route table** Create `admin/internal/api/routes.go`: @@ -2473,7 +2473,7 @@ func cors(allowed []string) gin.HandlerFunc { } ``` -- [ ] **Step 4: Mount it** +- [x] **Step 4: Mount it** In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with: @@ -2483,7 +2483,7 @@ In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with: Import `"github.com/mrhid6/vantage/admin/internal/api"`. -- [ ] **Step 5: Build** +- [x] **Step 5: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -2491,7 +2491,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: failures naming the staff handlers, which arrive in Task 9. Build again after Task 9. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add admin/ @@ -2509,7 +2509,7 @@ git commit -m "feat(admin): linking, relink and the scoped customer API" - Consumes: `licensing.Issue`, `licensing.Relink`, `models` - Produces: the handlers named in `routes.go` -- [ ] **Step 1: Write the handlers** +- [x] **Step 1: Write the handlers** Create `admin/internal/api/staff.go`: @@ -2857,7 +2857,7 @@ func staffInjectionHealth(c *gin.Context) { } ``` -- [ ] **Step 2: Build** +- [x] **Step 2: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -2865,7 +2865,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. This is the first build where the whole service compiles. -- [ ] **Step 3: Audit customer scoping by hand** +- [x] **Step 3: Audit customer scoping by hand** With no test suite, this replaces spec 3's table-driven test 20. @@ -2876,7 +2876,7 @@ grep -n "func \(getAccount\|linkInstance\|relinkInstance\|getInstanceLicense\|do Every customer route must either call `ownedInstance` or filter by `s.AccountID`. A route that does neither is a data leak — fix before continuing. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add admin/ @@ -2890,7 +2890,7 @@ git commit -m "feat(admin): staff API" **Files:** - Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml` -- [ ] **Step 1: Add the service** +- [x] **Step 1: Add the service** In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`: @@ -2919,11 +2919,11 @@ In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`: `LICENSE_SIGNING_KEY` appears in exactly one service in exactly one compose file. It must never be added to `server`, and `deploy/docker-compose.yml` — the self-hosted deployment — must not mention admin at all. -- [ ] **Step 2: Add the build** +- [x] **Step 2: Add the build** In `.gitea/workflows/server-deploy.yml`, add a fourth image alongside `server`, `web`, `site` and `sitesvc`, with context `.` and file `admin/Dockerfile`, following the pattern the other Go services already use. -- [ ] **Step 3: Confirm the self-hosted deployment is untouched** +- [x] **Step 3: Confirm the self-hosted deployment is untouched** ```bash grep -c "admin" deploy/docker-compose.yml @@ -2931,7 +2931,7 @@ grep -c "admin" deploy/docker-compose.yml Expected: `0`. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add deploy/ .gitea/ @@ -2946,7 +2946,7 @@ Everything in containers. With no test suite this is the only evidence. **Files:** none -- [ ] **Step 1: Build every module and the image** +- [x] **Step 1: Build every module and the image** ```bash cd c:/Work/Repos/vantage @@ -2960,7 +2960,7 @@ docker build -q -f server/Dockerfile -t vantage-server:test . Expected: all succeed. The image build is the one that catches a `go.sum` the workspace was masking — it caught exactly that in plan 2. -- [ ] **Step 2: Start the supporting containers** +- [x] **Step 2: Start the supporting containers** ```bash docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine @@ -2968,7 +2968,7 @@ docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine MongoDB is expected on the host at `27021`, as in plan 2. -- [ ] **Step 3: Boot the control plane and bootstrap a cloud instance** +- [x] **Step 3: Boot the control plane and bootstrap a cloud instance** ```bash docker run -d --name vadmin-server -p 8080:8080 \ @@ -2984,7 +2984,7 @@ curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json Record the `instance_id`. -- [ ] **Step 4: Boot admin** +- [x] **Step 4: Boot admin** ```bash docker run -d --name vadmin -p 8083:8083 \ @@ -3008,7 +3008,7 @@ 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: Adopt the cloud instance and issue through the staff API** +- [x] **Step 5: Adopt the cloud instance and issue through the staff API** This is the flow the admin UI will drive when you licence your existing cloud instances by hand. @@ -3043,7 +3043,7 @@ Expected: adopting the instance returns 201, issuing returns 201, and the contro 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** +- [x] **Step 6: Confirm the Free rule and the deployment check** Reusing the staff session and `$INSTANCE` from step 5: @@ -3054,7 +3054,7 @@ curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt Expected: `"that plan is not available for this deployment type"` — a self-hosted plan cannot be issued to a cloud instance, and the mirror of that check is what makes Free cloud-only. -- [ ] **Step 7: Confirm supersession and the snapshot rule** +- [x] **Step 7: Confirm supersession and the snapshot rule** ```bash curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \ @@ -3075,7 +3075,7 @@ curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt Expected: the existing licences still report `max_servers: -1`. Editing a plan never rewrites history. -- [ ] **Step 8: Confirm reconciliation repairs a tampered instance** +- [x] **Step 8: Confirm reconciliation repairs a tampered instance** ```bash docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \ @@ -3093,7 +3093,7 @@ curl -s localhost:8080/api/license -b /tmp/a.txt Expected: the control plane reports `valid` again once reconciliation has run — the blob is restored from what admin believes it should be. Confirm `reconcile: repairing instance` appears in `docker logs vadmin`. -- [ ] **Step 9: Confirm customer scoping returns 404, not 403** +- [x] **Step 9: Confirm customer scoping returns 404, not 403** Create a second account and instance via the staff API, sign in as the first customer, and request the second account's instance: @@ -3104,7 +3104,7 @@ curl -s -o /dev/null -w "other account's instance: %{http_code}\n" \ Expected: **404**. A 403 would confirm the instance exists. -- [ ] **Step 10: Confirm admin is not a runtime dependency** +- [x] **Step 10: Confirm admin is not a runtime dependency** ```bash docker stop vadmin @@ -3114,7 +3114,7 @@ curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/a Expected: the licence still reports `valid` and mutations still work. **This is the most important check in the plan.** Instances verify offline and must never call admin. -- [ ] **Step 11: Confirm the control-plane write surface** +- [x] **Step 11: Confirm the control-plane write surface** ```bash docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \ @@ -3124,7 +3124,7 @@ docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \ Expected: `servers`, `keys`, `secrets` and every other collection are exactly as the control plane left them. Admin touched only `instances`. -- [ ] **Step 12: Clean up and commit** +- [x] **Step 12: Clean up and commit** ```bash docker rm -f vadmin vadmin-server vadmin-redis diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md index 134bf93..3696f1f 100644 --- a/docs/superpowers/specs/README.md +++ b/docs/superpowers/specs/README.md @@ -8,9 +8,9 @@ Seven specs, designed 2026-07-24. Build in this order. | 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live | | 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | **shipped** | | 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills | -| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | planned | -| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 | -| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 | +| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases | +| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start | +| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start | Specs 1 and 2 together give working licensing with licences cut by hand with `lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands. @@ -66,5 +66,9 @@ Free is cloud-only by construction: it is only ever signed with `deployment: "cloud"`, and verification rejects a deployment mismatch. There is no server-side flag to edit. One Free instance per account. -**Existing cloud tenants** are grandfathered to Professional, one year out, by -migration `0005`. +**Existing cloud tenants are not grandfathered.** The migration that would have +done it was removed before plan 2 shipped, so every existing cloud instance is +read-only until it is licensed by hand through the admin service: attach it to an +account with `POST /api/staff/instances`, then `POST /api/staff/instances/:id/issue`. +That flow is verified in plan 3, so it works today via the API and is the first +job the admin UI is used for.