# 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

- **The token set is `site/`'s, copied verbatim.** `adminsite/app/globals.css` carries the same custom properties, with the same names and the same hex values, as `site/app/globals.css` — brand navy accent `#0b2a58` light / `#5b9be8` dark, `--up #2f8a60` / `--down #c6462f` / `--pend #b0801f`, the same `--shadow`, the same `--s--1`…`--s-4` clamp scale, `--rail: 1200px`. **When `site/`'s tokens change, change these in the same commit** — they are one visual system in two apps, and there is nothing that enforces the match automatically.
- **Tailwind maps those variables, it does not redefine them.** Colours in `tailwind.config.ts` are `var(--…)` references only. Never write a hex value in a component or in the Tailwind config.
- **Licence state uses `site/`'s semantic tokens.** Tailwind exposes them as `valid`/`warn`/`expired`, aliased onto `--up`/`--pend`/`--down` so this app names them for what it means while staying the same three colours the marketing site uses for up, pending and down. Semantic hues are **never** reused as an accent.
- **Type roles:** `--sans` for everything, `--mono` with `tabular-nums` for every UUID, timestamp, blob and limit. **There is no display face** — matching `site/` means headings are the sans at `font-weight: 800`, `letter-spacing: -0.03em`, `line-height: 1.03`.
- **Distinctness from `web/` rests on ground and hue together.** `web/` is locked to dark with an indigo `#6366f1` accent; this app defaults to light with navy. Note the caveat: in dark mode `site/`'s accent lifts to `#5b9be8`, which is nearer web/'s indigo, so the "which app am I in" cue leans on the light ground. Do not make dark the default.
- **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> <command...>
  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`, `Field`, `Button`, `Queue`, `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`

- [x] **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,
	})
}
```

- [x] **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)`.

- [x] **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.

- [x] **Step 3b: Make `CreateCustomerUser` undo its own insert**

Found while verifying Step 7: when the verification email fails, `HandleSignup`
rolls the account back but the `customer_users` row survives. That orphan can
never be signed in to *and* it holds the unique index on `email`, so the next
signup with that address hits the "already exists" branch and gets a cheerful
`201` forever — the customer is locked out of their own address with no error
anyone can see.

In `admin/internal/auth/customer.go`, replace the tail of `CreateCustomerUser`:

```go
	if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
		return err
	}

	if err := mail.SendVerification(u.Email, token); err != nil {
		// Undo the insert. A row whose verification link was never delivered is
		// worse than no row: it can never be signed in to, and it holds the
		// unique index on email, so the customer cannot sign up again with the
		// address they just used.
		_, _ = db.Admin("customer_users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
		return err
	}
	return nil
}
```

Confirm by posting the same signup twice with SMTP unconfigured: both must
return `500`, and both collections must be empty afterwards. Before the fix the
second call returns `201`.

- [x] **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,
	})
```

- [x] **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)
```

- [x] **Step 6: Build**

```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [x] **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`).

- [x] **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`

- [x] **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
	}
```

- [x] **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.

- [x] **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(&current) == 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)
}
```

- [x] **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}}))
```

- [x] **Step 5: Route them**

In the `staff` group in `admin/internal/api/routes.go`:

```go
		staff.GET("/instances/:id", staffGetInstance)
		staff.GET("/subscriptions", staffListSubscriptions)
```

- [x] **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.

- [x] **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`.

- [x] **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-sans font-mono` (no display face — headings are the sans at weight 800)

- [ ] **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` — every colour is a `var()` reference, so `site/`'s token file is the single source of truth and one set serves both themes:

```ts
import type { Config } from "tailwindcss";

/*
 * Tokens are shared with site/ — same names, same values, copied verbatim into
 * app/globals.css. Nothing here may hold a hex value: if a colour needs to
 * change it changes in globals.css, in both apps, in one commit.
 *
 * The semantic three are aliased rather than renamed. site/ calls them up,
 * down and pend because it shows monitor state; this app calls them valid,
 * expired and warn because it shows licence state. Same colours, honest names
 * on both sides.
 */
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(--up)",
                warn: "var(--pend)",
                expired: "var(--down)",
                archival: "var(--ink-3)",
            },
            fontFamily: {
                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"],
            },
            // site/ uses 4px on panels and buttons, 2px on focus rings.
            borderRadius: { DEFAULT: "4px" },
            maxWidth: { rail: "1200px" },
        },
    },
    plugins: [],
};

export default config;
```

- [ ] **Step 5: app/globals.css**

Copy the token block out of `site/app/globals.css` unchanged — same names, same
values, all three theme selectors. The only addition is `--accent-wash`, which
`site/` has no need for.

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

/* ==========================================================================
   Vantage admin console design tokens.

   These are site/app/globals.css's tokens, copied verbatim: the marketing site
   and this console are one visual system. Change them in both apps in the same
   commit — nothing enforces the match automatically.

   Light is the default because web/ is locked to dark, and telling the two
   apart at a glance is what stops a Reissue landing in the wrong tab. In dark
   mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
   distinction leans on the ground rather than the hue.
   ========================================================================== */

:root {
    color-scheme: light dark;

    --ground: #eaedf3;
    --panel: #ffffff;
    --panel-2: #f4f6fa;
    --ink: #0a1b33;
    --ink-2: #41556f;
    --ink-3: #6c7f96;
    --rule: #cdd6e2;
    --rule-soft: #e0e6ef;
    --accent: #0b2a58;
    --accent-ink: #ffffff;
    --up: #2f8a60;
    --down: #c6462f;
    --pend: #b0801f;
    --shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
    --logo: #0b2a58;

    /* Not in site/: the hatched sandbox badge and hover washes need a tinted
       fill, and deriving it per-use would drift. */
    --accent-wash: rgba(11, 42, 88, 0.07);

    --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;

    --s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
    --s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
    --s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
    --s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
    --s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
    --s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);

    --rail: 1200px;
}

/* Dark tokens are defined once and applied through three selectors: the OS
   preference, and both explicit values of data-theme so an in-page toggle wins
   in either direction. Same pattern as site/. */
@media (prefers-color-scheme: dark) {
    :root {
        --ground: #071628;
        --panel: #0d2138;
        --panel-2: #102842;
        --ink: #e4ecf6;
        --ink-2: #9fb3ca;
        --ink-3: #71879f;
        --rule: #1e3855;
        --rule-soft: #172c44;
        --accent: #5b9be8;
        --accent-ink: #04101f;
        --up: #4fb484;
        --down: #e2705a;
        --pend: #d6a63f;
        --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
        --logo: #7fb2f0;
        --accent-wash: rgba(91, 155, 232, 0.1);
    }
}

:root[data-theme="dark"] {
    --ground: #071628;
    --panel: #0d2138;
    --panel-2: #102842;
    --ink: #e4ecf6;
    --ink-2: #9fb3ca;
    --ink-3: #71879f;
    --rule: #1e3855;
    --rule-soft: #172c44;
    --accent: #5b9be8;
    --accent-ink: #04101f;
    --up: #4fb484;
    --down: #e2705a;
    --pend: #d6a63f;
    --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
    --logo: #7fb2f0;
    --accent-wash: rgba(91, 155, 232, 0.1);
}

:root[data-theme="light"] {
    --ground: #eaedf3;
    --panel: #ffffff;
    --panel-2: #f4f6fa;
    --ink: #0a1b33;
    --ink-2: #41556f;
    --ink-3: #6c7f96;
    --rule: #cdd6e2;
    --rule-soft: #e0e6ef;
    --accent: #0b2a58;
    --accent-ink: #ffffff;
    --up: #2f8a60;
    --down: #c6462f;
    --pend: #b0801f;
    --shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
    --logo: #0b2a58;
    --accent-wash: rgba(11, 42, 88, 0.07);
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

body {
    margin: 0;
    background: var(--ground);
    color: var(--ink);
    font-family: var(--sans);
    font-size: var(--s-0);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
}

/* site/'s heading treatment, which is what replaces a display face. */
h1,
h2,
h3 {
    margin: 0;
    font-weight: 800;
    line-height: 1.03;
    letter-spacing: -0.03em;
    text-wrap: balance;
}

p {
    margin: 0;
}

code {
    font-family: var(--mono);
    font-size: 0.92em;
}

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 3px;
    border-radius: 2px;
}

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.001ms !important;
        transition-duration: 0.001ms !important;
    }
}
```

Because `h1`–`h3` carry the weight and tracking from this file, the Tailwind
classes on headings in later tasks (`text-3xl font-extrabold tracking-[-0.03em]`)
are belt-and-braces for elements that are not `h1`–`h3`. Leave them; they cost
nothing and keep a `<p>` used as a title looking right.

- [ ] **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 (
        <html lang="en">
            <body>
                <header className="border-b border-rule bg-panel">
                    <div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-4 py-4 sm:px-8">
                        <span className="flex items-baseline gap-2 text-lg font-extrabold tracking-[-0.03em]">
                            <b className="font-semibold">Vantage</b>
                            <span className="font-sans text-[0.72rem] uppercase tracking-[0.14em] text-ink-3">
                                Licensing
                            </span>
                        </span>
                        <EnvBadge />
                    </div>
                </header>
                <Providers>{children}</Providers>
            </body>
        </html>
    );
}
```

- [ ] **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`
  - `<NotConnectedPanel />`
  - `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(<NotConnectedPanel url="https://admin.example.com" />);

        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(<NotConnectedPanel url="" />);
        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 (
        <div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
            <h2 className="text-xl font-extrabold tracking-[-0.03em] text-expired">
                Not connected to the licensing service
            </h2>
            {url ? (
                <p className="text-ink-2">
                    This build points at <code className="font-mono text-ink">ADMIN_API_URL</code> ={" "}
                    <code className="font-mono text-ink">{url}</code>, which did not respond.
                </p>
            ) : (
                <p className="text-ink-2">
                    <code className="font-mono text-ink">ADMIN_API_URL</code> was not set when this
                    app was built, so there is nowhere to send requests.
                </p>
            )}
            <p className="text-[0.82rem] text-ink-3">
                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&rsquo;s <code className="font-mono">ADMIN_ORIGIN</code>, or the browser
                blocks every request.
            </p>
        </div>
    );
}
```

- [ ] **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<T>(path: string, init?: RequestInit): Promise<T> {
    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 = <T,>(path: string, payload?: unknown) =>
    req<T>(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<string, string>;
    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<Session>("/auth/me"),
    login: (email: string, password: string) => post<Session>("/auth/login", { email, password }),
    staffLogin: (email: string, password: string) =>
        post<Session>("/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<AccountResponse>("/api/account"),
    link: (instance_id: string, name: string) =>
        post<Instance>("/api/instances/link", { instance_id, name }),
    relink: (id: string, instance_id: string) =>
        post<License>(`/api/instances/${id}/relink`, { instance_id }),
    license: (id: string) => req<License>(`/api/instances/${id}/license`),
    licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
    subscriptions: () => req<Subscription[]>("/api/subscriptions"),

    staff: {
        accounts: (q?: string) =>
            req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
        account: (id: string) => req<StaffAccountResponse>(`/api/staff/accounts/${id}`),
        instances: (params?: Record<string, string>) =>
            req<Instance[]>(`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`),
        instance: (id: string) => req<StaffInstanceResponse>(`/api/staff/instances/${id}`),
        issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
            post<License>(`/api/staff/instances/${id}/issue`, payload),
        relink: (id: string, instance_id: string) =>
            post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
        licenses: (params?: Record<string, string>) =>
            req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
        plans: () => req<Plan[]>("/api/staff/plans"),
        updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
            req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
                method: "PUT",
                body: JSON.stringify(plan),
            }),
        audit: (accountId?: string) =>
            req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
        injectionHealth: () =>
            req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
        subscriptions: (status?: string) =>
            req<Subscription[]>(`/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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
```

- [ ] **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()`, `<RequireKind kind="staff" | "customer">`, `<EnvBadge />`, `<Button />`, `<Field />`

- [ ] **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<typeof import("./api")>("./api");
    return { ...actual, api: { ...actual.api, me: () => me() } };
});

function wrap(ui: React.ReactNode) {
    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

describe("RequireKind", () => {
    beforeEach(() => replace.mockClear());

    it("renders staff screens for a staff session", async () => {
        me.mockResolvedValue({ kind: "staff", email: "s@example.com" });
        wrap(<RequireKind kind="staff">operations</RequireKind>);
        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(<RequireKind kind="staff">operations</RequireKind>);
        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(<RequireKind kind="customer">account</RequireKind>);
        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<Session>({
        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 <NotConnectedPanel url={API_BASE} />;
    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 (
        <span
            className={
                sandbox
                    ? "inline-flex items-center gap-2 rounded-sm border border-warn px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink [background-image:repeating-linear-gradient(-45deg,var(--accent-wash)_0_6px,transparent_6px_12px)]"
                    : "inline-flex items-center gap-2 rounded-sm bg-accent px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-accent-ink"
            }
        >
            <i className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" />
            {sandbox ? "Sandbox" : "Production"}
        </span>
    );
}
```

`adminsite/components/Button.tsx`:

```tsx
import clsx from "clsx";

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };

/*
 * Matches site/'s .btn--solid and .btn--line exactly, including the neutral
 * border on the secondary variant. site/ does not have an accent-outlined
 * button and this app should not invent one.
 */
export function Button({ variant = "solid", className, ...rest }: Props) {
    return (
        <button
            {...rest}
            className={clsx(
                "inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
                "transition-[filter,border-color] duration-150 hover:brightness-110",
                variant === "solid"
                    ? "border-accent bg-accent text-accent-ink"
                    : "border-rule bg-panel text-ink hover:border-ink-3",
                rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
                className,
            )}
        />
    );
}
```

`adminsite/components/Field.tsx`:

```tsx
export function Field({
    label,
    hint,
    error,
    ...input
}: React.InputHTMLAttributes<HTMLInputElement> & {
    label: string;
    hint?: React.ReactNode;
    error?: string;
}) {
    return (
        <label className="grid max-w-md gap-1.5">
            <span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
                {label}
            </span>
            <input
                {...input}
                className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
            />
            {error ? (
                <span className="text-[0.82rem] text-expired">{error}</span>
            ) : hint ? (
                <span className="text-[0.82rem] text-ink-3">{hint}</span>
            ) : null}
        </label>
    );
}
```

- [ ] **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<string | null>(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 <Main><NotConnectedPanel url={API_BASE} /></Main>;

    return (
        <Main>
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Sign in</h1>
            <form onSubmit={submit} className="mt-6 grid gap-4">
                <Field
                    label="Email"
                    type="email"
                    autoComplete="username"
                    required
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                />
                <Field
                    label="Password"
                    type="password"
                    autoComplete="current-password"
                    required
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    error={error ?? undefined}
                />
                <label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
                    <input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} />
                    I work at Vantage
                </label>
                <div className="flex flex-wrap items-center gap-3">
                    <Button type="submit" disabled={busy}>
                        {busy ? "Signing in…" : "Sign in"}
                    </Button>
                    <Link href="/signup" className="text-[0.82rem] text-accent underline">
                        Create an account for a self-hosted licence
                    </Link>
                </div>
            </form>
        </Main>
    );
}

function Main({ children }: { children: React.ReactNode }) {
    return <main className="mx-auto max-w-rail px-4 py-12 sm:px-8">{children}</main>;
}
```

`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<string | null>(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 (
        <main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
            {state === "sent" ? (
                <div className="grid max-w-xl gap-3">
                    <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Check your email</h1>
                    <p className="text-ink-2">
                        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.
                    </p>
                </div>
            ) : (
                <>
                    <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Create an account</h1>
                    <p className="mt-2 max-w-xl text-ink-2">
                        For self-hosted licences. If you run on our cloud, sign in with the same
                        details you use for your Vantage instance.
                    </p>
                    <form onSubmit={submit} className="mt-6 grid gap-4">
                        <Field
                            label="Organisation"
                            required
                            value={form.name}
                            onChange={(e) => setForm({ ...form, name: e.target.value })}
                        />
                        <Field
                            label="Email"
                            type="email"
                            required
                            value={form.email}
                            onChange={(e) => setForm({ ...form, email: e.target.value })}
                        />
                        <Field
                            label="Password"
                            type="password"
                            required
                            minLength={12}
                            hint="At least 12 characters."
                            value={form.password}
                            onChange={(e) => setForm({ ...form, password: e.target.value })}
                            error={error ?? undefined}
                        />
                        {/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
                        <input
                            type="text"
                            name="website"
                            tabIndex={-1}
                            autoComplete="off"
                            aria-hidden="true"
                            value={form.website}
                            onChange={(e) => setForm({ ...form, website: e.target.value })}
                            className="absolute left-[-9999px] h-0 w-0"
                        />
                        <Button type="submit" disabled={state === "busy"}>
                            {state === "busy" ? "Creating…" : "Create account"}
                        </Button>
                    </form>
                </>
            )}
        </main>
    );
}
```

`adminsite/app/verify/page.tsx`:

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense } from "react";
import { api } from "@/lib/api";

function Verify() {
    const token = useSearchParams().get("token") ?? "";
    const { data, error, isLoading } = useQuery({
        queryKey: ["verify", token],
        queryFn: () => api.verify(token),
        enabled: token !== "",
        retry: false,
    });

    if (!token) return <Message title="That link is incomplete" body="It is missing its token. Use the link in the email exactly as sent." />;
    if (isLoading) return <Message title="Verifying…" body="One moment." />;
    if (error || !data?.verified)
        return (
            <Message
                title="That link is invalid or has expired"
                body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
            />
        );

    return (
        <div className="grid max-w-xl gap-3">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Email verified</h1>
            <p className="text-ink-2">Your account is ready.</p>
            <Link href="/login" className="text-accent underline">Sign in</Link>
        </div>
    );
}

function Message({ title, body }: { title: string; body: string }) {
    return (
        <div className="grid max-w-xl gap-3">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">{title}</h1>
            <p className="text-ink-2">{body}</p>
        </div>
    );
}

export default function VerifyPage() {
    return (
        <main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
            <Suspense fallback={null}>
                <Verify />
            </Suspense>
        </main>
    );
}
```

- [ ] **Step 7: Write the two guarded layouts and the root redirect**

`adminsite/app/(customer)/layout.tsx`:

```tsx
"use client";

import Link from "next/link";
import { RequireKind } from "@/lib/session";

export default function CustomerLayout({ children }: { children: React.ReactNode }) {
    return (
        <RequireKind kind="customer">
            <nav className="border-b border-rule-soft bg-panel-2">
                <div className="mx-auto flex max-w-rail flex-wrap gap-5 px-4 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em] sm:px-8">
                    <Link href="/" className="text-accent">Overview</Link>
                    <Link href="/instances/link" className="text-ink-3">Link an install</Link>
                    <Link href="/billing" className="text-ink-3">Billing</Link>
                </div>
            </nav>
            <main className="mx-auto max-w-rail px-4 py-8 sm:px-8">{children}</main>
        </RequireKind>
    );
}
```

`adminsite/app/(staff)/staff/layout.tsx` — same shape, `kind="staff"`, links `/staff`, `/staff/accounts`, `/staff/licenses`, `/staff/plans`, `/staff/audit`.

`adminsite/app/page.tsx` is inside `(customer)` as `app/(customer)/page.tsx` (Task 6). Add `adminsite/app/not-found.tsx`:

```tsx
import Link from "next/link";

export default function NotFound() {
    return (
        <main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Nothing here</h1>
            <p className="mt-2 text-ink-2">
                That page does not exist, or it belongs to an account you are not signed in to.
            </p>
            <Link href="/" className="mt-4 inline-block text-accent underline">Back to your account</Link>
        </main>
    );
}
```

- [ ] **Step 8: Run the suite**

```bash
sh /tmp/noderun.sh adminsite npx vitest run
```

Expected: `5 passed`.

- [ ] **Step 9: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): session guards and the auth screens"
```

---

### Task 6: Instance cards and the customer overview

Covers spec test 2 and the `awaiting_link` prompt.

**Files:**
- Create: `adminsite/components/StatePill.tsx`, `adminsite/components/InstanceCard.tsx`, `adminsite/components/InstanceCard.test.tsx`, `adminsite/app/(customer)/page.tsx`

**Interfaces:**
- Consumes: `licenceState`, `daysRemaining`, `formatDate`, `Instance`, `License`
- Produces: `<StatePill state={LicenceState} />`, `<InstanceCard instance license />`

- [ ] **Step 1: Write the failing card test**

`adminsite/components/InstanceCard.test.tsx`:

```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { InstanceCard } from "./InstanceCard";
import type { Instance, License } from "@/lib/api";

const base: Instance = {
    instance_id: "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d",
    account_id: "a1",
    name: "Acme Production",
    slug: "acme",
    deployment: "cloud",
    tier: "professional",
    status: "active",
    current_license: "l1",
    relink_count: 0,
    created_at: "2026-01-01T00:00:00Z",
};

function licence(daysFromNow: number): License {
    return {
        license_id: "l1",
        instance_id: base.instance_id,
        account_id: "a1",
        tier: "professional",
        deployment: "cloud",
        limits: { max_servers: -1, max_secret_groups: -1, max_channels: -1 },
        features: ["console", "oidc"],
        issued_at: "2026-01-01T00:00:00Z",
        expires_at: new Date(Date.now() + daysFromNow * 86_400_000).toISOString(),
        issued_by: "staff@example.com",
        reason: "new",
    };
}

describe("InstanceCard", () => {
    it("shows a valid licence with days remaining", () => {
        render(<InstanceCard instance={base} license={licence(367)} />);
        expect(screen.getByText("Valid")).toBeInTheDocument();
        expect(screen.getByText(/367 days remaining/)).toBeInTheDocument();
    });

    it("warns inside fourteen days", () => {
        render(<InstanceCard instance={base} license={licence(9)} />);
        expect(screen.getByText("Expiring")).toBeInTheDocument();
        expect(screen.getByText(/9 days remaining/)).toBeInTheDocument();
    });

    it("names what still works when expired", () => {
        render(<InstanceCard instance={base} license={licence(-3)} />);
        expect(screen.getByText("Expired")).toBeInTheDocument();
        // The reassurance is the point: this is the first thing a worried
        // customer needs, and the backend really does keep these running.
        expect(screen.getByText(/servers and monitors are still running/i)).toBeInTheDocument();
        expect(screen.getByText(/changes are disabled/i)).toBeInTheDocument();
    });

    it("prompts to link when paid but never linked", () => {
        render(
            <InstanceCard
                instance={{ ...base, status: "awaiting_link", deployment: "self_hosted", current_license: undefined }}
            />,
        );
        expect(screen.getByText("Awaiting link")).toBeInTheDocument();
        expect(screen.getByText(/not attached to an install yet/i)).toBeInTheDocument();
        expect(screen.getByRole("link", { name: /link an install/i })).toBeInTheDocument();
    });

    it("links to the instance's own subdomain for cloud", () => {
        render(<InstanceCard instance={base} license={licence(30)} />);
        expect(screen.getByRole("link", { name: /open/i })).toBeInTheDocument();
    });
});
```

- [ ] **Step 2: Run it and watch it fail**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/InstanceCard.test.tsx
```

Expected: FAIL — cannot resolve `./InstanceCard`.

- [ ] **Step 3: Write StatePill**

`adminsite/components/StatePill.tsx`:

```tsx
import clsx from "clsx";
import type { LicenceState } from "@/lib/format";

const LABEL: Record<LicenceState, string> = {
    valid: "Valid",
    warn: "Expiring",
    expired: "Expired",
    none: "Awaiting link",
};

/*
 * State reads three ways: this pill's colour, the pill's SHAPE, and the label.
 * Colour alone would fail a colourblind reader on the one screen where getting
 * it wrong costs money.
 */
const SHAPE: Record<LicenceState, string> = {
    valid: "rounded-full",
    warn: "[clip-path:polygon(50%_0,100%_100%,0_100%)]",
    expired: "[clip-path:polygon(20%_0,80%_0,100%_20%,100%_80%,80%_100%,20%_100%,0_80%,0_20%)]",
    none: "rounded-none",
};

const TONE: Record<LicenceState, string> = {
    valid: "border-valid text-valid",
    warn: "border-warn text-warn",
    expired: "border-expired text-expired",
    none: "border-accent text-accent",
};

export function StatePill({ state }: { state: LicenceState }) {
    return (
        <span
            className={clsx(
                "inline-flex shrink-0 items-center gap-1.5 rounded-sm border bg-panel px-2 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.08em]",
                TONE[state],
            )}
        >
            <i className={clsx("h-1.5 w-1.5 shrink-0 bg-current", SHAPE[state])} />
            {LABEL[state]}
        </span>
    );
}
```

- [ ] **Step 4: Write InstanceCard**

`adminsite/components/InstanceCard.tsx`:

```tsx
import Link from "next/link";
import clsx from "clsx";
import type { Instance, License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";

const STRIPE = {
    valid: "before:bg-valid",
    warn: "before:bg-warn",
    expired: "before:bg-expired",
    none: "before:bg-accent",
} as const;

export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) {
    const state = licenceState(license?.expires_at, Boolean(license));
    const days = license ? daysRemaining(license.expires_at) : 0;
    const cloud = instance.deployment === "cloud";

    return (
        <article
            className={clsx(
                "relative grid gap-3 rounded border border-rule bg-panel p-4 pl-5",
                "before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
                STRIPE[state],
            )}
        >
            <div className="flex items-start justify-between gap-3">
                <div>
                    <h3 className="text-lg font-semibold">{instance.name || "Unnamed instance"}</h3>
                    <p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
                        {cloud ? "Cloud" : "Self-hosted"}
                        {instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
                    </p>
                </div>
                <StatePillLazy state={state} />
            </div>

            {state === "expired" && (
                <p className="text-[0.82rem] text-ink-2">
                    Servers and monitors are still running, and your agents keep their keys.
                    Changes are disabled until you renew.
                </p>
            )}

            {state === "none" && (
                <p className="text-[0.82rem] text-ink-2">
                    You have paid for this but it is not attached to an install yet, so no licence
                    has been issued. Linking takes a minute.
                </p>
            )}

            {license && state !== "expired" && (
                <div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
                    <span>{days} days remaining</span>
                    <div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
                        <div
                            className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
                            style={{ width: `${Math.max(2, Math.min(100, (days / 365) * 100))}%` }}
                        />
                    </div>
                    <span>Renews {formatDate(license.expires_at)}</span>
                </div>
            )}

            {state === "none" ? (
                <Link href="/instances/link" className="justify-self-start text-[0.82rem] font-semibold text-accent underline">
                    Link an install
                </Link>
            ) : cloud && instance.slug ? (
                <a
                    href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
                    className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
                >
                    Open {instance.slug}.vantage.hostxtra.co.uk
                </a>
            ) : (
                <Link
                    href={`/instances/${instance.instance_id}`}
                    className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
                >
                    {state === "expired" ? "Renew and download" : "Licence and download"}
                </Link>
            )}
        </article>
    );
}

import { StatePill as StatePillLazy } from "./StatePill";
```

Move that import to the top of the file when writing it; it is shown last only to keep the diff readable.

- [ ] **Step 5: Run it and watch it pass**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/InstanceCard.test.tsx
```

Expected: `5 passed`.

- [ ] **Step 6: Write the overview**

`adminsite/app/(customer)/page.tsx`:

```tsx
"use client";

import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { InstanceCard } from "@/components/InstanceCard";

export default function OverviewPage() {
    const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });

    const licences = useQueries({
        queries: (data?.instances ?? [])
            .filter((i) => i.current_license)
            .map((i) => ({
                queryKey: ["license", i.instance_id],
                queryFn: () => api.license(i.instance_id),
            })),
    });

    if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
    if (isLoading || !data) return <p className="text-ink-3">Loading your account…</p>;

    const byInstance = new Map<string, License>();
    licences.forEach((q) => {
        if (q.data) byInstance.set(q.data.instance_id, q.data);
    });

    const unlinked = data.instances.filter((i) => i.status === "awaiting_link");

    return (
        <div className="grid gap-8">
            <header className="grid gap-2">
                <h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.account.name}</h1>
                <p className="text-ink-2">{data.account.billing_email}</p>
            </header>

            {unlinked.length > 0 && (
                <div className="rounded border border-accent bg-accent-wash p-4">
                    <h2 className="font-semibold">Finish setting up your licence</h2>
                    <p className="mt-1 text-[0.82rem] text-ink-2">
                        {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"}.
                    </p>
                    <Link href="/instances/link" className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline">
                        Link an install
                    </Link>
                </div>
            )}

            {data.instances.length === 0 ? (
                <div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
                    <h2 className="text-xl font-extrabold tracking-[-0.03em]">No instances yet</h2>
                    <p className="text-ink-2">
                        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.
                    </p>
                </div>
            ) : (
                <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
                    {data.instances.map((i) => (
                        <InstanceCard key={i.instance_id} instance={i} license={byInstance.get(i.instance_id)} />
                    ))}
                </section>
            )}
        </div>
    );
}
```

- [ ] **Step 7: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): instance cards and the customer overview"
```

---

### Task 7: Customer instance detail, download fallback and relink

Covers spec tests 4 and 5.

**Files:**
- Create: `adminsite/app/(customer)/instances/[id]/page.tsx`, `adminsite/components/LicenceDelivery.tsx`, `adminsite/components/LicenceDelivery.test.tsx`, `adminsite/components/RelinkPanel.tsx`, `adminsite/components/RelinkPanel.test.tsx`

**Interfaces:**
- Produces: `<LicenceDelivery instanceId blob downloadUrl />`, `<RelinkPanel instanceId used max onRelink />`

- [ ] **Step 1: Write the failing tests**

`adminsite/components/LicenceDelivery.test.tsx`:

```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LicenceDelivery } from "./LicenceDelivery";

describe("LicenceDelivery", () => {
    it("offers the file and always shows the blob as a fallback", () => {
        render(
            <LicenceDelivery
                instanceId="6a0fe3f0-49d2-4aa1-967c-a3094b200b5d"
                blob="VANTAGE-LIC abc123"
                downloadUrl="https://admin.example.com/api/instances/x/license/download"
            />,
        );
        const link = screen.getByRole("link", { name: /download licence/i });
        expect(link).toHaveAttribute("href", expect.stringContaining("/license/download"));
        // A blocked download must never leave a paying customer stuck.
        expect(screen.getByText(/VANTAGE-LIC abc123/)).toBeInTheDocument();
        expect(screen.getByText(/Settings → Licence/)).toBeInTheDocument();
    });
});
```

`adminsite/components/RelinkPanel.test.tsx`:

```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RelinkPanel } from "./RelinkPanel";

describe("RelinkPanel", () => {
    it("shows the remaining allowance", () => {
        render(<RelinkPanel instanceId="i1" used={1} max={3} onRelink={vi.fn()} />);
        expect(screen.getByText("2 of 3 relinks left this term")).toBeInTheDocument();
        expect(screen.getByRole("button", { name: /relink/i })).toBeEnabled();
    });

    it("disables at zero and says what to do instead", () => {
        render(<RelinkPanel instanceId="i1" used={3} max={3} onRelink={vi.fn()} />);
        expect(screen.getByRole("button", { name: /relink/i })).toBeDisabled();
        expect(screen.getByText(/contact support/i)).toBeInTheDocument();
    });
});
```

- [ ] **Step 2: Run both and watch them fail**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/LicenceDelivery.test.tsx components/RelinkPanel.test.tsx
```

Expected: FAIL — both imports unresolved.

- [ ] **Step 3: Write LicenceDelivery**

```tsx
"use client";

import { useState } from "react";
import { Button } from "./Button";

/*
 * A licence blob is signed public data, not a secret — it is useless on any
 * instance other than the one it names. So it is safe to show inline, and
 * showing it is what stops a blocked download from blocking a paying customer.
 */
export function LicenceDelivery({
    instanceId,
    blob,
    downloadUrl,
}: {
    instanceId: string;
    blob: string;
    downloadUrl: string;
}) {
    const [copied, setCopied] = useState(false);

    async function copy() {
        await navigator.clipboard.writeText(blob);
        setCopied(true);
        setTimeout(() => setCopied(false), 2000);
    }

    return (
        <section className="grid gap-3">
            <h2 className="text-xl font-extrabold tracking-[-0.03em]">Your licence</h2>
            <div className="flex flex-wrap items-center gap-3">
                <a
                    href={downloadUrl}
                    download={`vantage-${instanceId}.lic`}
                    className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.82rem] font-semibold text-accent-ink"
                >
                    Download licence
                </a>
                <Button variant="line" type="button" onClick={copy}>
                    {copied ? "Copied" : "Copy to clipboard"}
                </Button>
            </div>
            <pre className="overflow-x-auto rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
                {blob}
            </pre>
            <ol className="grid gap-2">
                {[
                    <>Open <code className="rounded-sm bg-accent-wash px-1 font-mono">Settings → Licence</code> on your install.</>,
                    <>Paste the licence into the box and save.</>,
                    <>The page reports <code className="rounded-sm bg-accent-wash px-1 font-mono">Valid</code> straight away — no restart.</>,
                ].map((body, i) => (
                    <li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
                        <span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
                            {i + 1}
                        </span>
                        <span>{body}</span>
                    </li>
                ))}
            </ol>
        </section>
    );
}
```

- [ ] **Step 4: Write RelinkPanel**

```tsx
"use client";

import { useState } from "react";
import { Button } from "./Button";
import { Field } from "./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 RelinkPanel({
    instanceId,
    used,
    max,
    onRelink,
    error,
}: {
    instanceId: string;
    used: number;
    max: number;
    onRelink: (newId: string) => void;
    error?: string;
}) {
    const [open, setOpen] = useState(false);
    const [value, setValue] = useState("");
    const remaining = Math.max(0, max - used);
    const exhausted = remaining === 0;

    return (
        <section className="grid gap-3 border-t border-rule-soft pt-5">
            <h2 className="text-xl font-extrabold tracking-[-0.03em]">Moved to a new server?</h2>
            <p className="text-[0.82rem] text-ink-2">
                Relinking issues a replacement licence for the new install, covering the rest of
                your current term.
            </p>
            {open && !exhausted && (
                <Field
                    label="New instance ID"
                    value={value}
                    onChange={(e) => setValue(e.target.value)}
                    error={error}
                    hint="From Settings → Licence on the new install."
                />
            )}
            <div className="flex flex-wrap items-center gap-3">
                <Button
                    type="button"
                    variant="line"
                    disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
                    onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
                >
                    Relink to a new install
                </Button>
                <span className="text-[0.82rem] text-ink-3">
                    {exhausted
                        ? "You have used every relink for this term — contact support and we will sort it out."
                        : `${remaining} of ${max} relinks left this term`}
                </span>
            </div>
        </section>
    );
}
```

- [ ] **Step 5: Run both and watch them pass**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/LicenceDelivery.test.tsx components/RelinkPanel.test.tsx
```

Expected: `3 passed`.

- [ ] **Step 6: Write the instance detail page**

`adminsite/app/(customer)/instances/[id]/page.tsx`:

```tsx
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { formatDate, licenceState, limitLabel } from "@/lib/format";

export default function InstancePage() {
    const id = String(useParams().id);
    const router = useRouter();
    const qc = useQueryClient();
    const [relinkError, setRelinkError] = useState<string | undefined>();

    const account = useQuery({ queryKey: ["account"], queryFn: api.account });
    const licence = useQuery({ queryKey: ["license", id], queryFn: () => api.license(id), retry: false });

    const relink = useMutation({
        mutationFn: (newId: string) => api.relink(id, newId),
        onSuccess: (lic) => {
            qc.invalidateQueries({ queryKey: ["account"] });
            router.replace(`/instances/${lic.instance_id}`);
        },
        onError: (err) =>
            setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
    });

    if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;

    const instance = account.data?.instances.find((i) => i.instance_id === id);
    if (account.isLoading) return <p className="text-ink-3">Loading…</p>;
    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 <p className="text-ink-2">That instance is not on your account.</p>;
    }

    const lic = licence.data;
    const state = licenceState(lic?.expires_at, Boolean(lic));

    return (
        <div className="grid gap-8">
            <header className="grid gap-3">
                <div className="flex flex-wrap items-center gap-3">
                    <h1 className="text-3xl font-extrabold tracking-[-0.03em]">{instance.name}</h1>
                    <StatePill state={state} />
                </div>
                <p className="font-mono text-[0.82rem] tabular-nums text-ink-3">{instance.instance_id}</p>
            </header>

            {lic ? (
                <>
                    <dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
                        <Fact label="Tier" value={lic.tier.replace("_", " ")} />
                        <Fact label="Expires" value={formatDate(lic.expires_at)} />
                        <Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
                        <Fact label="Features" value={lic.features.join(", ") || "none"} />
                    </dl>

                    {instance.deployment === "self_hosted" && (
                        <LicenceDelivery
                            instanceId={instance.instance_id}
                            blob={lic.blob ?? ""}
                            downloadUrl={api.licenseBlobUrl(instance.instance_id)}
                        />
                    )}

                    {instance.deployment === "self_hosted" && (
                        <RelinkPanel
                            instanceId={instance.instance_id}
                            used={instance.relink_count}
                            max={account.data?.max_relinks ?? 3}
                            error={relinkError}
                            onRelink={(newId) => relink.mutate(newId)}
                        />
                    )}
                </>
            ) : (
                <p className="text-ink-2">No licence has been issued for this instance yet.</p>
            )}
        </div>
    );
}

function Fact({ label, value }: { label: string; value: string }) {
    return (
        <div className="grid gap-1">
            <dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{label}</dt>
            <dd className="font-mono tabular-nums">{value}</dd>
        </div>
    );
}
```

`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<License & { blob?: string }>(`/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: `<LinkForm onLinked={(instanceId: string) => 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<typeof import("@/lib/api")>("@/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(<LinkForm onLinked={vi.fn()} />);
        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(<LinkForm onLinked={onLinked} />);
        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(<LinkForm onLinked={vi.fn()} />);
        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<string | undefined>();
    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 (
        <form onSubmit={submit} className="grid gap-4" noValidate>
            <Field
                label="Instance ID"
                value={id}
                onChange={(e) => setId(e.target.value)}
                error={error}
                hint={
                    <>
                        Find this on your install&rsquo;s{" "}
                        <code className="font-mono">Settings → Licence</code> page, or on the setup
                        screen just after you first sign in. It looks like{" "}
                        <code className="font-mono">6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
                    </>
                }
            />
            <Field
                label="Name it (optional)"
                value={name}
                onChange={(e) => setName(e.target.value)}
                hint="So you can tell it apart from your other installs."
            />
            <Button type="submit" disabled={busy}>
                {busy ? "Linking…" : "Link and issue licence"}
            </Button>
        </form>
    );
}
```

- [ ] **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 (
        <div className="grid max-w-2xl gap-6">
            <header className="grid gap-2">
                <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Link an install</h1>
                <p className="text-ink-2">
                    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.
                </p>
            </header>
            <LinkForm
                onLinked={(instanceId) => {
                    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}`);
                }}
            />
        </div>
    );
}
```

- [ ] **Step 6: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): the self-hosted link flow"
```

---

### Task 9: Billing

**Files:**
- Create: `adminsite/app/(customer)/billing/page.tsx`

- [ ] **Step 1: Write the page**

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { formatDate } from "@/lib/format";

export default function BillingPage() {
    const { data, error, isLoading } = useQuery({
        queryKey: ["subscriptions"],
        queryFn: api.subscriptions,
    });

    if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
    if (isLoading) return <p className="text-ink-3">Loading…</p>;

    return (
        <div className="grid gap-6">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Billing</h1>

            {!data || data.length === 0 ? (
                <p className="text-ink-2">
                    You have no subscriptions. Cloud instances and self-hosted licences are both
                    bought from the pricing page.
                </p>
            ) : (
                <div className="overflow-x-auto rounded border border-rule bg-panel">
                    <table className="w-full border-collapse text-left">
                        <thead>
                            <tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
                                <th className="px-4 py-2.5">Plan</th>
                                <th className="px-4 py-2.5">Term</th>
                                <th className="px-4 py-2.5">Status</th>
                                <th className="px-4 py-2.5">Renews</th>
                            </tr>
                        </thead>
                        <tbody>
                            {data.map((s) => (
                                <tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
                                    <td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
                                    <td className="px-4 py-3">{s.term}</td>
                                    <td className="px-4 py-3">{s.status}</td>
                                    <td className="px-4 py-3 font-mono tabular-nums">
                                        {formatDate(s.current_period_end)}
                                    </td>
                                </tr>
                            ))}
                        </tbody>
                    </table>
                </div>
            )}

            <p className="max-w-xl text-[0.82rem] text-ink-3">
                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.
            </p>
        </div>
    );
}
```

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: `<Queue title count tone items />` 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(
            <Queue
                title="Failed injections"
                tone="expired"
                count={2}
                items={[
                    { label: "Acme Production", href: "/staff/instances/i1", meta: "4h ago" },
                    { label: "Globex EU", href: "/staff/instances/i2", meta: "26m ago" },
                ]}
            />,
        );
        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(<Queue title="Past due" tone="expired" count={0} items={[]} />);
        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 (
        <section className={clsx("grid gap-2 rounded border border-rule border-l-4 bg-panel p-4", TONE[tone])}>
            <h2 className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{title}</h2>
            <p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">{count}</p>
            {items.length === 0 ? (
                <p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
            ) : (
                <ul className="grid gap-1">
                    {items.map((i) => (
                        <li key={i.href} className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2">
                            <Link href={i.href} className="text-accent underline">{i.label}</Link>
                            <span className="tabular-nums">{i.meta}</span>
                        </li>
                    ))}
                </ul>
            )}
        </section>
    );
}
```

- [ ] **Step 4: Run it and watch it pass**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/Queue.test.tsx
```

Expected: `2 passed`.

- [ ] **Step 5: Write the dashboard**

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Queue } from "@/components/Queue";
import { daysRemaining } from "@/lib/format";

const HOURS_48 = 48 * 3600_000;

export default function StaffDashboard() {
    const injection = useQuery({ queryKey: ["injection"], queryFn: api.staff.injectionHealth });
    const expiring = useQuery({
        queryKey: ["instances", "expiring"],
        queryFn: () => api.staff.instances({ expiring: "true" }),
    });
    const pastDue = useQuery({
        queryKey: ["subs", "past_due"],
        queryFn: () => api.staff.subscriptions("past_due"),
    });
    const unlinked = useQuery({
        queryKey: ["instances", "awaiting_link"],
        queryFn: () => api.staff.instances({ status: "awaiting_link" }),
    });

    if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;

    const stale = (unlinked.data ?? []).filter(
        (i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
    );

    return (
        <div className="grid gap-6">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Operations</h1>
            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
                <Queue
                    title="Failed injections"
                    tone="expired"
                    count={injection.data?.count ?? 0}
                    items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
                        label: i.name || i.instance_id,
                        href: `/staff/instances/${i.instance_id}`,
                        meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
                    }))}
                />
                <Queue
                    title="Expiring ≤ 14 days"
                    tone="warn"
                    count={expiring.data?.length ?? 0}
                    items={(expiring.data ?? []).slice(0, 4).map((i) => ({
                        label: i.name || i.instance_id,
                        href: `/staff/instances/${i.instance_id}`,
                        meta: i.tier ?? "",
                    }))}
                />
                <Queue
                    title="Past due"
                    tone="expired"
                    count={pastDue.data?.length ?? 0}
                    items={(pastDue.data ?? []).slice(0, 4).map((s) => ({
                        label: s.instance_id || s.account_id,
                        href: `/staff/accounts/${s.account_id}`,
                        meta: `${daysRemaining(s.current_period_end)}d`,
                    }))}
                />
                <Queue
                    title="Unlinked > 48h"
                    tone="accent"
                    count={stale.length}
                    items={stale.slice(0, 4).map((i) => ({
                        label: i.name || i.instance_id,
                        href: `/staff/accounts/${i.account_id}`,
                        meta: `${Math.floor((Date.now() - new Date(i.created_at).getTime()) / 86_400_000)}d`,
                    }))}
                />
            </div>
        </div>
    );
}
```

- [ ] **Step 6: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): staff operations dashboard"
```

---

### Task 11: Staff accounts and search

Covers spec test 9.

**Files:**
- Create: `adminsite/app/(staff)/staff/accounts/page.tsx`, `adminsite/app/(staff)/staff/accounts/[id]/page.tsx`, `adminsite/app/(staff)/staff/accounts/AccountSearch.tsx`, `adminsite/app/(staff)/staff/accounts/AccountSearch.test.tsx`

- [ ] **Step 1: Write the failing test**

```tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { AccountSearch } from "./AccountSearch";

const accounts = vi.fn();
vi.mock("@/lib/api", async () => {
    const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
    return { ...actual, api: { ...actual.api, staff: { ...actual.api.staff, accounts: (q?: string) => accounts(q) } } };
});

function wrap(ui: React.ReactNode) {
    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

describe("AccountSearch", () => {
    it("finds an account by instance UUID, which is often all a support email has", async () => {
        const uuid = "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d";
        accounts.mockResolvedValue([
            { account_id: "a1", name: "Acme", billing_email: "ops@acme.example", status: "active", created_at: "2026-01-01T00:00:00Z" },
        ]);

        wrap(<AccountSearch />);
        await userEvent.type(screen.getByLabelText(/search/i), uuid);

        await waitFor(() => expect(accounts).toHaveBeenCalledWith(uuid));
        expect(await screen.findByRole("link", { name: /acme/i })).toHaveAttribute(
            "href",
            "/staff/accounts/a1",
        );
    });
});
```

- [ ] **Step 2: Run it and watch it fail**

```bash
sh /tmp/noderun.sh adminsite npx vitest run app/\(staff\)/staff/accounts/AccountSearch.test.tsx
```

Expected: FAIL — cannot resolve `./AccountSearch`.

- [ ] **Step 3: Write AccountSearch**

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api } from "@/lib/api";
import { Field } from "@/components/Field";
import { formatDate } from "@/lib/format";

export function AccountSearch() {
    const [q, setQ] = useState("");
    const { data, isFetching } = useQuery({
        queryKey: ["staff-accounts", q],
        queryFn: () => api.staff.accounts(q || undefined),
    });

    return (
        <div className="grid gap-4">
            <Field
                label="Search"
                value={q}
                onChange={(e) => setQ(e.target.value)}
                hint="Name, email, Paddle customer ID, or an instance UUID."
            />
            <div className="overflow-x-auto rounded border border-rule bg-panel">
                <table className="w-full border-collapse text-left">
                    <thead>
                        <tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
                            <th className="px-4 py-2.5">Account</th>
                            <th className="px-4 py-2.5">Billing email</th>
                            <th className="px-4 py-2.5">Status</th>
                            <th className="px-4 py-2.5">Created</th>
                        </tr>
                    </thead>
                    <tbody>
                        {(data ?? []).map((a) => (
                            <tr key={a.account_id} className="border-b border-rule-soft last:border-0">
                                <td className="px-4 py-3">
                                    <Link href={`/staff/accounts/${a.account_id}`} className="text-accent underline">
                                        {a.name}
                                    </Link>
                                </td>
                                <td className="px-4 py-3 font-mono text-[0.82rem]">{a.billing_email}</td>
                                <td className="px-4 py-3">{a.status}</td>
                                <td className="px-4 py-3 font-mono tabular-nums">{formatDate(a.created_at)}</td>
                            </tr>
                        ))}
                    </tbody>
                </table>
                {!isFetching && (data ?? []).length === 0 && (
                    <p className="px-4 py-6 text-ink-3">
                        No account matches that. Try the instance UUID from the customer&rsquo;s email.
                    </p>
                )}
            </div>
        </div>
    );
}
```

- [ ] **Step 4: Run it and watch it pass**

```bash
sh /tmp/noderun.sh adminsite npx vitest run app/\(staff\)/staff/accounts/AccountSearch.test.tsx
```

Expected: `1 passed`.

- [ ] **Step 5: Write the two pages**

`accounts/page.tsx`:

```tsx
import { AccountSearch } from "./AccountSearch";

export default function AccountsPage() {
    return (
        <div className="grid gap-6">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Accounts</h1>
            <AccountSearch />
        </div>
    );
}
```

`accounts/[id]/page.tsx`:

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";

export default function AccountDetailPage() {
    const id = String(useParams().id);
    const { data, isLoading } = useQuery({
        queryKey: ["staff-account", id],
        queryFn: () => api.staff.account(id),
    });

    if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;

    return (
        <div className="grid gap-8">
            <header className="grid gap-1">
                <h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.account.name}</h1>
                <p className="font-mono text-[0.82rem] text-ink-3">
                    {data.account.billing_email} · {data.account.account_id}
                </p>
            </header>

            <Panel title="Instances">
                <ul className="grid gap-2">
                    {data.instances.map((i) => (
                        <li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
                            <Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
                                {i.name || i.instance_id}
                            </Link>
                            <span className="font-mono text-[0.82rem] text-ink-3">
                                {i.deployment} · {i.tier ?? "no tier"} · {i.status}
                            </span>
                        </li>
                    ))}
                    {data.instances.length === 0 && <li className="text-ink-3">None.</li>}
                </ul>
            </Panel>

            <Panel title="Subscriptions">
                <ul className="grid gap-2">
                    {data.subscriptions.map((s) => (
                        <li key={s.subscription_id} className="flex flex-wrap justify-between gap-2">
                            <span>{s.tier.replace("_", " ")} · {s.term}</span>
                            <span className="font-mono text-[0.82rem] text-ink-3">
                                {s.status} · renews {formatDate(s.current_period_end)}
                            </span>
                        </li>
                    ))}
                    {data.subscriptions.length === 0 && <li className="text-ink-3">None.</li>}
                </ul>
            </Panel>

            <Panel title="People">
                <ul className="grid gap-2">
                    {data.users.map((u) => (
                        <li key={u.user_id} className="flex flex-wrap justify-between gap-2">
                            <span className="font-mono text-[0.82rem]">{u.email}</span>
                            <span className="font-mono text-[0.82rem] text-ink-3">
                                {u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}
                            </span>
                        </li>
                    ))}
                    {data.users.length === 0 && (
                        <li className="text-ink-3">
                            None — this is a cloud account, so its people sign in with their control-plane details.
                        </li>
                    )}
                </ul>
            </Panel>

            <Panel title="Audit">
                <ul className="grid gap-1 font-mono text-[0.82rem]">
                    {data.audit.map((e, n) => (
                        <li key={n} className="flex flex-wrap justify-between gap-2 text-ink-2">
                            <span>{e.action} · {e.actor}</span>
                            <span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
                        </li>
                    ))}
                </ul>
            </Panel>
        </div>
    );
}

function Panel({ title, children }: { title: string; children: React.ReactNode }) {
    return (
        <section className="grid gap-3 rounded border border-rule bg-panel p-5">
            <h2 className="text-xl font-extrabold tracking-[-0.03em]">{title}</h2>
            {children}
        </section>
    );
}
```

- [ ] **Step 6: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): staff accounts, search by UUID and account detail"
```

---

### Task 12: The licence ledger

Covers spec test 10. This is the screen the whole design is built around.

**Files:**
- Create: `adminsite/components/Ledger.tsx`, `adminsite/components/Ledger.test.tsx`, `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, `adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx`

**Interfaces:**
- Produces: `<Ledger licenses={License[]} />`

- [ ] **Step 1: Write the failing test**

```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Ledger } from "./Ledger";
import type { License } from "@/lib/api";

function lic(over: Partial<License>): License {
    return {
        license_id: "l1",
        instance_id: "i1",
        account_id: "a1",
        tier: "professional",
        deployment: "cloud",
        limits: { max_servers: -1, max_secret_groups: -1, max_channels: -1 },
        features: ["console"],
        issued_at: "2026-07-28T19:50:19Z",
        expires_at: "2027-07-28T19:50:19Z",
        issued_by: "staff@example.com",
        reason: "renewal",
        ...over,
    };
}

describe("Ledger", () => {
    it("renders every reason, newest first, and keeps superseded rows visible", () => {
        render(
            <Ledger
                licenses={[
                    lic({ license_id: "a8c1c7aa", reason: "renewal" }),
                    lic({ license_id: "281bd1e3", reason: "manual", superseded_by: "a8c1c7aa", issued_at: "2026-07-25T18:16:05Z" }),
                    lic({ license_id: "0c41ff90", reason: "tier_change", superseded_by: "281bd1e3", issued_at: "2026-07-14T09:04:41Z", tier: "free" }),
                    lic({ license_id: "44ffaa11", reason: "relink", superseded_by: "0c41ff90", issued_at: "2026-07-02T11:00:00Z" }),
                    lic({ license_id: "11aa22bb", reason: "new", superseded_by: "44ffaa11", issued_at: "2026-06-01T09:00:00Z" }),
                ]}
            />,
        );

        for (const label of ["Renewal", "Manual", "Tier change", "Relink", "New"]) {
            expect(screen.getByText(label)).toBeInTheDocument();
        }

        // Nothing is deleted, so nothing is hidden — four are overprinted.
        expect(screen.getAllByText("Superseded")).toHaveLength(4);

        const rows = screen.getAllByRole("listitem");
        expect(rows[0]).toHaveTextContent("Renewal");
        expect(rows[4]).toHaveTextContent("New");
    });

    it("links a superseded entry to what replaced it", () => {
        render(<Ledger licenses={[lic({ license_id: "old", superseded_by: "new1" })]} />);
        expect(screen.getByText(/new1/)).toBeInTheDocument();
    });

    it("says so when an instance has never been licensed", () => {
        render(<Ledger licenses={[]} />);
        expect(screen.getByText(/no licence has ever been issued/i)).toBeInTheDocument();
    });
});
```

- [ ] **Step 2: Run it and watch it fail**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/Ledger.test.tsx
```

Expected: FAIL — cannot resolve `./Ledger`.

- [ ] **Step 3: Write Ledger**

```tsx
import clsx from "clsx";
import type { License } from "@/lib/api";
import { formatDate, formatStamp, limitLabel } from "@/lib/format";

const REASON: Record<License["reason"], string> = {
    new: "New",
    renewal: "Renewal",
    tier_change: "Tier change",
    relink: "Relink",
    manual: "Manual",
};

/*
 * Licences are append-only: a renewal supersedes its predecessor rather than
 * replacing it. So this is a ledger, not a table. Superseded rows stay visible
 * and are overprinted the way a cancelled instrument is — hiding them would
 * destroy the only record of why an instance stopped working on a given date.
 */
export function Ledger({ licenses }: { licenses: License[] }) {
    if (licenses.length === 0) {
        return (
            <p className="text-ink-2">
                No licence has ever been issued for this instance, so it is read-only.
            </p>
        );
    }

    return (
        <ul className="grid">
            {licenses.map((l) => {
                const dead = Boolean(l.superseded_by);
                return (
                    <li
                        key={l.license_id}
                        className={clsx(
                            "grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
                            dead && "text-ink-3",
                        )}
                    >
                        <div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
                            <b className={clsx("block font-sans text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>
                                {formatDate(l.issued_at)}
                            </b>
                            {formatStamp(l.issued_at)}
                        </div>
                        <div className="grid justify-items-start gap-1.5">
                            {dead && (
                                <span className="-rotate-2 rounded-sm border-2 border-archival px-1.5 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.18em] text-archival opacity-75">
                                    Superseded
                                </span>
                            )}
                            <p className="flex flex-wrap items-center gap-2 font-semibold">
                                {l.tier.replace("_", " ")}
                                <span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.09em] text-accent">
                                    {REASON[l.reason]}
                                </span>
                            </p>
                            <p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
                                {l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
                                {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
                                {l.superseded_by && (
                                    <>
                                        {" "}· replaced by{" "}
                                        <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
                                    </>
                                )}
                            </p>
                        </div>
                    </li>
                );
            })}
        </ul>
    );
}
```

- [ ] **Step 4: Run it and watch it pass**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/Ledger.test.tsx
```

Expected: `3 passed`.

- [ ] **Step 5: Write IssuePanel**

```tsx
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type Tier } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";

export function IssuePanel({ instanceId, deployment }: { instanceId: string; deployment: string }) {
    const qc = useQueryClient();
    const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
    const [term, setTerm] = useState("annual");
    const [newId, setNewId] = useState("");
    const [error, setError] = useState<string | undefined>();

    const invalidate = () => qc.invalidateQueries({ queryKey: ["staff-instance", instanceId] });

    const issue = useMutation({
        mutationFn: () => api.staff.issue(instanceId, { tier, term, reason: "manual" }),
        onSuccess: invalidate,
        onError: (e) => setError(e instanceof ApiError ? e.message : "Issue failed."),
    });

    const relink = useMutation({
        mutationFn: () => api.staff.relink(instanceId, newId.trim()),
        onSuccess: invalidate,
        onError: (e) => setError(e instanceof ApiError ? e.message : "Relink failed."),
    });

    return (
        <section className="grid gap-4 border-t border-rule-soft pt-5">
            <div className="flex flex-wrap items-end gap-3">
                <label className="grid gap-1.5">
                    <span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
                    <select
                        value={tier}
                        onChange={(e) => setTier(e.target.value as Tier)}
                        className="rounded border border-rule bg-panel-2 px-2.5 py-2"
                    >
                        <option value="free">Free</option>
                        <option value="professional">Professional</option>
                        <option value="self_hosted">Self Hosted</option>
                    </select>
                </label>
                <label className="grid gap-1.5">
                    <span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
                    <select
                        value={term}
                        onChange={(e) => setTerm(e.target.value)}
                        className="rounded border border-rule bg-panel-2 px-2.5 py-2"
                    >
                        <option value="annual">Annual</option>
                        <option value="monthly">Monthly</option>
                    </select>
                </label>
                <Button type="button" onClick={() => issue.mutate()} disabled={issue.isPending}>
                    {issue.isPending ? "Issuing…" : "Issue licence"}
                </Button>
            </div>

            <div className="flex flex-wrap items-end gap-3">
                <Field
                    label="Relink to instance ID"
                    value={newId}
                    onChange={(e) => setNewId(e.target.value)}
                    hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
                />
                <Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
                    Relink
                </Button>
            </div>

            {error && <p className="text-[0.82rem] text-expired">{error}</p>}
        </section>
    );
}
```

- [ ] **Step 6: Write the instance page**

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { IssuePanel } from "./IssuePanel";

const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
    current: { label: "Control plane holds the current licence", tone: "text-valid" },
    stale: { label: "Control plane holds an older blob — the reconciler will repair it", tone: "text-warn" },
    missing: { label: "No matching instance in the control plane", tone: "text-expired" },
    none_issued: { label: "Nothing issued yet, so nothing to inject", tone: "text-ink-3" },
};

export default function StaffInstancePage() {
    const id = String(useParams().id);
    const { data, isLoading } = useQuery({
        queryKey: ["staff-instance", id],
        queryFn: () => api.staff.instance(id),
        refetchInterval: 30_000,
    });

    if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;

    const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;

    return (
        <div className="grid gap-8">
            <header className="grid gap-2">
                <h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.instance.name || data.instance.instance_id}</h1>
                <p className="font-mono text-[0.82rem] tabular-nums text-ink-3">{data.instance.instance_id}</p>
                <p className="text-[0.82rem]">
                    <Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
                        {data.account.name}
                    </Link>
                    <span className="text-ink-3">
                        {" "}· {data.instance.deployment} · {data.instance.status}
                        {data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
                    </span>
                </p>
                {data.injection.applicable && inj && (
                    <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
                )}
            </header>

            <section className="grid gap-3 rounded border border-rule bg-panel p-5">
                <h2 className="text-xl font-extrabold tracking-[-0.03em]">Licence history</h2>
                <Ledger licenses={data.licenses} />
                <IssuePanel instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
            </section>
        </div>
    );
}
```

- [ ] **Step 7: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): the licence ledger and staff instance actions"
```

---

### Task 13: Staff licences and audit

**Files:**
- Create: `adminsite/app/(staff)/staff/licenses/page.tsx`, `adminsite/app/(staff)/staff/audit/page.tsx`

- [ ] **Step 1: Write the licences page**

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate } from "@/lib/format";

export default function LicensesPage() {
    const [tier, setTier] = useState<"" | Tier>("");
    const [reason, setReason] = useState("");
    const { data } = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });

    // Filtered here rather than server-side: the endpoint caps at 500 rows and
    // staff are narrowing a list they can already see.
    const rows = (data ?? []).filter(
        (l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
    );

    return (
        <div className="grid gap-6">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Licences</h1>
            <div className="flex flex-wrap gap-3">
                <select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
                    <option value="">All tiers</option>
                    <option value="free">Free</option>
                    <option value="professional">Professional</option>
                    <option value="self_hosted">Self Hosted</option>
                </select>
                <select value={reason} onChange={(e) => setReason(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
                    <option value="">All reasons</option>
                    <option value="new">New</option>
                    <option value="renewal">Renewal</option>
                    <option value="tier_change">Tier change</option>
                    <option value="relink">Relink</option>
                    <option value="manual">Manual</option>
                </select>
            </div>
            <div className="overflow-x-auto rounded border border-rule bg-panel">
                <table className="w-full border-collapse text-left">
                    <thead>
                        <tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
                            <th className="px-4 py-2.5">Issued</th>
                            <th className="px-4 py-2.5">Instance</th>
                            <th className="px-4 py-2.5">Tier</th>
                            <th className="px-4 py-2.5">Reason</th>
                            <th className="px-4 py-2.5">Expires</th>
                            <th className="px-4 py-2.5">State</th>
                        </tr>
                    </thead>
                    <tbody>
                        {rows.map((l) => (
                            <tr key={l.license_id} className="border-b border-rule-soft last:border-0">
                                <td className="px-4 py-3 font-mono tabular-nums">{formatDate(l.issued_at)}</td>
                                <td className="px-4 py-3">
                                    <Link href={`/staff/instances/${l.instance_id}`} className="font-mono text-[0.82rem] text-accent underline">
                                        {l.instance_id.slice(0, 8)}
                                    </Link>
                                </td>
                                <td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
                                <td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
                                <td className="px-4 py-3 font-mono tabular-nums">{formatDate(l.expires_at)}</td>
                                <td className="px-4 py-3 text-ink-3">{l.superseded_by ? "superseded" : "current"}</td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        </div>
    );
}
```

- [ ] **Step 2: Write the audit page**

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { Field } from "@/components/Field";

export default function AuditPage() {
    const [filter, setFilter] = useState("");
    const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });

    const rows = (data ?? []).filter((e) =>
        filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true,
    );

    return (
        <div className="grid gap-6">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Audit</h1>
            <Field label="Filter" value={filter} onChange={(e) => setFilter(e.target.value)} hint="Action, actor or target." />
            <ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
                {rows.map((e, n) => (
                    <li key={n} className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]">
                        <span className="tabular-nums text-ink-3">
                            {formatDate(e.created_at)} {formatStamp(e.created_at)}
                        </span>
                        <span className="text-ink-2">
                            <b className="text-ink">{e.action}</b> · {e.actor}
                            {e.target && ` · ${e.target}`}
                            {e.detail && ` · ${e.detail}`}
                        </span>
                    </li>
                ))}
            </ul>
        </div>
    );
}
```

- [ ] **Step 3: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): staff licence history and audit"
```

---

### Task 14: Plans, with both guard rails

Covers spec test 8.

**Files:**
- Create: `adminsite/app/(staff)/staff/plans/page.tsx`, `adminsite/components/ConfirmPlanChange.tsx`, `adminsite/components/ConfirmPlanChange.test.tsx`

- [ ] **Step 1: Write the failing test**

```tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ConfirmPlanChange } from "./ConfirmPlanChange";

const plan = {
    tier: "professional" as const,
    name: "Professional",
    deployment: "cloud" as const,
    limits: { max_servers: -1, max_secret_groups: -1, max_channels: -1 },
    features: ["console", "oidc"],
    active: true,
};

describe("ConfirmPlanChange", () => {
    it("names every change and states that issued licences are unaffected", () => {
        render(
            <ConfirmPlanChange
                plan={plan}
                next={{ ...plan, limits: { ...plan.limits, max_servers: 7 }, features: ["console"] }}
                issuedCount={34}
                onConfirm={vi.fn()}
                onCancel={vi.fn()}
            />,
        );

        expect(screen.getByText(/max_servers/)).toBeInTheDocument();
        expect(screen.getByText(/unlimited/)).toBeInTheDocument();
        expect(screen.getByText(/\b7\b/)).toBeInTheDocument();
        expect(screen.getByText(/oidc/)).toBeInTheDocument();
        // The wording the spec asks for, verbatim in spirit: existing licences
        // keep what they were signed with.
        expect(screen.getByText(/34 licences already issued keep what they were signed with/i)).toBeInTheDocument();
    });

    it("does nothing until confirmed", async () => {
        const onConfirm = vi.fn();
        render(
            <ConfirmPlanChange
                plan={plan}
                next={{ ...plan, features: [] }}
                issuedCount={0}
                onConfirm={onConfirm}
                onCancel={vi.fn()}
            />,
        );
        expect(onConfirm).not.toHaveBeenCalled();
        await userEvent.click(screen.getByRole("button", { name: /change plan/i }));
        expect(onConfirm).toHaveBeenCalledOnce();
    });
});
```

- [ ] **Step 2: Run it and watch it fail**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/ConfirmPlanChange.test.tsx
```

Expected: FAIL — cannot resolve `./ConfirmPlanChange`.

- [ ] **Step 3: Write ConfirmPlanChange**

```tsx
import type { Plan } from "@/lib/api";
import { limitLabel } from "@/lib/format";
import { Button } from "./Button";

/*
 * Editing a plan changes what every future customer gets, so the confirmation
 * names each field rather than asking "are you sure". Existing licences
 * snapshotted their plan at issue time and are genuinely unaffected — saying so
 * is what stops a well-meaning edit being followed by a panicked reissue.
 */
export function ConfirmPlanChange({
    plan,
    next,
    issuedCount,
    onConfirm,
    onCancel,
}: {
    plan: Plan;
    next: Plan;
    issuedCount: number;
    onConfirm: () => void;
    onCancel: () => void;
}) {
    const rows: { field: string; was: string; now: string }[] = [];
    if (plan.limits.max_servers !== next.limits.max_servers)
        rows.push({ field: "max_servers", was: limitLabel(plan.limits.max_servers), now: limitLabel(next.limits.max_servers) });
    if (plan.limits.max_secret_groups !== next.limits.max_secret_groups)
        rows.push({ field: "max_secret_groups", was: limitLabel(plan.limits.max_secret_groups), now: limitLabel(next.limits.max_secret_groups) });
    if (plan.limits.max_channels !== next.limits.max_channels)
        rows.push({ field: "max_channels", was: limitLabel(plan.limits.max_channels), now: limitLabel(next.limits.max_channels) });
    if (plan.features.join(",") !== next.features.join(","))
        rows.push({ field: "features", was: plan.features.join(", ") || "none", now: next.features.join(", ") || "none" });

    return (
        <div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
            <h2 className="text-xl font-extrabold tracking-[-0.03em]">Change what {plan.name} grants?</h2>
            <ul className="grid gap-1 font-mono text-[0.82rem]">
                {rows.map((r) => (
                    <li key={r.field} className="flex flex-wrap gap-2">
                        <span className="text-ink-3">{r.field}</span>
                        <span className="text-ink-3 line-through">{r.was}</span>
                        <span className="font-semibold text-ink">→ {r.now}</span>
                    </li>
                ))}
            </ul>
            <p className="text-[0.82rem] text-ink-3">
                This applies to licences issued from now on. The {issuedCount} licences already
                issued keep what they were signed with until each is reissued.
            </p>
            <div className="flex flex-wrap gap-3">
                <Button type="button" onClick={onConfirm}>Change plan</Button>
                <Button type="button" variant="line" onClick={onCancel}>Keep as is</Button>
            </div>
        </div>
    );
}
```

- [ ] **Step 4: Run it and watch it pass**

```bash
sh /tmp/noderun.sh adminsite npx vitest run components/ConfirmPlanChange.test.tsx
```

Expected: `2 passed`.

- [ ] **Step 5: Write the plans page**

```tsx
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Plan } from "@/lib/api";
import { Button } from "@/components/Button";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { limitLabel } from "@/lib/format";

export default function PlansPage() {
    const qc = useQueryClient();
    const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
    const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
    const [draft, setDraft] = useState<Plan | null>(null);

    const save = useMutation({
        mutationFn: (p: Plan) =>
            api.staff.updatePlan(p.tier, {
                name: p.name,
                limits: p.limits,
                features: p.features,
                paddle_product_id: p.paddle_product_id,
                paddle_price_ids: p.paddle_price_ids,
                active: p.active,
            }),
        onSuccess: () => {
            qc.invalidateQueries({ queryKey: ["plans"] });
            setDraft(null);
        },
    });

    const original = plans.data?.find((p) => p.tier === draft?.tier);

    return (
        <div className="grid gap-6">
            <h1 className="text-3xl font-extrabold tracking-[-0.03em]">Plans</h1>

            {draft && original && (
                <ConfirmPlanChange
                    plan={original}
                    next={draft}
                    issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier).length}
                    onConfirm={() => save.mutate(draft)}
                    onCancel={() => setDraft(null)}
                />
            )}

            <div className="grid gap-4 lg:grid-cols-3">
                {(plans.data ?? []).map((p) => (
                    <section key={p.tier} className="grid gap-3 rounded border border-rule bg-panel p-5">
                        <h2 className="text-xl font-extrabold tracking-[-0.03em]">{p.name}</h2>
                        <dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
                            <div className="flex justify-between gap-2"><dt>servers</dt><dd>{limitLabel(p.limits.max_servers)}</dd></div>
                            <div className="flex justify-between gap-2"><dt>secret groups</dt><dd>{limitLabel(p.limits.max_secret_groups)}</dd></div>
                            <div className="flex justify-between gap-2"><dt>channels</dt><dd>{limitLabel(p.limits.max_channels)}</dd></div>
                            <div className="flex justify-between gap-2"><dt>features</dt><dd>{p.features.join(", ") || "none"}</dd></div>
                        </dl>

                        {/* Guard rail two: deployment is shown, never edited. */}
                        <p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
                            <span aria-hidden="true">🔒</span>
                            Deployment is fixed at <b className="font-mono">{p.deployment}</b>. Moving a
                            tier between cloud and self-hosted is a code change, not a form field.
                        </p>

                        <div className="flex flex-wrap gap-2">
                            <Button
                                type="button"
                                variant="line"
                                onClick={() =>
                                    setDraft({ ...p, limits: { ...p.limits, max_servers: 7 } })
                                }
                            >
                                Cap servers at 7
                            </Button>
                            <Button
                                type="button"
                                variant="line"
                                onClick={() => setDraft({ ...p, features: p.features.filter((f) => f !== "oidc") })}
                            >
                                Remove OIDC
                            </Button>
                        </div>
                    </section>
                ))}
            </div>
        </div>
    );
}
```

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` |
