diff --git a/docs/superpowers/plans/2026-07-25-admin-site.md b/docs/superpowers/plans/2026-07-25-admin-site.md
new file mode 100644
index 0000000..d2401ed
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-25-admin-site.md
@@ -0,0 +1,4093 @@
+# Admin Site Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build `adminsite/`, a fifth Next.js app serving staff and customers from one codebase, so a customer can buy, link, download and renew a licence without contacting anyone, and staff can answer "why did this stop working" on one screen.
+
+**Architecture:** Route groups do the guarding — `(customer)` and `(staff)/staff` share auth plumbing and a component library but no screens. The browser talks to admin directly (build-time `NEXT_PUBLIC_ADMIN_API_URL`, CORS, `credentials: "include"`), the way `site/` talks to sitesvc, not through a Next rewrite proxy the way `web/` talks to the control plane. Tasks 1 and 2 add the admin endpoints spec 4 needs and spec 3 did not build; every frontend task after that consumes a real endpoint.
+
+**Tech Stack:** Next.js 16.2.9 App Router, React 18.3.1, Tailwind 3.4, TanStack Query 5, TypeScript 5.5, Vitest + React Testing Library (new to this repo), Go 1.26 for tasks 1–2.
+
+## Global Constraints
+
+- **Design tokens are fixed by the approved direction.** Light ground primary with full dark support, petrol accent `#0d5f6e` / `#5cc6d8`, semantic `--valid #2f8a60` / `--warn #a4761b` / `--expired #c6462f` carried over from `site/`. Semantic hues are **never** reused as an accent, and the accent is never green, amber, red or indigo — indigo belongs to `web/`.
+- **Type roles:** serif display (`"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`) for page titles, counts and the wordmark only; system sans for UI; mono with `tabular-nums` for every UUID, timestamp, blob and limit.
+- **State never reads by colour alone.** Every licence state renders as a stripe, a shaped-and-labelled pill, and copy.
+- **Served at `vantage-hq.hostxtra.co.uk`, published on `3004`.** Spec 4 said 3002, but `deploy/docker-compose.site.yml` now maps `site` to `3002:3000`. Container port stays `3000`. Note the host is deliberately *not* under `*.vantage.hostxtra.co.uk`: that namespace is per-tenant instance subdomains, and the control plane's `APP_ROOT_LABEL` guard resolves an org from the label before `vantage`. A host like `hq.vantage.hostxtra.co.uk` would look like a tenant slug called `hq`.
+- **`ADMIN_ORIGIN` must contain `https://vantage-hq.hostxtra.co.uk`** exactly — scheme included, no trailing slash. Admin echoes only origins on that list, so a mismatch blocks every browser request while curl from the server keeps working, which is what makes it confusing to diagnose.
+- **`NEXT_PUBLIC_ADMIN_API_URL` is baked in at build time** and must be browser-reachable *and* present in admin's `ADMIN_ORIGIN`. When unset or unreachable the app renders an explicit not-connected state naming the variable. This is the single most common deployment failure in this repo.
+- **Cookies work cross-origin only because both hosts share a registrable domain.** `admin_session` is `SameSite=Lax`, which browsers send on same-*site* subresource requests — and same-site is judged on the registrable domain, not the origin. `vantage-hq.hostxtra.co.uk` and admin's own host are both under `hostxtra.co.uk`, so a `fetch` with `credentials: "include"` carries the cookie. **Moving either host to a different registrable domain breaks every authenticated request** and would need `SameSite=None; Secure`, which is out of scope here.
+- **Route-group guards are UX, not security.** The real protection is admin's `RequireStaff`/`RequireCustomer` plus 404-not-403 scoping. Never rely on the client guard alone.
+- **Four-space indent, no semicolon-free style** — match `web/` and `site/`.
+- Admin **must never** gain a write path into the control plane beyond the three licence fields on `instances`. Tasks 1–2 add reads and admin-database writes only.
+- Every mutating admin endpoint added in tasks 1–2 writes an audit entry.
+- Run `go mod tidy` with `GOWORK=off`; `MSYS_NO_PATHCONV=1` on every `docker` call. Node commands run in a container:
+ ```sh
+ # /tmp/noderun.sh
+ DIR="$1"; shift
+ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \
+ -w "/src/$DIR" node:26-alpine "$@"
+ ```
+
+## Context this plan inherits
+
+Spec 3 shipped and is verified. Its API is the contract here. **Spec 4 asks for eight things spec 3 does not expose**, found by auditing every screen in the spec against `admin/internal/api/routes.go`:
+
+| Spec 4 needs | Status after spec 3 | Added by |
+|---|---|---|
+| Route guards knowing who is signed in | no `GET /auth/me` at all | Task 1 |
+| `app/signup/` self-hosted account creation | no signup endpoint at all | Task 1 |
+| "2 of 3 relinks remaining" | `relink_count` exposed, cap is a Go constant | Task 1 |
+| Dashboard "subscriptions past_due" | no staff subscriptions endpoint | Task 2 |
+| Account search by instance UUID and Paddle ID | `q` matches name and billing_email only | Task 2 |
+| Account detail: subscriptions, customer users, audit | returns account + instances only | Task 2 |
+| Staff instance detail with licence history | no `GET /api/staff/instances/:id` | Task 2 |
+| Audit filtered to one account | global list only | Task 2 |
+
+Paddle portal deep-links are spec 5's. Until then the billing screen renders the subscription list and says billing changes go through support, rather than linking nowhere.
+
+---
+
+## File Structure
+
+**Created — Go (tasks 1–2):** nothing new; all edits land in existing files.
+
+**Created — app:**
+
+| Path | Responsibility |
+|---|---|
+| `adminsite/package.json`, `tsconfig.json`, `next.config.ts`, `postcss.config.js`, `tailwind.config.ts` | app scaffold, tokens wired to CSS variables |
+| `adminsite/Dockerfile`, `.dockerignore` | image, built from `adminsite/` like `web/` |
+| `adminsite/vitest.config.ts`, `test/setup.ts` | the repo's first frontend test harness |
+| `adminsite/app/globals.css` | the token system, light and dark |
+| `adminsite/app/layout.tsx` | root shell, providers, environment badge |
+| `adminsite/app/login/page.tsx`, `signup/page.tsx`, `verify/page.tsx` | unauthed routes |
+| `adminsite/app/(customer)/layout.tsx`, `page.tsx` | customer guard + nav, account overview |
+| `adminsite/app/(customer)/instances/[id]/page.tsx` | licence, download, paste steps, relink |
+| `adminsite/app/(customer)/instances/link/page.tsx` | self-hosted activation |
+| `adminsite/app/(customer)/billing/page.tsx` | subscription list |
+| `adminsite/app/(staff)/staff/layout.tsx`, `page.tsx` | staff guard + nav, operations queue |
+| `adminsite/app/(staff)/staff/accounts/page.tsx`, `accounts/[id]/page.tsx` | search, account detail |
+| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the licence ledger |
+| `adminsite/app/(staff)/staff/licenses/page.tsx`, `audit/page.tsx` | global history |
+| `adminsite/app/(staff)/staff/plans/page.tsx` | plan editing with guard rails |
+| `adminsite/lib/api.ts` | typed client, `NotConnected`, `ApiError` |
+| `adminsite/lib/session.ts` | `useSession`, `useRequireKind` |
+| `adminsite/lib/query-client.ts` | TanStack config, mirrors `web/` |
+| `adminsite/lib/format.ts` | dates, days-remaining, licence state derivation |
+| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Guilloche`, `Field`, `Button`, `ConfirmPlanChange` |
+
+**Modified:** `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/auth/customer.go`, `admin/internal/models/models.go`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`.
+
+---
+
+### Task 1: Session, signup and the relink cap (admin backend)
+
+Without `GET /auth/me` no route guard can know who is signed in, and without signup the spec's `app/signup/` has nothing to post to. Both are backend gaps, so they land before any frontend work.
+
+**Files:**
+- Modify: `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/auth/customer.go`
+
+**Interfaces:**
+- Consumes: `auth.Current`, `auth.CreateCustomerUser`, `models.MaxRelinksPerTerm`
+- Produces:
+ - `GET /auth/me` → `200 {"kind","email","account_id"}` or `401`
+ - `POST /auth/signup` → `201 {"pending":true}`
+ - `GET /api/account` gains `"max_relinks": 3`
+
+- [ ] **Step 1: Add the session probe**
+
+Append to `admin/internal/api/customer.go`:
+
+```go
+// getMe reports who the caller is, for route guards in the UI.
+//
+// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a
+// truthful 401 to redirect on, not an error page. It reveals nothing a caller
+// does not already possess, because it only ever describes their own cookie.
+func getMe(c *gin.Context) {
+ s := auth.Load(c)
+ if s == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "kind": s.Kind,
+ "email": s.Email,
+ "account_id": s.AccountID,
+ })
+}
+```
+
+- [ ] **Step 2: Export the session loader**
+
+`load` in `admin/internal/auth/middleware.go` is unexported. Rename it to `Load` and update its two call sites in the same file:
+
+```go
+// Load returns the caller's session, or nil. Exported because the session probe
+// in api/ needs to read a session without requiring one.
+func Load(c *gin.Context) *Session {
+```
+
+Both `RequireStaff` and `RequireCustomer` call `s := Load(c)`.
+
+- [ ] **Step 3: Add signup**
+
+Append to `admin/internal/auth/customer.go`:
+
+```go
+// HandleSignup creates a self-hosted customer: an account, an unverified user,
+// and a verification email.
+//
+// Nothing is usable until the emailed link is opened, the same rule sitesvc
+// already proves — so an address nobody controls cannot occupy an email or
+// produce an account that can sign in.
+func HandleSignup(c *gin.Context) {
+ var body struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+ Website string `json:"website"` // honeypot; real users never fill it
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"})
+ return
+ }
+
+ // Honeypot: answer exactly as success so a bot learns nothing.
+ if strings.TrimSpace(body.Website) != "" {
+ c.JSON(http.StatusCreated, gin.H{"pending": true})
+ return
+ }
+
+ email := strings.ToLower(strings.TrimSpace(body.Email))
+ ctx := c.Request.Context()
+
+ if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"})
+ return
+ }
+ if !allowAttempt("signup:"+email, c.ClientIP()) {
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
+ return
+ }
+
+ if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
+ // Same response as success. Telling a stranger the address is taken
+ // confirms who has an account here.
+ c.JSON(http.StatusCreated, gin.H{"pending": true})
+ return
+ }
+
+ acct := models.Account{
+ AccountID: uuid.NewString(),
+ Name: strings.TrimSpace(body.Name),
+ BillingEmail: email,
+ Status: models.AccountActive,
+ CreatedAt: time.Now().UTC(),
+ }
+ if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"})
+ return
+ }
+
+ if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
+ // Roll the account back rather than strand one with no owner.
+ _, _ = db.Admin("accounts").DeleteOne(ctx, bson.M{"account_id": acct.AccountID})
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"})
+ return
+ }
+
+ audit.Write(ctx, models.AuditEntry{
+ Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()})
+ c.JSON(http.StatusCreated, gin.H{"pending": true})
+}
+```
+
+Add `"time"` and `"github.com/google/uuid"` to that file's imports.
+
+- [ ] **Step 4: Expose the relink cap**
+
+In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`:
+
+```go
+ c.JSON(http.StatusOK, gin.H{
+ "account": acct,
+ "instances": instances,
+ // Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a
+ // second source of truth for a rule the backend enforces.
+ "max_relinks": models.MaxRelinksPerTerm,
+ })
+```
+
+- [ ] **Step 5: Route them**
+
+In `admin/internal/api/routes.go`, below the existing auth routes:
+
+```go
+ r.GET("/auth/me", getMe)
+ r.POST("/auth/signup", auth.HandleSignup)
+```
+
+- [ ] **Step 6: Build**
+
+```bash
+sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
+```
+
+Expected: no output.
+
+- [ ] **Step 7: Confirm the probe and signup by hand**
+
+Start admin as in the spec-3 plan Task 11 steps 2–4, then:
+
+```bash
+curl -s -o /dev/null -w "unauthed me: %{http_code}\n" localhost:8083/auth/me
+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
+curl -s localhost:8083/auth/me -b /tmp/s.txt
+curl -s -o /dev/null -w "honeypot: %{http_code}\n" -X POST localhost:8083/auth/signup \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"Bot","email":"bot@example.com","password":"aaaaaaaaaaaa","website":"x"}'
+```
+
+Expected: `401`; then `{"kind":"staff","email":"staff@example.com","account_id":""}`; then `201` with **no** account created (confirm with `db.accounts.countDocuments({billing_email:"bot@example.com"})` returning `0`).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add admin/
+git commit -m "feat(admin): session probe, self-hosted signup and the relink cap"
+```
+
+---
+
+### Task 2: Staff reads spec 4 needs (admin backend)
+
+**Files:**
+- Modify: `admin/internal/api/staff.go`, `admin/internal/api/routes.go`
+
+**Interfaces:**
+- Produces:
+ - `GET /api/staff/subscriptions?status=past_due` → `[]Subscription`
+ - `GET /api/staff/instances/:id` → `{instance, account, licenses, injection}`
+ - `GET /api/staff/audit?account_id=` filtered
+ - `GET /api/staff/accounts?q=` also matching `paddle_customer_id` and an instance UUID
+ - `GET /api/staff/accounts/:id` also returning `subscriptions`, `users`, `audit`
+
+- [ ] **Step 1: Search accounts by Paddle ID and instance UUID**
+
+In `staffListAccounts`, replace the `if q := c.Query("q"); q != ""` block:
+
+```go
+ if q := c.Query("q"); q != "" {
+ or := []bson.M{
+ {"name": bson.M{"$regex": q, "$options": "i"}},
+ {"billing_email": bson.M{"$regex": q, "$options": "i"}},
+ {"paddle_customer_id": q},
+ }
+ // A support email often contains an instance UUID and nothing else, so
+ // resolve that to its owning account rather than returning nothing.
+ var inst models.Instance
+ if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
+ bson.M{"instance_id": q}).Decode(&inst); err == nil {
+ or = append(or, bson.M{"account_id": inst.AccountID})
+ }
+ filter["$or"] = or
+ }
+```
+
+- [ ] **Step 2: Fill out account detail**
+
+Replace the body of `staffGetAccount` after the account lookup:
+
+```go
+ instances := []models.Instance{}
+ if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
+ _ = cur.All(ctx, &instances)
+ }
+ subs := []models.Subscription{}
+ if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
+ _ = cur.All(ctx, &subs)
+ }
+ users := []models.CustomerUser{}
+ if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
+ _ = cur.All(ctx, &users)
+ }
+ entries := []models.AuditEntry{}
+ if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID},
+ options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil {
+ _ = cur.All(ctx, &entries)
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "account": acct,
+ "instances": instances,
+ "subscriptions": subs,
+ "users": users,
+ "audit": entries,
+ })
+```
+
+`CustomerUser.PasswordHash` and both verify-token fields are `json:"-"`, so no secret leaves here.
+
+- [ ] **Step 3: Add staff instance detail**
+
+Append to `admin/internal/api/staff.go`:
+
+```go
+// staffGetInstance is the "why did this stop working" screen's data: one
+// instance, its account, its whole licence history newest first, and whether
+// the control plane currently holds what we think it holds.
+func staffGetInstance(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ var inst models.Instance
+ if err := db.Admin("admin_instances").FindOne(ctx,
+ bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+
+ var acct models.Account
+ _ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct)
+
+ lics := []models.License{}
+ if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID},
+ options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil {
+ _ = cur.All(ctx, &lics)
+ }
+
+ // Injection state is only meaningful for cloud. For self-hosted the
+ // customer holds the blob and there is nothing for us to have written.
+ injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud}
+ if inst.Deployment == license.DeploymentCloud {
+ var remote sharedmodels.Instance
+ err := db.Control("instances").FindOne(ctx,
+ bson.M{"instance_id": inst.InstanceID}).Decode(&remote)
+ switch {
+ case err != nil:
+ injection["state"] = "missing"
+ case inst.CurrentLicense == "":
+ injection["state"] = "none_issued"
+ default:
+ var current models.License
+ if db.Admin("licenses").FindOne(ctx,
+ bson.M{"license_id": inst.CurrentLicense}).Decode(¤t) == nil &&
+ remote.LicenseBlob == current.Blob {
+ injection["state"] = "current"
+ } else {
+ injection["state"] = "stale"
+ }
+ }
+ injection["failed_at"] = inst.InjectFailedAt
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "instance": inst, "account": acct, "licenses": lics, "injection": injection,
+ })
+}
+
+// staffListSubscriptions backs the past-due queue on the dashboard.
+func staffListSubscriptions(c *gin.Context) {
+ filter := bson.M{}
+ if v := c.Query("status"); v != "" {
+ filter["status"] = v
+ }
+ if v := c.Query("account_id"); v != "" {
+ filter["account_id"] = v
+ }
+ cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter,
+ options.Find().SetLimit(500))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ subs := []models.Subscription{}
+ if err := cur.All(c.Request.Context(), &subs); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, subs)
+}
+```
+
+- [ ] **Step 4: Filter audit by account**
+
+In `staffAudit`, replace `bson.M{}` with a filter:
+
+```go
+ filter := bson.M{}
+ if v := c.Query("account_id"); v != "" {
+ filter["account_id"] = v
+ }
+ cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter,
+ options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
+```
+
+- [ ] **Step 5: Route them**
+
+In the `staff` group in `admin/internal/api/routes.go`:
+
+```go
+ staff.GET("/instances/:id", staffGetInstance)
+ staff.GET("/subscriptions", staffListSubscriptions)
+```
+
+- [ ] **Step 6: Build and confirm the write surface is unchanged**
+
+```bash
+sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
+grep -rn 'Control("' admin/internal/ --include=*.go | grep -v "db.go"
+```
+
+Expected: build clean; exactly one `UpdateOne` on `instances` in `inject.go`, and reads only elsewhere. `staffGetInstance` adds a read, never a write.
+
+- [ ] **Step 7: Confirm search by UUID**
+
+With the spec-3 verification stack running and an instance adopted:
+
+```bash
+curl -s "localhost:8083/api/staff/accounts?q=6a0fe3f0-49d2-4aa1-967c-a3094b200b5d" -b /tmp/s.txt
+curl -s localhost:8083/api/staff/instances/6a0fe3f0-49d2-4aa1-967c-a3094b200b5d -b /tmp/s.txt | head -c 200
+```
+
+Expected: the owning account, and an instance payload whose `injection.state` is `current`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add admin/
+git commit -m "feat(admin): staff instance detail, subscriptions and richer search"
+```
+
+---
+
+### Task 3: App scaffold and tokens
+
+**Files:**
+- Create: `adminsite/package.json`, `adminsite/tsconfig.json`, `adminsite/next.config.ts`, `adminsite/postcss.config.js`, `adminsite/tailwind.config.ts`, `adminsite/app/globals.css`, `adminsite/app/layout.tsx`, `adminsite/.gitignore`
+
+**Interfaces:**
+- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-display font-sans font-mono`
+
+- [ ] **Step 1: package.json**
+
+```json
+{
+ "name": "vantage-adminsite",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "next": "16.2.9",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "@tanstack/react-query": "^5.51.1",
+ "clsx": "^2.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.11",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@testing-library/react": "^16.0.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@testing-library/jest-dom": "^6.4.8",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^9.0.0",
+ "eslint-config-next": "16.2.9",
+ "jsdom": "^24.1.1",
+ "postcss": "^8.4.39",
+ "tailwindcss": "^3.4.6",
+ "typescript": "^5.5.3",
+ "vitest": "^2.0.5"
+ }
+}
+```
+
+- [ ] **Step 2: tsconfig.json**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "types": ["vitest/globals", "@testing-library/jest-dom"],
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
+```
+
+- [ ] **Step 3: next.config.ts**
+
+```ts
+import type { NextConfig } from "next";
+
+/*
+ * Unlike web/, this app does NOT proxy /api through a rewrite. The browser
+ * calls admin directly, so NEXT_PUBLIC_ADMIN_API_URL must be reachable from the
+ * browser and must appear in admin's ADMIN_ORIGIN. lib/api.ts renders an
+ * explicit not-connected state when it is not.
+ */
+const nextConfig: NextConfig = {
+ output: "standalone",
+};
+
+export default nextConfig;
+```
+
+- [ ] **Step 4: postcss.config.js and tailwind.config.ts**
+
+`postcss.config.js`:
+
+```js
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+```
+
+`tailwind.config.ts` — colours point at CSS variables so one token set serves both themes:
+
+```ts
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ ground: "var(--ground)",
+ panel: "var(--panel)",
+ "panel-2": "var(--panel-2)",
+ ink: "var(--ink)",
+ "ink-2": "var(--ink-2)",
+ "ink-3": "var(--ink-3)",
+ rule: "var(--rule)",
+ "rule-soft": "var(--rule-soft)",
+ accent: "var(--accent)",
+ "accent-ink": "var(--accent-ink)",
+ "accent-wash": "var(--accent-wash)",
+ valid: "var(--valid)",
+ warn: "var(--warn)",
+ expired: "var(--expired)",
+ archival: "var(--archival)",
+ },
+ fontFamily: {
+ display: ["Iowan Old Style", "Palatino Linotype", "Palatino", "Book Antiqua", "Georgia", "serif"],
+ sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
+ mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
+ },
+ borderRadius: { DEFAULT: "3px" },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
+```
+
+- [ ] **Step 5: app/globals.css**
+
+```css
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/*
+ * Admin console tokens. Petrol accent, deliberately clear of indigo (web/) and
+ * of the three semantic hues this app says constantly. Light is home because
+ * web/ is locked to dark, and telling the two apps apart at a glance is what
+ * stops a Reissue landing in the wrong tab.
+ */
+:root {
+ color-scheme: light dark;
+
+ --ground: #eaeff0;
+ --panel: #ffffff;
+ --panel-2: #f2f6f7;
+ --ink: #0f2429;
+ --ink-2: #43606a;
+ --ink-3: #6d868e;
+ --rule: #ccd9dc;
+ --rule-soft: #dfe8ea;
+ --accent: #0d5f6e;
+ --accent-ink: #ffffff;
+ --accent-wash: rgba(13, 95, 110, 0.07);
+
+ /* Semantic, shared with site/ so all three apps agree. Never an accent. */
+ --valid: #2f8a60;
+ --warn: #a4761b;
+ --expired: #c6462f;
+ --archival: #7d4a45;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --ground: #081a1f;
+ --panel: #0e262d;
+ --panel-2: #123039;
+ --ink: #e2eff1;
+ --ink-2: #9fbcc3;
+ --ink-3: #74949c;
+ --rule: #1e454f;
+ --rule-soft: #17363f;
+ --accent: #5cc6d8;
+ --accent-ink: #04171c;
+ --accent-wash: rgba(92, 198, 216, 0.1);
+ --valid: #4fb484;
+ --warn: #d6a63f;
+ --expired: #e2705a;
+ --archival: #c08a84;
+ }
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background-color: var(--ground);
+ color: var(--ink);
+ -webkit-font-smoothing: antialiased;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ transition-duration: 0.001ms !important;
+ }
+}
+```
+
+- [ ] **Step 6: app/layout.tsx**
+
+```tsx
+import type { Metadata } from "next";
+import "./globals.css";
+import { Providers } from "@/components/Providers";
+import { EnvBadge } from "@/components/EnvBadge";
+
+export const metadata: Metadata = {
+ title: "Vantage Licensing",
+ description: "Licences, instances and billing for Vantage.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ Vantage
+
+ Licensing
+
+
+
+
+
+ {children}
+
+
+ );
+}
+```
+
+- [ ] **Step 7: .gitignore**
+
+```
+node_modules
+.next
+next-env.d.ts
+.env
+*.lic
+```
+
+- [ ] **Step 8: Install and confirm it builds later**
+
+```bash
+sh /tmp/noderun.sh adminsite npm install
+```
+
+Expected: a lockfile appears. `npm run build` cannot pass until Task 5 creates the pages it imports; that is Task 5's verification.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add adminsite/
+git commit -m "feat(adminsite): scaffold and the approved token system"
+```
+
+---
+
+### Task 4: Test harness, API client and the not-connected state
+
+The repo has no frontend test setup. This task creates it and proves it on spec 4's test 6.
+
+**Files:**
+- Create: `adminsite/vitest.config.ts`, `adminsite/test/setup.ts`, `adminsite/lib/api.ts`, `adminsite/lib/query-client.ts`, `adminsite/lib/format.ts`, `adminsite/components/Providers.tsx`, `adminsite/components/NotConnected.tsx`, `adminsite/components/NotConnected.test.tsx`
+
+**Interfaces:**
+- Produces:
+ - `NotConnected` (error class), `ApiError` with `.status`
+ - `api.me()`, `api.login()`, `api.staffLogin()`, `api.logout()`, `api.signup()`, `api.verify()`
+ - `api.account()`, `api.link()`, `api.relink()`, `api.license()`, `api.licenseBlobUrl()`, `api.subscriptions()`
+ - `api.staff.*` — `accounts`, `account`, `instances`, `instance`, `issue`, `relink`, `licenses`, `plans`, `updatePlan`, `audit`, `injectionHealth`, `subscriptions`
+ - ``
+ - `licenceState(expiresAt: string | undefined, hasLicence: boolean): "valid" | "warn" | "expired" | "none"`, `daysRemaining(iso: string): number`, `formatDate(iso: string): string`
+
+- [ ] **Step 1: vitest.config.ts and test/setup.ts**
+
+```ts
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import { resolve } from "node:path";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: { alias: { "@": resolve(__dirname, ".") } },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./test/setup.ts"],
+ include: ["**/*.test.{ts,tsx}"],
+ },
+});
+```
+
+`test/setup.ts`:
+
+```ts
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+```
+
+- [ ] **Step 2: Write the failing test for the not-connected state**
+
+`adminsite/components/NotConnected.test.tsx`:
+
+```tsx
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { NotConnectedPanel } from "./NotConnected";
+
+describe("NotConnectedPanel", () => {
+ it("names the variable that is wrong and where it is set", () => {
+ render();
+
+ expect(screen.getByRole("heading")).toHaveTextContent(
+ /not connected to the licensing service/i,
+ );
+ expect(screen.getByText(/ADMIN_API_URL/)).toBeInTheDocument();
+ expect(screen.getByText(/https:\/\/admin\.example\.com/)).toBeInTheDocument();
+ // The two mistakes that actually cause this, both named.
+ expect(screen.getByText(/reachable from your browser/i)).toBeInTheDocument();
+ expect(screen.getByText(/ADMIN_ORIGIN/)).toBeInTheDocument();
+ });
+
+ it("says the value is missing when no URL was baked in", () => {
+ render();
+ expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 3: Run it and watch it fail**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run components/NotConnected.test.tsx
+```
+
+Expected: FAIL — `Failed to resolve import "./NotConnected"`.
+
+- [ ] **Step 4: Write the component**
+
+`adminsite/components/NotConnected.tsx`:
+
+```tsx
+/*
+ * The deployment failure this repo makes most often, made legible. It names the
+ * variable, the value baked in, and both reasons it fails — unreachable from
+ * the browser, or missing from admin's ADMIN_ORIGIN.
+ */
+export function NotConnectedPanel({ url }: { url: string }) {
+ return (
+
+
+ Not connected to the licensing service
+
+ {url ? (
+
+ This build points at ADMIN_API_URL ={" "}
+ {url}, which did not respond.
+
+ ) : (
+
+ ADMIN_API_URL was not set when this
+ app was built, so there is nowhere to send requests.
+
+ )}
+
+ The value is baked in when the image is built and has to be reachable from your
+ browser, not just from the server. It also has to appear in the licensing
+ service’s ADMIN_ORIGIN, or the browser
+ blocks every request.
+
+
+ );
+}
+```
+
+- [ ] **Step 5: Run it and watch it pass**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run components/NotConnected.test.tsx
+```
+
+Expected: `2 passed`.
+
+- [ ] **Step 6: Write the API client**
+
+`adminsite/lib/api.ts`:
+
+```ts
+/*
+ * The typed client for the licensing service.
+ *
+ * The browser calls admin directly, so every request carries credentials and
+ * every failure mode is one of three: the API is unreachable (NotConnected),
+ * the caller is not signed in (ApiError 401, which layouts redirect on), or the
+ * request was refused (ApiError with the backend's own message, which is
+ * customer-facing and should be shown verbatim).
+ */
+
+export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
+
+export class NotConnected extends Error {
+ constructor() {
+ super("not connected");
+ this.name = "NotConnected";
+ }
+}
+
+export class ApiError extends Error {
+ status: number;
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+async function req(path: string, init?: RequestInit): Promise {
+ if (!API_BASE) throw new NotConnected();
+
+ let res: Response;
+ try {
+ res = await fetch(`${API_BASE}${path}`, {
+ ...init,
+ credentials: "include",
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ });
+ } catch {
+ // Network-level failure, DNS, or a CORS preflight the browser refused.
+ throw new NotConnected();
+ }
+
+ if (res.status === 204) return undefined as T;
+
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`);
+ }
+ return body as T;
+}
+
+const post = (path: string, payload?: unknown) =>
+ req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
+
+// --- types ---------------------------------------------------------------
+
+export type Deployment = "cloud" | "self_hosted";
+export type Tier = "free" | "professional" | "self_hosted";
+export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled";
+
+export interface Session {
+ kind: "staff" | "customer";
+ email: string;
+ account_id?: string;
+}
+
+export interface Limits {
+ max_servers: number;
+ max_secret_groups: number;
+ max_channels: number;
+}
+
+export interface Account {
+ account_id: string;
+ name: string;
+ billing_email: string;
+ paddle_customer_id?: string;
+ status: "active" | "suspended";
+ created_at: string;
+}
+
+export interface Instance {
+ instance_id: string;
+ account_id: string;
+ name: string;
+ slug?: string;
+ deployment: Deployment;
+ tier?: Tier;
+ status: InstanceStatus;
+ current_license?: string;
+ relink_count: number;
+ inject_failed_at?: string | null;
+ created_at: string;
+}
+
+export interface License {
+ license_id: string;
+ instance_id: string;
+ account_id: string;
+ tier: Tier;
+ deployment: Deployment;
+ limits: Limits;
+ features: string[];
+ issued_at: string;
+ expires_at: string;
+ superseded_by?: string;
+ issued_by: string;
+ reason: "new" | "renewal" | "tier_change" | "relink" | "manual";
+}
+
+export interface Subscription {
+ subscription_id: string;
+ account_id: string;
+ instance_id?: string;
+ tier: Tier;
+ term: string;
+ status: string;
+ current_period_end: string;
+}
+
+export interface Plan {
+ tier: Tier;
+ name: string;
+ deployment: Deployment;
+ limits: Limits;
+ features: string[];
+ paddle_product_id?: string;
+ paddle_price_ids?: Record;
+ active: boolean;
+}
+
+export interface CustomerUser {
+ user_id: string;
+ account_id: string;
+ email: string;
+ verified_at?: string | null;
+ created_at: string;
+}
+
+export interface AuditEntry {
+ actor: string;
+ action: string;
+ account_id?: string;
+ target?: string;
+ detail?: string;
+ ip?: string;
+ created_at: string;
+}
+
+export interface AccountResponse {
+ account: Account;
+ instances: Instance[];
+ max_relinks: number;
+}
+
+export interface StaffAccountResponse {
+ account: Account;
+ instances: Instance[];
+ subscriptions: Subscription[];
+ users: CustomerUser[];
+ audit: AuditEntry[];
+}
+
+export type InjectionState = "current" | "stale" | "missing" | "none_issued";
+
+export interface StaffInstanceResponse {
+ instance: Instance;
+ account: Account;
+ licenses: License[];
+ injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
+}
+
+// --- calls ---------------------------------------------------------------
+
+export const api = {
+ me: () => req("/auth/me"),
+ login: (email: string, password: string) => post("/auth/login", { email, password }),
+ staffLogin: (email: string, password: string) =>
+ post("/auth/staff/login", { email, password }),
+ logout: () => post<{ ok: boolean }>("/auth/logout"),
+ signup: (payload: { name: string; email: string; password: string; website?: string }) =>
+ post<{ pending: boolean }>("/auth/signup", payload),
+ verify: (token: string) => req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
+
+ account: () => req("/api/account"),
+ link: (instance_id: string, name: string) =>
+ post("/api/instances/link", { instance_id, name }),
+ relink: (id: string, instance_id: string) =>
+ post(`/api/instances/${id}/relink`, { instance_id }),
+ license: (id: string) => req(`/api/instances/${id}/license`),
+ licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
+ subscriptions: () => req("/api/subscriptions"),
+
+ staff: {
+ accounts: (q?: string) =>
+ req(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
+ account: (id: string) => req(`/api/staff/accounts/${id}`),
+ instances: (params?: Record) =>
+ req(`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`),
+ instance: (id: string) => req(`/api/staff/instances/${id}`),
+ issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
+ post(`/api/staff/instances/${id}/issue`, payload),
+ relink: (id: string, instance_id: string) =>
+ post(`/api/staff/instances/${id}/relink`, { instance_id }),
+ licenses: (params?: Record) =>
+ req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
+ plans: () => req("/api/staff/plans"),
+ updatePlan: (tier: Tier, plan: Omit) =>
+ req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
+ method: "PUT",
+ body: JSON.stringify(plan),
+ }),
+ audit: (accountId?: string) =>
+ req(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
+ injectionHealth: () =>
+ req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
+ subscriptions: (status?: string) =>
+ req(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
+ },
+};
+```
+
+- [ ] **Step 7: Write format helpers and providers**
+
+`adminsite/lib/format.ts`:
+
+```ts
+export type LicenceState = "valid" | "warn" | "expired" | "none";
+
+/** Amber inside 14 days, matching the window staff chase renewals on. */
+export const EXPIRY_WARNING_DAYS = 14;
+
+export function daysRemaining(iso: string): number {
+ const ms = new Date(iso).getTime() - Date.now();
+ return Math.ceil(ms / 86_400_000);
+}
+
+export function licenceState(expiresAt: string | undefined, hasLicence: boolean): LicenceState {
+ if (!hasLicence || !expiresAt) return "none";
+ const days = daysRemaining(expiresAt);
+ if (days <= 0) return "expired";
+ if (days <= EXPIRY_WARNING_DAYS) return "warn";
+ return "valid";
+}
+
+export function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
+
+export function formatStamp(iso: string): string {
+ return `${new Date(iso).toISOString().slice(11, 19)} UTC`;
+}
+
+export function limitLabel(n: number): string {
+ return n === -1 ? "unlimited" : String(n);
+}
+```
+
+`adminsite/lib/query-client.ts`:
+
+```ts
+"use client";
+
+import { QueryClient } from "@tanstack/react-query";
+import { ApiError, NotConnected } from "./api";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ // Retrying a 401 or a missing API URL just delays the redirect and
+ // the not-connected panel.
+ retry: (count, error) =>
+ error instanceof NotConnected || error instanceof ApiError ? false : count < 1,
+ },
+ },
+});
+```
+
+`adminsite/components/Providers.tsx`:
+
+```tsx
+"use client";
+
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "@/lib/query-client";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+```
+
+- [ ] **Step 8: Run the whole suite**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run
+```
+
+Expected: `2 passed`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add adminsite/
+git commit -m "feat(adminsite): test harness, typed client and the not-connected state"
+```
+
+---
+
+### Task 5: Session, route-group guards and auth screens
+
+Covers spec test 1.
+
+**Files:**
+- Create: `adminsite/lib/session.ts`, `adminsite/components/EnvBadge.tsx`, `adminsite/components/Button.tsx`, `adminsite/components/Field.tsx`, `adminsite/app/login/page.tsx`, `adminsite/app/signup/page.tsx`, `adminsite/app/verify/page.tsx`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(staff)/staff/layout.tsx`, `adminsite/app/page.tsx`, `adminsite/lib/session.test.tsx`
+
+**Interfaces:**
+- Consumes: `api`, `NotConnected`, `ApiError`
+- Produces: `useSession()`, ``, ``, ``, ``
+
+- [ ] **Step 1: Write the failing guard test**
+
+`adminsite/lib/session.test.tsx`:
+
+```tsx
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ApiError } from "./api";
+import { RequireKind } from "./session";
+
+const replace = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace, push: vi.fn() }),
+}));
+
+const me = vi.fn();
+vi.mock("./api", async () => {
+ const actual = await vi.importActual("./api");
+ return { ...actual, api: { ...actual.api, me: () => me() } };
+});
+
+function wrap(ui: React.ReactNode) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render({ui});
+}
+
+describe("RequireKind", () => {
+ beforeEach(() => replace.mockClear());
+
+ it("renders staff screens for a staff session", async () => {
+ me.mockResolvedValue({ kind: "staff", email: "s@example.com" });
+ wrap(operations);
+ expect(await screen.findByText("operations")).toBeInTheDocument();
+ expect(replace).not.toHaveBeenCalled();
+ });
+
+ it("sends a customer session away from staff screens without telling it anything", async () => {
+ me.mockResolvedValue({ kind: "customer", email: "c@example.com", account_id: "a1" });
+ wrap(operations);
+ await waitFor(() => expect(replace).toHaveBeenCalledWith("/"));
+ expect(screen.queryByText("operations")).not.toBeInTheDocument();
+ });
+
+ it("sends an unauthenticated visitor to sign in", async () => {
+ me.mockRejectedValue(new ApiError(401, "not signed in"));
+ wrap(account);
+ await waitFor(() => expect(replace).toHaveBeenCalledWith("/login"));
+ });
+});
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run lib/session.test.tsx
+```
+
+Expected: FAIL — cannot resolve `./session`.
+
+- [ ] **Step 3: Write the session module**
+
+`adminsite/lib/session.ts`:
+
+```tsx
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { ApiError, NotConnected, api, API_BASE, type Session } from "./api";
+import { NotConnectedPanel } from "@/components/NotConnected";
+
+export function useSession() {
+ const { data, error, isLoading } = useQuery({
+ queryKey: ["me"],
+ queryFn: api.me,
+ staleTime: 60_000,
+ });
+ return { session: data, error, isLoading };
+}
+
+/*
+ * The route-group guard. This is UX, not security: admin enforces the same
+ * boundary with RequireStaff/RequireCustomer and returns 404 rather than 403
+ * for another account's data. A customer hitting a staff route is redirected
+ * rather than shown a refusal, because there is nothing to tell them about.
+ */
+export function RequireKind({
+ kind,
+ children,
+}: {
+ kind: Session["kind"];
+ children: React.ReactNode;
+}) {
+ const router = useRouter();
+ const { session, error, isLoading } = useSession();
+
+ useEffect(() => {
+ if (error instanceof ApiError && error.status === 401) {
+ router.replace("/login");
+ return;
+ }
+ if (session && session.kind !== kind) {
+ router.replace(session.kind === "staff" ? "/staff" : "/");
+ }
+ }, [error, session, kind, router]);
+
+ if (error instanceof NotConnected) return ;
+ if (isLoading || !session || session.kind !== kind) return null;
+ return <>{children}>;
+}
+```
+
+Note the redirect target for a mismatched session is the *other* home, so a customer on `/staff/*` lands on `/`.
+
+- [ ] **Step 4: Run it and watch it pass**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run lib/session.test.tsx
+```
+
+Expected: `3 passed`. The mismatch test asserts `replace("/")`, which the code produces for a customer.
+
+- [ ] **Step 5: Write the shared primitives**
+
+`adminsite/components/EnvBadge.tsx`:
+
+```tsx
+/*
+ * Sandbox is hatched as well as coloured, so it survives a colourblind reader
+ * and a glance. It sits in the same place on every screen: issuing against the
+ * wrong environment should feel wrong before you click.
+ */
+const ENV = process.env.NEXT_PUBLIC_ADMIN_ENV === "sandbox" ? "sandbox" : "production";
+
+export function EnvBadge() {
+ const sandbox = ENV === "sandbox";
+ return (
+
+
+ {sandbox ? "Sandbox" : "Production"}
+
+ );
+}
+```
+
+`adminsite/components/Button.tsx`:
+
+```tsx
+import clsx from "clsx";
+
+type Props = React.ButtonHTMLAttributes & { variant?: "solid" | "ghost" };
+
+export function Button({ variant = "solid", className, ...rest }: Props) {
+ return (
+
+ );
+}
+```
+
+`adminsite/components/Field.tsx`:
+
+```tsx
+export function Field({
+ label,
+ hint,
+ error,
+ ...input
+}: React.InputHTMLAttributes & {
+ label: string;
+ hint?: React.ReactNode;
+ error?: string;
+}) {
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 6: Write the auth screens**
+
+`adminsite/app/login/page.tsx`:
+
+```tsx
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import Link from "next/link";
+import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
+import { NotConnectedPanel } from "@/components/NotConnected";
+import { Button } from "@/components/Button";
+import { Field } from "@/components/Field";
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [staff, setStaff] = useState(false);
+ const [error, setError] = useState(null);
+ const [offline, setOffline] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ setError(null);
+ try {
+ const s = staff ? await api.staffLogin(email, password) : await api.login(email, password);
+ router.replace(s.kind === "staff" ? "/staff" : "/");
+ } catch (err) {
+ if (err instanceof NotConnected) setOffline(true);
+ else if (err instanceof ApiError) setError(err.message);
+ else setError("Sign in failed. Try again.");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ if (offline) return ;
+
+ return (
+
+
Sign in
+
+
+ );
+}
+
+function Main({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+```
+
+`adminsite/app/signup/page.tsx`:
+
+```tsx
+"use client";
+
+import { useState } from "react";
+import { ApiError, NotConnected, api } from "@/lib/api";
+import { Button } from "@/components/Button";
+import { Field } from "@/components/Field";
+
+export default function SignupPage() {
+ const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
+ const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
+ const [error, setError] = useState(null);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setState("busy");
+ setError(null);
+ try {
+ await api.signup(form);
+ setState("sent");
+ } catch (err) {
+ setState("idle");
+ setError(
+ err instanceof NotConnected
+ ? "The licensing service is not reachable from this page."
+ : err instanceof ApiError
+ ? err.message
+ : "Could not create the account. Try again.",
+ );
+ }
+ }
+
+ return (
+
+ {state === "sent" ? (
+
+
Check your email
+
+ We sent a link to {form.email}. Open it to finish setting up your account —
+ it expires in 24 hours. Nothing is created until you do.
+
+
+ ) : (
+ <>
+
Create an account
+
+ For self-hosted licences. If you run on our cloud, sign in with the same
+ details you use for your Vantage instance.
+
+ {unlinked.length === 1 ? "One purchase is" : `${unlinked.length} purchases are`}{" "}
+ not attached to an install yet, so no licence has been issued for{" "}
+ {unlinked.length === 1 ? "it" : "them"}.
+
+
+ Link an install
+
+
+ )}
+
+ {data.instances.length === 0 ? (
+
+
No instances yet
+
+ There are two ways to run Vantage. Buy a cloud instance and we host it, and
+ your licence is applied automatically. Or buy a self-hosted licence, install
+ Vantage on your own server, and link it here to get your licence file.
+
+ Relinking issues a replacement licence for the new install, covering the rest of
+ your current term.
+
+ {open && !exhausted && (
+ setValue(e.target.value)}
+ error={error}
+ hint="From Settings → Licence on the new install."
+ />
+ )}
+
+
+
+ {exhausted
+ ? "You have used every relink for this term — contact support and we will sort it out."
+ : `${remaining} of ${max} relinks left this term`}
+
+
;
+ if (!instance) {
+ // 404 rather than a refusal: the backend does the same, and confirming
+ // an instance exists would be an existence oracle over other accounts.
+ return
+ );
+}
+```
+
+`License.blob` is `json:"-"` on the backend, so add a customer-facing blob field: in `admin/internal/api/customer.go`, `getInstanceLicense` and `downloadInstanceLicense` already have the record — change `getInstanceLicense` to respond `c.JSON(http.StatusOK, gin.H{"license": lic, "blob": lic.Blob})` and widen the TS type to `License & { blob?: string }`. Do this in Step 7 before the page can render the blob.
+
+- [ ] **Step 7: Return the blob to its owner**
+
+In `admin/internal/api/customer.go`, replace the final line of `getInstanceLicense`:
+
+```go
+ // The owner gets the blob itself: it is signed public data bound to their
+ // own instance, and the download endpoint hands over the same bytes.
+ c.JSON(http.StatusOK, gin.H{
+ "license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier,
+ "deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features,
+ "issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason,
+ "issued_by": lic.IssuedBy, "blob": lic.Blob,
+ })
+```
+
+In `adminsite/lib/api.ts`, change the customer call's type:
+
+```ts
+ license: (id: string) => req(`/api/instances/${id}/license`),
+```
+
+Rebuild admin: `sh /tmp/gorun.sh admin go build ./...` — expected no output.
+
+- [ ] **Step 8: Run the suite**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run
+```
+
+Expected: `13 passed`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add adminsite/ admin/
+git commit -m "feat(adminsite): licence delivery, paste instructions and relink"
+```
+
+---
+
+### Task 8: The link flow
+
+Covers spec test 3. This is the screen the five-minute bar applies to.
+
+**Files:**
+- Create: `adminsite/app/(customer)/instances/link/page.tsx`, `adminsite/app/(customer)/instances/link/LinkForm.tsx`, `adminsite/app/(customer)/instances/link/LinkForm.test.tsx`
+
+**Interfaces:**
+- Produces: ` void} />`
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ApiError } from "@/lib/api";
+import { LinkForm } from "./LinkForm";
+
+const link = vi.fn();
+vi.mock("@/lib/api", async () => {
+ const actual = await vi.importActual("@/lib/api");
+ return { ...actual, api: { ...actual.api, link: (...a: unknown[]) => link(...a) } };
+});
+
+const VALID = "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d";
+
+describe("LinkForm", () => {
+ it("catches a malformed id before asking the server", async () => {
+ render();
+ await userEvent.type(screen.getByLabelText(/instance id/i), "not-a-uuid");
+ await userEvent.click(screen.getByRole("button", { name: /link and issue/i }));
+
+ expect(screen.getByText(/does not look like an instance id/i)).toBeInTheDocument();
+ expect(link).not.toHaveBeenCalled();
+ });
+
+ it("lands the customer on their licence on success", async () => {
+ const onLinked = vi.fn();
+ link.mockResolvedValue({ instance_id: VALID });
+ render();
+ await userEvent.type(screen.getByLabelText(/instance id/i), VALID);
+ await userEvent.click(screen.getByRole("button", { name: /link and issue/i }));
+
+ await waitFor(() => expect(onLinked).toHaveBeenCalledWith(VALID));
+ });
+
+ it("shows the server's own message when the id is already linked", async () => {
+ link.mockRejectedValue(new ApiError(409, "that instance ID is already linked to an account"));
+ render();
+ await userEvent.type(screen.getByLabelText(/instance id/i), VALID);
+ await userEvent.click(screen.getByRole("button", { name: /link and issue/i }));
+
+ expect(await screen.findByText(/already linked to an account/i)).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run app/\(customer\)/instances/link/LinkForm.test.tsx
+```
+
+Expected: FAIL — cannot resolve `./LinkForm`.
+
+- [ ] **Step 3: Write LinkForm**
+
+```tsx
+"use client";
+
+import { useState } from "react";
+import { ApiError, NotConnected, api } from "@/lib/api";
+import { Button } from "@/components/Button";
+import { Field } from "@/components/Field";
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) {
+ const [id, setId] = useState("");
+ const [name, setName] = useState("");
+ const [error, setError] = useState();
+ const [busy, setBusy] = useState(false);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ const value = id.trim();
+
+ // Checked here so a typo costs nothing and the message is instant.
+ if (!UUID_RE.test(value)) {
+ setError("That does not look like an instance ID. It should look like the example below.");
+ return;
+ }
+
+ setBusy(true);
+ setError(undefined);
+ try {
+ const inst = await api.link(value, name.trim());
+ onLinked(inst.instance_id);
+ } catch (err) {
+ setError(
+ err instanceof NotConnected
+ ? "The licensing service is not reachable from this page."
+ : err instanceof ApiError
+ ? err.message
+ : "Could not link that instance. Try again.",
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 4: Run it and watch it pass**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run app/\(customer\)/instances/link/LinkForm.test.tsx
+```
+
+Expected: `3 passed`.
+
+- [ ] **Step 5: Write the page**
+
+```tsx
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useQueryClient } from "@tanstack/react-query";
+import { LinkForm } from "./LinkForm";
+
+export default function LinkPage() {
+ const router = useRouter();
+ const qc = useQueryClient();
+
+ return (
+
+
+
Link an install
+
+ Every licence is tied to one install, so we need its ID before we can issue
+ yours. Paste it below and your licence is ready on the next screen.
+
+
+ {
+ qc.invalidateQueries({ queryKey: ["account"] });
+ // Straight to the download, not back to a list: the licence is
+ // the thing they came for.
+ router.push(`/instances/${instanceId}`);
+ }}
+ />
+
+ You have no subscriptions. Cloud instances and self-hosted licences are both
+ bought from the pricing page.
+
+ ) : (
+
+
+
+
+
Plan
+
Term
+
Status
+
Renews
+
+
+
+ {data.map((s) => (
+
+
{s.tier.replace("_", " ")}
+
{s.term}
+
{s.status}
+
+ {formatDate(s.current_period_end)}
+
+
+ ))}
+
+
+
+ )}
+
+
+ To change a card, download an invoice or cancel, email support and we will send you
+ a billing link. Self-service billing arrives with card payments.
+
+
+ );
+}
+```
+
+That last paragraph is replaced by the Paddle portal deep-link in spec 5. It states the current truth rather than linking nowhere.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add adminsite/
+git commit -m "feat(adminsite): customer billing view"
+```
+
+---
+
+### Task 10: Staff operations dashboard
+
+Covers spec test 7.
+
+**Files:**
+- Create: `adminsite/app/(staff)/staff/page.tsx`, `adminsite/components/Queue.tsx`, `adminsite/components/Queue.test.tsx`
+
+**Interfaces:**
+- Produces: `` where `items: { label: string; href: string; meta: string }[]`
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Queue } from "./Queue";
+
+describe("Queue", () => {
+ it("shows the count and links every row to the work", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Failed injections")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Acme Production" })).toHaveAttribute(
+ "href",
+ "/staff/instances/i1",
+ );
+ });
+
+ it("says so plainly when there is nothing to do", () => {
+ render();
+ expect(screen.getByText(/nothing to do/i)).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run components/Queue.test.tsx
+```
+
+Expected: FAIL — cannot resolve `./Queue`.
+
+- [ ] **Step 3: Write Queue**
+
+```tsx
+import Link from "next/link";
+import clsx from "clsx";
+
+const TONE = {
+ expired: "border-l-expired text-expired",
+ warn: "border-l-warn text-warn",
+ accent: "border-l-accent text-accent",
+} as const;
+
+export function Queue({
+ title,
+ count,
+ tone,
+ items,
+}: {
+ title: string;
+ count: number;
+ tone: keyof typeof TONE;
+ items: { label: string; href: string; meta: string }[];
+}) {
+ return (
+
+
+
+ {/* Guard rail two: deployment is shown, never edited. */}
+
+ 🔒
+ Deployment is fixed at {p.deployment}. Moving a
+ tier between cloud and self-hosted is a code change, not a form field.
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+```
+
+Those two buttons are the concrete edits staff actually need on day one; a general-purpose limits editor is deliberately not built until someone asks for it.
+
+- [ ] **Step 6: Run the whole suite**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run
+```
+
+Expected: `26 passed`, covering all ten of spec 4's listed tests.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add adminsite/
+git commit -m "feat(adminsite): plan editing with both guard rails"
+```
+
+---
+
+### Task 15: Image, compose, CI and docs
+
+**Files:**
+- Create: `adminsite/Dockerfile`, `adminsite/.dockerignore`
+- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`
+
+- [ ] **Step 1: Write the Dockerfile**
+
+Context is `adminsite/`, like `web/` — this app has no shared-module dependency.
+
+```dockerfile
+FROM node:26-alpine AS deps
+
+WORKDIR /app
+
+COPY package.json package-lock.json* ./
+RUN npm install
+
+FROM node:26-alpine AS builder
+
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# Baked in at build time and must be reachable from the BROWSER, and present in
+# admin's ADMIN_ORIGIN. Wrong here means every request fails at runtime.
+ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083
+ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
+ARG NEXT_PUBLIC_ADMIN_ENV=production
+ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV
+
+RUN npm run build
+
+FROM node:26-alpine AS runner
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 nextjs
+
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
+```
+
+`adminsite/.dockerignore`:
+
+```
+node_modules
+.next
+.env
+*.lic
+```
+
+- [ ] **Step 2: Add the compose service**
+
+In `deploy/docker-compose.site.yml`, after `admin`:
+
+```yaml
+ adminsite:
+ image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
+ restart: unless-stopped
+ ports:
+ # 3002 is the marketing site; this takes 3004.
+ - 3004:3000
+ depends_on:
+ - admin
+```
+
+- [ ] **Step 3: Add the image build**
+
+In `.gitea/workflows/server-deploy.yml`, after the admin step:
+
+```yaml
+ - name: Build and push adminsite image
+ run: |
+ IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/adminsite:latest"
+ docker build \
+ --build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
+ --build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
+ -t "$IMAGE" \
+ -f adminsite/Dockerfile adminsite/
+ docker push "$IMAGE"
+```
+
+- [ ] **Step 4: Document it**
+
+In `CLAUDE.md`:
+
+- add `adminsite/` to the repository structure and the route table
+- record that it is served at `vantage-hq.hostxtra.co.uk`, listens on `3000` and is published as `3004`, and that the host sits outside `*.vantage.hostxtra.co.uk` on purpose because that namespace is per-tenant instance subdomains
+- add `ADMIN_API_URL` and `ADMIN_ENV` to the CI variables table beside `SITE_API_URL`, carrying the same browser-reachable warning
+- record that admin's HTTP surface gained `GET /auth/me`, `POST /auth/signup`, `GET /api/staff/instances/:id` and `GET /api/staff/subscriptions`, and that `GET /api/instances/:id/license` now returns the blob to its owner
+
+- [ ] **Step 5: Build the image**
+
+```bash
+MSYS_NO_PATHCONV=1 docker build -q \
+ --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \
+ -f adminsite/Dockerfile -t vantage-adminsite:test adminsite/
+```
+
+Expected: an image ID. This is the check that catches a missing dependency the dev server tolerated.
+
+- [ ] **Step 6: Confirm the self-hosted deployment is untouched**
+
+```bash
+grep -c "adminsite" deploy/docker-compose.yml
+cd deploy && MSYS_NO_PATHCONV=1 docker compose -f docker-compose.yml config --services | sort | tr '\n' ' '
+```
+
+Expected: `0`, then exactly `guacd redis server web`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add adminsite/ deploy/ .gitea/ CLAUDE.md
+git commit -m "feat(adminsite): image, compose service and image build"
+```
+
+---
+
+### Task 16: Full verification
+
+Everything in containers, against the stack from spec 3's Task 11.
+
+- [ ] **Step 1: Suite green and a clean production build**
+
+```bash
+sh /tmp/noderun.sh adminsite npx vitest run
+sh /tmp/noderun.sh adminsite npm run build
+sh /tmp/noderun.sh adminsite npm run lint
+```
+
+Expected: `26 passed`; a successful standalone build; no lint errors.
+
+- [ ] **Step 2: Bring up the whole stack**
+
+Start Mongo, Redis, the control plane and admin exactly as in the spec-3 plan Task 11 steps 2–4, then adminsite pointed at admin, with admin's `ADMIN_ORIGIN` including it:
+
+```bash
+docker rm -f vadminsite >/dev/null 2>&1
+MSYS_NO_PATHCONV=1 docker build -q \
+ --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \
+ --build-arg NEXT_PUBLIC_ADMIN_ENV=sandbox \
+ -f adminsite/Dockerfile -t vantage-adminsite:test adminsite/
+MSYS_NO_PATHCONV=1 docker run -d --name vadminsite -p 3004:3000 vantage-adminsite:test
+sleep 6
+curl -s -o /dev/null -w "adminsite: %{http_code}\n" localhost:3004/login
+```
+
+Admin must be started with `-e ADMIN_ORIGIN=http://localhost:3004`. Locally the origin is `localhost:3004`; in production it is `https://vantage-hq.hostxtra.co.uk`, and both must appear in `ADMIN_ORIGIN` if you want the local build to keep working against the deployed API.
+
+Expected: `200`.
+
+- [ ] **Step 3: Self-hosted purchase to working licence, timed, no documentation**
+
+In a browser at `http://localhost:3004`: sign up, open the verification link from admin's logs (`docker logs vadmin | grep verify`), sign in, link the instance UUID from a self-hosted install, download the licence, paste it into that install's `Settings → Licence`.
+
+Expected: the install reports `Valid · Self Hosted`. **Time it. Over five minutes means the flow needs work, not the plan.**
+
+- [ ] **Step 4: Cloud pass**
+
+Sign in as the cloud owner (`owner@example.com`), confirm the overview shows the instance as valid with days remaining, and that the instance's own `/settings/license` agrees.
+
+- [ ] **Step 5: Staff pass, the whole point of the ledger**
+
+Sign in with `I work at Vantage`, search accounts by the instance UUID, open the instance, read the ledger top to bottom, reissue, and confirm the control plane picks it up within a minute:
+
+```bash
+curl -s localhost:8080/api/license -b /tmp/a.txt | grep -oE '"state":"[a-z]+"|"tier":"[a-z_]+"'
+```
+
+Expected: the ledger shows the previous licence overprinted `Superseded` and linked to the new one; the control plane reports `valid`.
+
+- [ ] **Step 6: Confirm the guards both ways**
+
+```bash
+curl -s -o /dev/null -w "customer hitting staff API: %{http_code}\n" \
+ localhost:8083/api/staff/accounts -b /tmp/c.txt
+curl -s -o /dev/null -w "customer reading another account's instance: %{http_code}\n" \
+ localhost:8083/api/instances/11111111-2222-3333-4444-555555555555/license -b /tmp/c.txt
+```
+
+Expected: `401` and `404`. Then in the browser, as a customer, visit `/staff` and confirm it redirects to `/` rather than showing a refusal.
+
+- [ ] **Step 7: Not-connected state**
+
+```bash
+docker rm -f vadminsite-broken >/dev/null 2>&1
+MSYS_NO_PATHCONV=1 docker build -q \
+ --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:9999 \
+ -f adminsite/Dockerfile -t vantage-adminsite:broken adminsite/
+MSYS_NO_PATHCONV=1 docker run -d --name vadminsite-broken -p 3005:3000 vantage-adminsite:broken
+```
+
+Expected: `localhost:3005` renders the not-connected panel naming `ADMIN_API_URL` and `ADMIN_ORIGIN`, not a blank page or a spinner forever.
+
+- [ ] **Step 8: Environment badge and responsive check**
+
+Confirm the sandbox badge is hatched and visible on every screen in the `NEXT_PUBLIC_ADMIN_ENV=sandbox` build. Then at 375px width, walk the customer overview, instance detail and link flow — a customer hit by an expiry email opens this on a phone.
+
+- [ ] **Step 9: Clean up and commit**
+
+```bash
+docker rm -f vadminsite vadminsite-broken vadmin vadmin-server vadmin-mongo vadmin-redis
+docker rmi vantage-adminsite:test vantage-adminsite:broken
+git add -A
+git commit -m "chore: verify the admin site end to end" --allow-empty
+```
+
+---
+
+## Rollout
+
+1. Point `vantage-hq.hostxtra.co.uk` at the host and terminate TLS in front of `3004`.
+2. Set `ADMIN_API_URL` and `ADMIN_ENV` as Gitea variables **before the first build** — `ADMIN_API_URL` is baked into the image, so changing it later means a rebuild, not a restart. It must be browser-reachable and listed in admin's `ADMIN_ORIGIN`.
+3. Add `https://vantage-hq.hostxtra.co.uk` to `ADMIN_ORIGIN` in the host `.env` and restart admin.
+4. Deploy admin first (tasks 1–2 change its API), then adminsite.
+5. `adminctl staff-add` per staff member if not already done.
+6. **First real job: licence the existing cloud instances.** Staff → Accounts → create an account, attach the instance, issue. They stay read-only until that is done.
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| `ADMIN_API_URL` misconfigured at build | Explicit not-connected panel naming the variable and `ADMIN_ORIGIN`; verified in Task 16 Step 7 |
+| Cross-origin cookies silently dropped | Both hosts stay under one registrable domain so `SameSite=Lax` still applies; documented in Global Constraints and exercised by every browser step in Task 16 |
+| Staff action against the wrong environment | Hatched badge on every screen from the root layout; confirmation on plan changes |
+| Customer session reaching staff data | Route-group guard (test 1) plus admin's own 401/404 (Task 16 Step 6). Two layers |
+| Customer confused by the self-hosted flow | Named UUID location, client-side format check, success lands on the download; five-minute bar in Task 16 Step 3 |
+| Plan edit mistaken for retroactive | Confirmation names each field and the count of licences already issued |
+| Ledger unreadable once an instance has years of history | Newest first, superseded rows dimmed and overprinted; filterable global view on `/staff/licenses` |