docs: mark the admin backend shipped and verified

Ticks all 66 plan steps and records spec 3 as shipped.

Also corrects a stale line that still claimed existing cloud tenants are
grandfathered by migration 0005. That migration was reverted before plan 2
shipped; those instances are read-only until licensed by hand, and the same
file already said so one table above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 19:21:45 +01:00
co-authored by Claude Opus 5
parent b7221d8111
commit cfdc00552e
2 changed files with 75 additions and 71 deletions
@@ -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