From 8708bd9498861c5c1d9ff45aee26294502f96b44 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 20:22:00 +0100 Subject: [PATCH] docs(plan): add the admin site implementation plan Sixteen tasks: two that close gaps in admin's API, thirteen frontend, one verification pass. Auditing spec 4's screens against what spec 3 actually shipped turned up eight things the UI needs and the backend does not expose -- including no GET /auth/me at all, which no route guard can work without, and no signup endpoint for the self-hosted flow the spec's app/signup/ implies. Those are tasks 1 and 2 rather than frontend improvisation. Records the approved design direction as fixed constraints: light ground because web/ is dark-locked and telling the two apart is what stops a Reissue landing in the wrong tab, petrol accent because green, amber and red are spoken for by licence state and indigo belongs to web/, and the licence ledger as the one screen that earns ornament. Serves vantage-hq.hostxtra.co.uk on 3004 -- 3002 is the marketing site now, and the host stays outside *.vantage.hostxtra.co.uk because that namespace is per-tenant instance subdomains. Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-25-admin-site.md | 4093 +++++++++++++++++ 1 file changed, 4093 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-admin-site.md diff --git a/docs/superpowers/plans/2026-07-25-admin-site.md b/docs/superpowers/plans/2026-07-25-admin-site.md new file mode 100644 index 0000000..d2401ed --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-admin-site.md @@ -0,0 +1,4093 @@ +# Admin Site Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `adminsite/`, a fifth Next.js app serving staff and customers from one codebase, so a customer can buy, link, download and renew a licence without contacting anyone, and staff can answer "why did this stop working" on one screen. + +**Architecture:** Route groups do the guarding — `(customer)` and `(staff)/staff` share auth plumbing and a component library but no screens. The browser talks to admin directly (build-time `NEXT_PUBLIC_ADMIN_API_URL`, CORS, `credentials: "include"`), the way `site/` talks to sitesvc, not through a Next rewrite proxy the way `web/` talks to the control plane. Tasks 1 and 2 add the admin endpoints spec 4 needs and spec 3 did not build; every frontend task after that consumes a real endpoint. + +**Tech Stack:** Next.js 16.2.9 App Router, React 18.3.1, Tailwind 3.4, TanStack Query 5, TypeScript 5.5, Vitest + React Testing Library (new to this repo), Go 1.26 for tasks 1–2. + +## Global Constraints + +- **Design tokens are fixed by the approved direction.** Light ground primary with full dark support, petrol accent `#0d5f6e` / `#5cc6d8`, semantic `--valid #2f8a60` / `--warn #a4761b` / `--expired #c6462f` carried over from `site/`. Semantic hues are **never** reused as an accent, and the accent is never green, amber, red or indigo — indigo belongs to `web/`. +- **Type roles:** serif display (`"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`) for page titles, counts and the wordmark only; system sans for UI; mono with `tabular-nums` for every UUID, timestamp, blob and limit. +- **State never reads by colour alone.** Every licence state renders as a stripe, a shaped-and-labelled pill, and copy. +- **Served at `vantage-hq.hostxtra.co.uk`, published on `3004`.** Spec 4 said 3002, but `deploy/docker-compose.site.yml` now maps `site` to `3002:3000`. Container port stays `3000`. Note the host is deliberately *not* under `*.vantage.hostxtra.co.uk`: that namespace is per-tenant instance subdomains, and the control plane's `APP_ROOT_LABEL` guard resolves an org from the label before `vantage`. A host like `hq.vantage.hostxtra.co.uk` would look like a tenant slug called `hq`. +- **`ADMIN_ORIGIN` must contain `https://vantage-hq.hostxtra.co.uk`** exactly — scheme included, no trailing slash. Admin echoes only origins on that list, so a mismatch blocks every browser request while curl from the server keeps working, which is what makes it confusing to diagnose. +- **`NEXT_PUBLIC_ADMIN_API_URL` is baked in at build time** and must be browser-reachable *and* present in admin's `ADMIN_ORIGIN`. When unset or unreachable the app renders an explicit not-connected state naming the variable. This is the single most common deployment failure in this repo. +- **Cookies work cross-origin only because both hosts share a registrable domain.** `admin_session` is `SameSite=Lax`, which browsers send on same-*site* subresource requests — and same-site is judged on the registrable domain, not the origin. `vantage-hq.hostxtra.co.uk` and admin's own host are both under `hostxtra.co.uk`, so a `fetch` with `credentials: "include"` carries the cookie. **Moving either host to a different registrable domain breaks every authenticated request** and would need `SameSite=None; Secure`, which is out of scope here. +- **Route-group guards are UX, not security.** The real protection is admin's `RequireStaff`/`RequireCustomer` plus 404-not-403 scoping. Never rely on the client guard alone. +- **Four-space indent, no semicolon-free style** — match `web/` and `site/`. +- Admin **must never** gain a write path into the control plane beyond the three licence fields on `instances`. Tasks 1–2 add reads and admin-database writes only. +- Every mutating admin endpoint added in tasks 1–2 writes an audit entry. +- Run `go mod tidy` with `GOWORK=off`; `MSYS_NO_PATHCONV=1` on every `docker` call. Node commands run in a container: + ```sh + # /tmp/noderun.sh + DIR="$1"; shift + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \ + -w "/src/$DIR" node:26-alpine "$@" + ``` + +## Context this plan inherits + +Spec 3 shipped and is verified. Its API is the contract here. **Spec 4 asks for eight things spec 3 does not expose**, found by auditing every screen in the spec against `admin/internal/api/routes.go`: + +| Spec 4 needs | Status after spec 3 | Added by | +|---|---|---| +| Route guards knowing who is signed in | no `GET /auth/me` at all | Task 1 | +| `app/signup/` self-hosted account creation | no signup endpoint at all | Task 1 | +| "2 of 3 relinks remaining" | `relink_count` exposed, cap is a Go constant | Task 1 | +| Dashboard "subscriptions past_due" | no staff subscriptions endpoint | Task 2 | +| Account search by instance UUID and Paddle ID | `q` matches name and billing_email only | Task 2 | +| Account detail: subscriptions, customer users, audit | returns account + instances only | Task 2 | +| Staff instance detail with licence history | no `GET /api/staff/instances/:id` | Task 2 | +| Audit filtered to one account | global list only | Task 2 | + +Paddle portal deep-links are spec 5's. Until then the billing screen renders the subscription list and says billing changes go through support, rather than linking nowhere. + +--- + +## File Structure + +**Created — Go (tasks 1–2):** nothing new; all edits land in existing files. + +**Created — app:** + +| Path | Responsibility | +|---|---| +| `adminsite/package.json`, `tsconfig.json`, `next.config.ts`, `postcss.config.js`, `tailwind.config.ts` | app scaffold, tokens wired to CSS variables | +| `adminsite/Dockerfile`, `.dockerignore` | image, built from `adminsite/` like `web/` | +| `adminsite/vitest.config.ts`, `test/setup.ts` | the repo's first frontend test harness | +| `adminsite/app/globals.css` | the token system, light and dark | +| `adminsite/app/layout.tsx` | root shell, providers, environment badge | +| `adminsite/app/login/page.tsx`, `signup/page.tsx`, `verify/page.tsx` | unauthed routes | +| `adminsite/app/(customer)/layout.tsx`, `page.tsx` | customer guard + nav, account overview | +| `adminsite/app/(customer)/instances/[id]/page.tsx` | licence, download, paste steps, relink | +| `adminsite/app/(customer)/instances/link/page.tsx` | self-hosted activation | +| `adminsite/app/(customer)/billing/page.tsx` | subscription list | +| `adminsite/app/(staff)/staff/layout.tsx`, `page.tsx` | staff guard + nav, operations queue | +| `adminsite/app/(staff)/staff/accounts/page.tsx`, `accounts/[id]/page.tsx` | search, account detail | +| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the licence ledger | +| `adminsite/app/(staff)/staff/licenses/page.tsx`, `audit/page.tsx` | global history | +| `adminsite/app/(staff)/staff/plans/page.tsx` | plan editing with guard rails | +| `adminsite/lib/api.ts` | typed client, `NotConnected`, `ApiError` | +| `adminsite/lib/session.ts` | `useSession`, `useRequireKind` | +| `adminsite/lib/query-client.ts` | TanStack config, mirrors `web/` | +| `adminsite/lib/format.ts` | dates, days-remaining, licence state derivation | +| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Guilloche`, `Field`, `Button`, `ConfirmPlanChange` | + +**Modified:** `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/auth/customer.go`, `admin/internal/models/models.go`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`. + +--- + +### Task 1: Session, signup and the relink cap (admin backend) + +Without `GET /auth/me` no route guard can know who is signed in, and without signup the spec's `app/signup/` has nothing to post to. Both are backend gaps, so they land before any frontend work. + +**Files:** +- Modify: `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/auth/customer.go` + +**Interfaces:** +- Consumes: `auth.Current`, `auth.CreateCustomerUser`, `models.MaxRelinksPerTerm` +- Produces: + - `GET /auth/me` → `200 {"kind","email","account_id"}` or `401` + - `POST /auth/signup` → `201 {"pending":true}` + - `GET /api/account` gains `"max_relinks": 3` + +- [ ] **Step 1: Add the session probe** + +Append to `admin/internal/api/customer.go`: + +```go +// getMe reports who the caller is, for route guards in the UI. +// +// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a +// truthful 401 to redirect on, not an error page. It reveals nothing a caller +// does not already possess, because it only ever describes their own cookie. +func getMe(c *gin.Context) { + s := auth.Load(c) + if s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "kind": s.Kind, + "email": s.Email, + "account_id": s.AccountID, + }) +} +``` + +- [ ] **Step 2: Export the session loader** + +`load` in `admin/internal/auth/middleware.go` is unexported. Rename it to `Load` and update its two call sites in the same file: + +```go +// Load returns the caller's session, or nil. Exported because the session probe +// in api/ needs to read a session without requiring one. +func Load(c *gin.Context) *Session { +``` + +Both `RequireStaff` and `RequireCustomer` call `s := Load(c)`. + +- [ ] **Step 3: Add signup** + +Append to `admin/internal/auth/customer.go`: + +```go +// HandleSignup creates a self-hosted customer: an account, an unverified user, +// and a verification email. +// +// Nothing is usable until the emailed link is opened, the same rule sitesvc +// already proves — so an address nobody controls cannot occupy an email or +// produce an account that can sign in. +func HandleSignup(c *gin.Context) { + var body struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Website string `json:"website"` // honeypot; real users never fill it + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"}) + return + } + + // Honeypot: answer exactly as success so a bot learns nothing. + if strings.TrimSpace(body.Website) != "" { + c.JSON(http.StatusCreated, gin.H{"pending": true}) + return + } + + email := strings.ToLower(strings.TrimSpace(body.Email)) + ctx := c.Request.Context() + + if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"}) + return + } + if !allowAttempt("signup:"+email, c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 { + // Same response as success. Telling a stranger the address is taken + // confirms who has an account here. + c.JSON(http.StatusCreated, gin.H{"pending": true}) + return + } + + acct := models.Account{ + AccountID: uuid.NewString(), + Name: strings.TrimSpace(body.Name), + BillingEmail: email, + Status: models.AccountActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"}) + return + } + + if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil { + // Roll the account back rather than strand one with no owner. + _, _ = db.Admin("accounts").DeleteOne(ctx, bson.M{"account_id": acct.AccountID}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()}) + c.JSON(http.StatusCreated, gin.H{"pending": true}) +} +``` + +Add `"time"` and `"github.com/google/uuid"` to that file's imports. + +- [ ] **Step 4: Expose the relink cap** + +In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`: + +```go + c.JSON(http.StatusOK, gin.H{ + "account": acct, + "instances": instances, + // Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a + // second source of truth for a rule the backend enforces. + "max_relinks": models.MaxRelinksPerTerm, + }) +``` + +- [ ] **Step 5: Route them** + +In `admin/internal/api/routes.go`, below the existing auth routes: + +```go + r.GET("/auth/me", getMe) + r.POST("/auth/signup", auth.HandleSignup) +``` + +- [ ] **Step 6: Build** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +``` + +Expected: no output. + +- [ ] **Step 7: Confirm the probe and signup by hand** + +Start admin as in the spec-3 plan Task 11 steps 2–4, then: + +```bash +curl -s -o /dev/null -w "unauthed me: %{http_code}\n" localhost:8083/auth/me +curl -s -X POST localhost:8083/auth/staff/login -H 'Content-Type: application/json' \ + -d '{"email":"staff@example.com","password":"correcthorsebattery"}' -c /tmp/s.txt +curl -s localhost:8083/auth/me -b /tmp/s.txt +curl -s -o /dev/null -w "honeypot: %{http_code}\n" -X POST localhost:8083/auth/signup \ + -H 'Content-Type: application/json' \ + -d '{"name":"Bot","email":"bot@example.com","password":"aaaaaaaaaaaa","website":"x"}' +``` + +Expected: `401`; then `{"kind":"staff","email":"staff@example.com","account_id":""}`; then `201` with **no** account created (confirm with `db.accounts.countDocuments({billing_email:"bot@example.com"})` returning `0`). + +- [ ] **Step 8: Commit** + +```bash +git add admin/ +git commit -m "feat(admin): session probe, self-hosted signup and the relink cap" +``` + +--- + +### Task 2: Staff reads spec 4 needs (admin backend) + +**Files:** +- Modify: `admin/internal/api/staff.go`, `admin/internal/api/routes.go` + +**Interfaces:** +- Produces: + - `GET /api/staff/subscriptions?status=past_due` → `[]Subscription` + - `GET /api/staff/instances/:id` → `{instance, account, licenses, injection}` + - `GET /api/staff/audit?account_id=` filtered + - `GET /api/staff/accounts?q=` also matching `paddle_customer_id` and an instance UUID + - `GET /api/staff/accounts/:id` also returning `subscriptions`, `users`, `audit` + +- [ ] **Step 1: Search accounts by Paddle ID and instance UUID** + +In `staffListAccounts`, replace the `if q := c.Query("q"); q != ""` block: + +```go + if q := c.Query("q"); q != "" { + or := []bson.M{ + {"name": bson.M{"$regex": q, "$options": "i"}}, + {"billing_email": bson.M{"$regex": q, "$options": "i"}}, + {"paddle_customer_id": q}, + } + // A support email often contains an instance UUID and nothing else, so + // resolve that to its owning account rather than returning nothing. + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(c.Request.Context(), + bson.M{"instance_id": q}).Decode(&inst); err == nil { + or = append(or, bson.M{"account_id": inst.AccountID}) + } + filter["$or"] = or + } +``` + +- [ ] **Step 2: Fill out account detail** + +Replace the body of `staffGetAccount` after the account lookup: + +```go + instances := []models.Instance{} + if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil { + _ = cur.All(ctx, &instances) + } + subs := []models.Subscription{} + if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil { + _ = cur.All(ctx, &subs) + } + users := []models.CustomerUser{} + if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil { + _ = cur.All(ctx, &users) + } + entries := []models.AuditEntry{} + if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID}, + options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil { + _ = cur.All(ctx, &entries) + } + + c.JSON(http.StatusOK, gin.H{ + "account": acct, + "instances": instances, + "subscriptions": subs, + "users": users, + "audit": entries, + }) +``` + +`CustomerUser.PasswordHash` and both verify-token fields are `json:"-"`, so no secret leaves here. + +- [ ] **Step 3: Add staff instance detail** + +Append to `admin/internal/api/staff.go`: + +```go +// staffGetInstance is the "why did this stop working" screen's data: one +// instance, its account, its whole licence history newest first, and whether +// the control plane currently holds what we think it holds. +func staffGetInstance(c *gin.Context) { + ctx := c.Request.Context() + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + var acct models.Account + _ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct) + + lics := []models.License{} + if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID}, + options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil { + _ = cur.All(ctx, &lics) + } + + // Injection state is only meaningful for cloud. For self-hosted the + // customer holds the blob and there is nothing for us to have written. + injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud} + if inst.Deployment == license.DeploymentCloud { + var remote sharedmodels.Instance + err := db.Control("instances").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID}).Decode(&remote) + switch { + case err != nil: + injection["state"] = "missing" + case inst.CurrentLicense == "": + injection["state"] = "none_issued" + default: + var current models.License + if db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(¤t) == nil && + remote.LicenseBlob == current.Blob { + injection["state"] = "current" + } else { + injection["state"] = "stale" + } + } + injection["failed_at"] = inst.InjectFailedAt + } + + c.JSON(http.StatusOK, gin.H{ + "instance": inst, "account": acct, "licenses": lics, "injection": injection, + }) +} + +// staffListSubscriptions backs the past-due queue on the dashboard. +func staffListSubscriptions(c *gin.Context) { + filter := bson.M{} + if v := c.Query("status"); v != "" { + filter["status"] = v + } + if v := c.Query("account_id"); v != "" { + filter["account_id"] = v + } + cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter, + options.Find().SetLimit(500)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + subs := []models.Subscription{} + if err := cur.All(c.Request.Context(), &subs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, subs) +} +``` + +- [ ] **Step 4: Filter audit by account** + +In `staffAudit`, replace `bson.M{}` with a filter: + +```go + filter := bson.M{} + if v := c.Query("account_id"); v != "" { + filter["account_id"] = v + } + cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter, + options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}})) +``` + +- [ ] **Step 5: Route them** + +In the `staff` group in `admin/internal/api/routes.go`: + +```go + staff.GET("/instances/:id", staffGetInstance) + staff.GET("/subscriptions", staffListSubscriptions) +``` + +- [ ] **Step 6: Build and confirm the write surface is unchanged** + +```bash +sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... +grep -rn 'Control("' admin/internal/ --include=*.go | grep -v "db.go" +``` + +Expected: build clean; exactly one `UpdateOne` on `instances` in `inject.go`, and reads only elsewhere. `staffGetInstance` adds a read, never a write. + +- [ ] **Step 7: Confirm search by UUID** + +With the spec-3 verification stack running and an instance adopted: + +```bash +curl -s "localhost:8083/api/staff/accounts?q=6a0fe3f0-49d2-4aa1-967c-a3094b200b5d" -b /tmp/s.txt +curl -s localhost:8083/api/staff/instances/6a0fe3f0-49d2-4aa1-967c-a3094b200b5d -b /tmp/s.txt | head -c 200 +``` + +Expected: the owning account, and an instance payload whose `injection.state` is `current`. + +- [ ] **Step 8: Commit** + +```bash +git add admin/ +git commit -m "feat(admin): staff instance detail, subscriptions and richer search" +``` + +--- + +### Task 3: App scaffold and tokens + +**Files:** +- Create: `adminsite/package.json`, `adminsite/tsconfig.json`, `adminsite/next.config.ts`, `adminsite/postcss.config.js`, `adminsite/tailwind.config.ts`, `adminsite/app/globals.css`, `adminsite/app/layout.tsx`, `adminsite/.gitignore` + +**Interfaces:** +- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-display font-sans font-mono` + +- [ ] **Step 1: package.json** + +```json +{ + "name": "vantage-adminsite", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "next": "16.2.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@tanstack/react-query": "^5.51.1", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@types/node": "^20.14.11", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@testing-library/jest-dom": "^6.4.8", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^9.0.0", + "eslint-config-next": "16.2.9", + "jsdom": "^24.1.1", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vitest": "^2.0.5" + } +} +``` + +- [ ] **Step 2: tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "types": ["vitest/globals", "@testing-library/jest-dom"], + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +- [ ] **Step 3: next.config.ts** + +```ts +import type { NextConfig } from "next"; + +/* + * Unlike web/, this app does NOT proxy /api through a rewrite. The browser + * calls admin directly, so NEXT_PUBLIC_ADMIN_API_URL must be reachable from the + * browser and must appear in admin's ADMIN_ORIGIN. lib/api.ts renders an + * explicit not-connected state when it is not. + */ +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; +``` + +- [ ] **Step 4: postcss.config.js and tailwind.config.ts** + +`postcss.config.js`: + +```js +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +``` + +`tailwind.config.ts` — colours point at CSS variables so one token set serves both themes: + +```ts +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + ground: "var(--ground)", + panel: "var(--panel)", + "panel-2": "var(--panel-2)", + ink: "var(--ink)", + "ink-2": "var(--ink-2)", + "ink-3": "var(--ink-3)", + rule: "var(--rule)", + "rule-soft": "var(--rule-soft)", + accent: "var(--accent)", + "accent-ink": "var(--accent-ink)", + "accent-wash": "var(--accent-wash)", + valid: "var(--valid)", + warn: "var(--warn)", + expired: "var(--expired)", + archival: "var(--archival)", + }, + fontFamily: { + display: ["Iowan Old Style", "Palatino Linotype", "Palatino", "Book Antiqua", "Georgia", "serif"], + sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"], + mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"], + }, + borderRadius: { DEFAULT: "3px" }, + }, + }, + plugins: [], +}; + +export default config; +``` + +- [ ] **Step 5: app/globals.css** + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* + * Admin console tokens. Petrol accent, deliberately clear of indigo (web/) and + * of the three semantic hues this app says constantly. Light is home because + * web/ is locked to dark, and telling the two apps apart at a glance is what + * stops a Reissue landing in the wrong tab. + */ +:root { + color-scheme: light dark; + + --ground: #eaeff0; + --panel: #ffffff; + --panel-2: #f2f6f7; + --ink: #0f2429; + --ink-2: #43606a; + --ink-3: #6d868e; + --rule: #ccd9dc; + --rule-soft: #dfe8ea; + --accent: #0d5f6e; + --accent-ink: #ffffff; + --accent-wash: rgba(13, 95, 110, 0.07); + + /* Semantic, shared with site/ so all three apps agree. Never an accent. */ + --valid: #2f8a60; + --warn: #a4761b; + --expired: #c6462f; + --archival: #7d4a45; +} + +@media (prefers-color-scheme: dark) { + :root { + --ground: #081a1f; + --panel: #0e262d; + --panel-2: #123039; + --ink: #e2eff1; + --ink-2: #9fbcc3; + --ink-3: #74949c; + --rule: #1e454f; + --rule-soft: #17363f; + --accent: #5cc6d8; + --accent-ink: #04171c; + --accent-wash: rgba(92, 198, 216, 0.1); + --valid: #4fb484; + --warn: #d6a63f; + --expired: #e2705a; + --archival: #c08a84; + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--ground); + color: var(--ink); + -webkit-font-smoothing: antialiased; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} +``` + +- [ ] **Step 6: app/layout.tsx** + +```tsx +import type { Metadata } from "next"; +import "./globals.css"; +import { Providers } from "@/components/Providers"; +import { EnvBadge } from "@/components/EnvBadge"; + +export const metadata: Metadata = { + title: "Vantage Licensing", + description: "Licences, instances and billing for Vantage.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+
+ + Vantage + + Licensing + + + +
+
+ {children} + + + ); +} +``` + +- [ ] **Step 7: .gitignore** + +``` +node_modules +.next +next-env.d.ts +.env +*.lic +``` + +- [ ] **Step 8: Install and confirm it builds later** + +```bash +sh /tmp/noderun.sh adminsite npm install +``` + +Expected: a lockfile appears. `npm run build` cannot pass until Task 5 creates the pages it imports; that is Task 5's verification. + +- [ ] **Step 9: Commit** + +```bash +git add adminsite/ +git commit -m "feat(adminsite): scaffold and the approved token system" +``` + +--- + +### Task 4: Test harness, API client and the not-connected state + +The repo has no frontend test setup. This task creates it and proves it on spec 4's test 6. + +**Files:** +- Create: `adminsite/vitest.config.ts`, `adminsite/test/setup.ts`, `adminsite/lib/api.ts`, `adminsite/lib/query-client.ts`, `adminsite/lib/format.ts`, `adminsite/components/Providers.tsx`, `adminsite/components/NotConnected.tsx`, `adminsite/components/NotConnected.test.tsx` + +**Interfaces:** +- Produces: + - `NotConnected` (error class), `ApiError` with `.status` + - `api.me()`, `api.login()`, `api.staffLogin()`, `api.logout()`, `api.signup()`, `api.verify()` + - `api.account()`, `api.link()`, `api.relink()`, `api.license()`, `api.licenseBlobUrl()`, `api.subscriptions()` + - `api.staff.*` — `accounts`, `account`, `instances`, `instance`, `issue`, `relink`, `licenses`, `plans`, `updatePlan`, `audit`, `injectionHealth`, `subscriptions` + - `` + - `licenceState(expiresAt: string | undefined, hasLicence: boolean): "valid" | "warn" | "expired" | "none"`, `daysRemaining(iso: string): number`, `formatDate(iso: string): string` + +- [ ] **Step 1: vitest.config.ts and test/setup.ts** + +```ts +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { alias: { "@": resolve(__dirname, ".") } }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["**/*.test.{ts,tsx}"], + }, +}); +``` + +`test/setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +``` + +- [ ] **Step 2: Write the failing test for the not-connected state** + +`adminsite/components/NotConnected.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { NotConnectedPanel } from "./NotConnected"; + +describe("NotConnectedPanel", () => { + it("names the variable that is wrong and where it is set", () => { + render(); + + expect(screen.getByRole("heading")).toHaveTextContent( + /not connected to the licensing service/i, + ); + expect(screen.getByText(/ADMIN_API_URL/)).toBeInTheDocument(); + expect(screen.getByText(/https:\/\/admin\.example\.com/)).toBeInTheDocument(); + // The two mistakes that actually cause this, both named. + expect(screen.getByText(/reachable from your browser/i)).toBeInTheDocument(); + expect(screen.getByText(/ADMIN_ORIGIN/)).toBeInTheDocument(); + }); + + it("says the value is missing when no URL was baked in", () => { + render(); + expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run it and watch it fail** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run components/NotConnected.test.tsx +``` + +Expected: FAIL — `Failed to resolve import "./NotConnected"`. + +- [ ] **Step 4: Write the component** + +`adminsite/components/NotConnected.tsx`: + +```tsx +/* + * The deployment failure this repo makes most often, made legible. It names the + * variable, the value baked in, and both reasons it fails — unreachable from + * the browser, or missing from admin's ADMIN_ORIGIN. + */ +export function NotConnectedPanel({ url }: { url: string }) { + return ( +
+

+ Not connected to the licensing service +

+ {url ? ( +

+ This build points at ADMIN_API_URL ={" "} + {url}, which did not respond. +

+ ) : ( +

+ ADMIN_API_URL was not set when this + app was built, so there is nowhere to send requests. +

+ )} +

+ The value is baked in when the image is built and has to be reachable from your + browser, not just from the server. It also has to appear in the licensing + service’s ADMIN_ORIGIN, or the browser + blocks every request. +

+
+ ); +} +``` + +- [ ] **Step 5: Run it and watch it pass** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run components/NotConnected.test.tsx +``` + +Expected: `2 passed`. + +- [ ] **Step 6: Write the API client** + +`adminsite/lib/api.ts`: + +```ts +/* + * The typed client for the licensing service. + * + * The browser calls admin directly, so every request carries credentials and + * every failure mode is one of three: the API is unreachable (NotConnected), + * the caller is not signed in (ApiError 401, which layouts redirect on), or the + * request was refused (ApiError with the backend's own message, which is + * customer-facing and should be shown verbatim). + */ + +export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, ""); + +export class NotConnected extends Error { + constructor() { + super("not connected"); + this.name = "NotConnected"; + } +} + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +async function req(path: string, init?: RequestInit): Promise { + if (!API_BASE) throw new NotConnected(); + + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { + ...init, + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }); + } catch { + // Network-level failure, DNS, or a CORS preflight the browser refused. + throw new NotConnected(); + } + + if (res.status === 204) return undefined as T; + + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`); + } + return body as T; +} + +const post = (path: string, payload?: unknown) => + req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined }); + +// --- types --------------------------------------------------------------- + +export type Deployment = "cloud" | "self_hosted"; +export type Tier = "free" | "professional" | "self_hosted"; +export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled"; + +export interface Session { + kind: "staff" | "customer"; + email: string; + account_id?: string; +} + +export interface Limits { + max_servers: number; + max_secret_groups: number; + max_channels: number; +} + +export interface Account { + account_id: string; + name: string; + billing_email: string; + paddle_customer_id?: string; + status: "active" | "suspended"; + created_at: string; +} + +export interface Instance { + instance_id: string; + account_id: string; + name: string; + slug?: string; + deployment: Deployment; + tier?: Tier; + status: InstanceStatus; + current_license?: string; + relink_count: number; + inject_failed_at?: string | null; + created_at: string; +} + +export interface License { + license_id: string; + instance_id: string; + account_id: string; + tier: Tier; + deployment: Deployment; + limits: Limits; + features: string[]; + issued_at: string; + expires_at: string; + superseded_by?: string; + issued_by: string; + reason: "new" | "renewal" | "tier_change" | "relink" | "manual"; +} + +export interface Subscription { + subscription_id: string; + account_id: string; + instance_id?: string; + tier: Tier; + term: string; + status: string; + current_period_end: string; +} + +export interface Plan { + tier: Tier; + name: string; + deployment: Deployment; + limits: Limits; + features: string[]; + paddle_product_id?: string; + paddle_price_ids?: Record; + active: boolean; +} + +export interface CustomerUser { + user_id: string; + account_id: string; + email: string; + verified_at?: string | null; + created_at: string; +} + +export interface AuditEntry { + actor: string; + action: string; + account_id?: string; + target?: string; + detail?: string; + ip?: string; + created_at: string; +} + +export interface AccountResponse { + account: Account; + instances: Instance[]; + max_relinks: number; +} + +export interface StaffAccountResponse { + account: Account; + instances: Instance[]; + subscriptions: Subscription[]; + users: CustomerUser[]; + audit: AuditEntry[]; +} + +export type InjectionState = "current" | "stale" | "missing" | "none_issued"; + +export interface StaffInstanceResponse { + instance: Instance; + account: Account; + licenses: License[]; + injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null }; +} + +// --- calls --------------------------------------------------------------- + +export const api = { + me: () => req("/auth/me"), + login: (email: string, password: string) => post("/auth/login", { email, password }), + staffLogin: (email: string, password: string) => + post("/auth/staff/login", { email, password }), + logout: () => post<{ ok: boolean }>("/auth/logout"), + signup: (payload: { name: string; email: string; password: string; website?: string }) => + post<{ pending: boolean }>("/auth/signup", payload), + verify: (token: string) => req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`), + + account: () => req("/api/account"), + link: (instance_id: string, name: string) => + post("/api/instances/link", { instance_id, name }), + relink: (id: string, instance_id: string) => + post(`/api/instances/${id}/relink`, { instance_id }), + license: (id: string) => req(`/api/instances/${id}/license`), + licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`, + subscriptions: () => req("/api/subscriptions"), + + staff: { + accounts: (q?: string) => + req(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`), + account: (id: string) => req(`/api/staff/accounts/${id}`), + instances: (params?: Record) => + req(`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`), + instance: (id: string) => req(`/api/staff/instances/${id}`), + issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) => + post(`/api/staff/instances/${id}/issue`, payload), + relink: (id: string, instance_id: string) => + post(`/api/staff/instances/${id}/relink`, { instance_id }), + licenses: (params?: Record) => + req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`), + plans: () => req("/api/staff/plans"), + updatePlan: (tier: Tier, plan: Omit) => + req<{ updated: boolean }>(`/api/staff/plans/${tier}`, { + method: "PUT", + body: JSON.stringify(plan), + }), + audit: (accountId?: string) => + req(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`), + injectionHealth: () => + req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"), + subscriptions: (status?: string) => + req(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`), + }, +}; +``` + +- [ ] **Step 7: Write format helpers and providers** + +`adminsite/lib/format.ts`: + +```ts +export type LicenceState = "valid" | "warn" | "expired" | "none"; + +/** Amber inside 14 days, matching the window staff chase renewals on. */ +export const EXPIRY_WARNING_DAYS = 14; + +export function daysRemaining(iso: string): number { + const ms = new Date(iso).getTime() - Date.now(); + return Math.ceil(ms / 86_400_000); +} + +export function licenceState(expiresAt: string | undefined, hasLicence: boolean): LicenceState { + if (!hasLicence || !expiresAt) return "none"; + const days = daysRemaining(expiresAt); + if (days <= 0) return "expired"; + if (days <= EXPIRY_WARNING_DAYS) return "warn"; + return "valid"; +} + +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +export function formatStamp(iso: string): string { + return `${new Date(iso).toISOString().slice(11, 19)} UTC`; +} + +export function limitLabel(n: number): string { + return n === -1 ? "unlimited" : String(n); +} +``` + +`adminsite/lib/query-client.ts`: + +```ts +"use client"; + +import { QueryClient } from "@tanstack/react-query"; +import { ApiError, NotConnected } from "./api"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // Retrying a 401 or a missing API URL just delays the redirect and + // the not-connected panel. + retry: (count, error) => + error instanceof NotConnected || error instanceof ApiError ? false : count < 1, + }, + }, +}); +``` + +`adminsite/components/Providers.tsx`: + +```tsx +"use client"; + +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@/lib/query-client"; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} +``` + +- [ ] **Step 8: Run the whole suite** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run +``` + +Expected: `2 passed`. + +- [ ] **Step 9: Commit** + +```bash +git add adminsite/ +git commit -m "feat(adminsite): test harness, typed client and the not-connected state" +``` + +--- + +### Task 5: Session, route-group guards and auth screens + +Covers spec test 1. + +**Files:** +- Create: `adminsite/lib/session.ts`, `adminsite/components/EnvBadge.tsx`, `adminsite/components/Button.tsx`, `adminsite/components/Field.tsx`, `adminsite/app/login/page.tsx`, `adminsite/app/signup/page.tsx`, `adminsite/app/verify/page.tsx`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(staff)/staff/layout.tsx`, `adminsite/app/page.tsx`, `adminsite/lib/session.test.tsx` + +**Interfaces:** +- Consumes: `api`, `NotConnected`, `ApiError` +- Produces: `useSession()`, ``, ``, ` + + Create an account for a self-hosted licence + + + + + ); +} + +function Main({ children }: { children: React.ReactNode }) { + return
{children}
; +} +``` + +`adminsite/app/signup/page.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { ApiError, NotConnected, api } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/Field"; + +export default function SignupPage() { + const [form, setForm] = useState({ name: "", email: "", password: "", website: "" }); + const [state, setState] = useState<"idle" | "busy" | "sent">("idle"); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setState("busy"); + setError(null); + try { + await api.signup(form); + setState("sent"); + } catch (err) { + setState("idle"); + setError( + err instanceof NotConnected + ? "The licensing service is not reachable from this page." + : err instanceof ApiError + ? err.message + : "Could not create the account. Try again.", + ); + } + } + + return ( +
+ {state === "sent" ? ( +
+

Check your email

+

+ We sent a link to {form.email}. Open it to finish setting up your account — + it expires in 24 hours. Nothing is created until you do. +

+
+ ) : ( + <> +

Create an account

+

+ For self-hosted licences. If you run on our cloud, sign in with the same + details you use for your Vantage instance. +

+
+ setForm({ ...form, name: e.target.value })} + /> + setForm({ ...form, email: e.target.value })} + /> + setForm({ ...form, password: e.target.value })} + error={error ?? undefined} + /> + {/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */} + setForm({ ...form, website: e.target.value })} + className="absolute left-[-9999px] h-0 w-0" + /> + + + + )} +
+ ); +} +``` + +`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 ; + if (isLoading) return ; + if (error || !data?.verified) + return ( + + ); + + return ( +
+

Email verified

+

Your account is ready.

+ Sign in +
+ ); +} + +function Message({ title, body }: { title: string; body: string }) { + return ( +
+

{title}

+

{body}

+
+ ); +} + +export default function VerifyPage() { + return ( +
+ + + +
+ ); +} +``` + +- [ ] **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 ( + + +
{children}
+
+ ); +} +``` + +`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 ( +
+

Nothing here

+

+ That page does not exist, or it belongs to an account you are not signed in to. +

+ Back to your account +
+ ); +} +``` + +- [ ] **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: ``, `` + +- [ ] **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(); + expect(screen.getByText("Valid")).toBeInTheDocument(); + expect(screen.getByText(/367 days remaining/)).toBeInTheDocument(); + }); + + it("warns inside fourteen days", () => { + render(); + expect(screen.getByText("Expiring")).toBeInTheDocument(); + expect(screen.getByText(/9 days remaining/)).toBeInTheDocument(); + }); + + it("names what still works when expired", () => { + render(); + 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( + , + ); + 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(); + 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 = { + 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 = { + 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 = { + 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 ( + + + {LABEL[state]} + + ); +} +``` + +- [ ] **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 ( +
+
+
+

{instance.name || "Unnamed instance"}

+

+ {cloud ? "Cloud" : "Self-hosted"} + {instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""} +

+
+ +
+ + {state === "expired" && ( +

+ Servers and monitors are still running, and your agents keep their keys. + Changes are disabled until you renew. +

+ )} + + {state === "none" && ( +

+ You have paid for this but it is not attached to an install yet, so no licence + has been issued. Linking takes a minute. +

+ )} + + {license && state !== "expired" && ( +
+ {days} days remaining +
+
+
+ Renews {formatDate(license.expires_at)} +
+ )} + + {state === "none" ? ( + + Link an install + + ) : cloud && instance.slug ? ( + + Open {instance.slug}.vantage.hostxtra.co.uk + + ) : ( + + {state === "expired" ? "Renew and download" : "Licence and download"} + + )} +
+ ); +} + +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 ; + if (isLoading || !data) return

Loading your account…

; + + const byInstance = new Map(); + 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 ( +
+
+

{data.account.name}

+

{data.account.billing_email}

+
+ + {unlinked.length > 0 && ( +
+

Finish setting up your licence

+

+ {unlinked.length === 1 ? "One purchase is" : `${unlinked.length} purchases are`}{" "} + not attached to an install yet, so no licence has been issued for{" "} + {unlinked.length === 1 ? "it" : "them"}. +

+ + Link an install + +
+ )} + + {data.instances.length === 0 ? ( +
+

No instances yet

+

+ There are two ways to run Vantage. Buy a cloud instance and we host it, and + your licence is applied automatically. Or buy a self-hosted licence, install + Vantage on your own server, and link it here to get your licence file. +

+
+ ) : ( +
+ {data.instances.map((i) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **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: ``, `` + +- [ ] **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( + , + ); + 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(); + 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(); + 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 ( +
+

Your licence

+
+ + Download licence + + +
+
+                {blob}
+            
+
    + {[ + <>Open Settings → Licence on your install., + <>Paste the licence into the box and save., + <>The page reports Valid straight away — no restart., + ].map((body, i) => ( +
  1. + + {i + 1} + + {body} +
  2. + ))} +
+
+ ); +} +``` + +- [ ] **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 ( +
+

Moved to a new server?

+

+ Relinking issues a replacement licence for the new install, covering the rest of + your current term. +

+ {open && !exhausted && ( + setValue(e.target.value)} + error={error} + hint="From Settings → Licence on the new install." + /> + )} +
+ + + {exhausted + ? "You have used every relink for this term — contact support and we will sort it out." + : `${remaining} of ${max} relinks left this term`} + +
+
+ ); +} +``` + +- [ ] **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(); + + 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 ; + + const instance = account.data?.instances.find((i) => i.instance_id === id); + if (account.isLoading) return

Loading…

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

That instance is not on your account.

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

{instance.name}

+ +
+

{instance.instance_id}

+
+ + {lic ? ( + <> +
+ + + + +
+ + {instance.deployment === "self_hosted" && ( + + )} + + {instance.deployment === "self_hosted" && ( + relink.mutate(newId)} + /> + )} + + ) : ( +

No licence has been issued for this instance yet.

+ )} +
+ ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} +``` + +`License.blob` is `json:"-"` on the backend, so add a customer-facing blob field: in `admin/internal/api/customer.go`, `getInstanceLicense` and `downloadInstanceLicense` already have the record — change `getInstanceLicense` to respond `c.JSON(http.StatusOK, gin.H{"license": lic, "blob": lic.Blob})` and widen the TS type to `License & { blob?: string }`. Do this in Step 7 before the page can render the blob. + +- [ ] **Step 7: Return the blob to its owner** + +In `admin/internal/api/customer.go`, replace the final line of `getInstanceLicense`: + +```go + // The owner gets the blob itself: it is signed public data bound to their + // own instance, and the download endpoint hands over the same bytes. + c.JSON(http.StatusOK, gin.H{ + "license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier, + "deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features, + "issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason, + "issued_by": lic.IssuedBy, "blob": lic.Blob, + }) +``` + +In `adminsite/lib/api.ts`, change the customer call's type: + +```ts + license: (id: string) => req(`/api/instances/${id}/license`), +``` + +Rebuild admin: `sh /tmp/gorun.sh admin go build ./...` — expected no output. + +- [ ] **Step 8: Run the suite** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run +``` + +Expected: `13 passed`. + +- [ ] **Step 9: Commit** + +```bash +git add adminsite/ admin/ +git commit -m "feat(adminsite): licence delivery, paste instructions and relink" +``` + +--- + +### Task 8: The link flow + +Covers spec test 3. This is the screen the five-minute bar applies to. + +**Files:** +- Create: `adminsite/app/(customer)/instances/link/page.tsx`, `adminsite/app/(customer)/instances/link/LinkForm.tsx`, `adminsite/app/(customer)/instances/link/LinkForm.test.tsx` + +**Interfaces:** +- Produces: ` void} />` + +- [ ] **Step 1: Write the failing test** + +```tsx +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; +import { LinkForm } from "./LinkForm"; + +const link = vi.fn(); +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { ...actual, api: { ...actual.api, link: (...a: unknown[]) => link(...a) } }; +}); + +const VALID = "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d"; + +describe("LinkForm", () => { + it("catches a malformed id before asking the server", async () => { + render(); + await userEvent.type(screen.getByLabelText(/instance id/i), "not-a-uuid"); + await userEvent.click(screen.getByRole("button", { name: /link and issue/i })); + + expect(screen.getByText(/does not look like an instance id/i)).toBeInTheDocument(); + expect(link).not.toHaveBeenCalled(); + }); + + it("lands the customer on their licence on success", async () => { + const onLinked = vi.fn(); + link.mockResolvedValue({ instance_id: VALID }); + render(); + await userEvent.type(screen.getByLabelText(/instance id/i), VALID); + await userEvent.click(screen.getByRole("button", { name: /link and issue/i })); + + await waitFor(() => expect(onLinked).toHaveBeenCalledWith(VALID)); + }); + + it("shows the server's own message when the id is already linked", async () => { + link.mockRejectedValue(new ApiError(409, "that instance ID is already linked to an account")); + render(); + await userEvent.type(screen.getByLabelText(/instance id/i), VALID); + await userEvent.click(screen.getByRole("button", { name: /link and issue/i })); + + expect(await screen.findByText(/already linked to an account/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run app/\(customer\)/instances/link/LinkForm.test.tsx +``` + +Expected: FAIL — cannot resolve `./LinkForm`. + +- [ ] **Step 3: Write LinkForm** + +```tsx +"use client"; + +import { useState } from "react"; +import { ApiError, NotConnected, api } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/Field"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) { + const [id, setId] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const value = id.trim(); + + // Checked here so a typo costs nothing and the message is instant. + if (!UUID_RE.test(value)) { + setError("That does not look like an instance ID. It should look like the example below."); + return; + } + + setBusy(true); + setError(undefined); + try { + const inst = await api.link(value, name.trim()); + onLinked(inst.instance_id); + } catch (err) { + setError( + err instanceof NotConnected + ? "The licensing service is not reachable from this page." + : err instanceof ApiError + ? err.message + : "Could not link that instance. Try again.", + ); + } finally { + setBusy(false); + } + } + + return ( +
+ setId(e.target.value)} + error={error} + hint={ + <> + Find this on your install’s{" "} + Settings → Licence page, or on the setup + screen just after you first sign in. It looks like{" "} + 6a0fe3f0-49d2-4aa1-967c-a3094b200b5d. + + } + /> + setName(e.target.value)} + hint="So you can tell it apart from your other installs." + /> + + + ); +} +``` + +- [ ] **Step 4: Run it and watch it pass** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run app/\(customer\)/instances/link/LinkForm.test.tsx +``` + +Expected: `3 passed`. + +- [ ] **Step 5: Write the page** + +```tsx +"use client"; + +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { LinkForm } from "./LinkForm"; + +export default function LinkPage() { + const router = useRouter(); + const qc = useQueryClient(); + + return ( +
+
+

Link an install

+

+ Every licence is tied to one install, so we need its ID before we can issue + yours. Paste it below and your licence is ready on the next screen. +

+
+ { + qc.invalidateQueries({ queryKey: ["account"] }); + // Straight to the download, not back to a list: the licence is + // the thing they came for. + router.push(`/instances/${instanceId}`); + }} + /> +
+ ); +} +``` + +- [ ] **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 ; + if (isLoading) return

Loading…

; + + return ( +
+

Billing

+ + {!data || data.length === 0 ? ( +

+ You have no subscriptions. Cloud instances and self-hosted licences are both + bought from the pricing page. +

+ ) : ( +
+ + + + + + + + + + + {data.map((s) => ( + + + + + + + ))} + +
PlanTermStatusRenews
{s.tier.replace("_", " ")}{s.term}{s.status} + {formatDate(s.current_period_end)} +
+
+ )} + +

+ To change a card, download an invoice or cancel, email support and we will send you + a billing link. Self-service billing arrives with card payments. +

+
+ ); +} +``` + +That last paragraph is replaced by the Paddle portal deep-link in spec 5. It states the current truth rather than linking nowhere. + +- [ ] **Step 2: Commit** + +```bash +git add adminsite/ +git commit -m "feat(adminsite): customer billing view" +``` + +--- + +### Task 10: Staff operations dashboard + +Covers spec test 7. + +**Files:** +- Create: `adminsite/app/(staff)/staff/page.tsx`, `adminsite/components/Queue.tsx`, `adminsite/components/Queue.test.tsx` + +**Interfaces:** +- Produces: `` where `items: { label: string; href: string; meta: string }[]` + +- [ ] **Step 1: Write the failing test** + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Queue } from "./Queue"; + +describe("Queue", () => { + it("shows the count and links every row to the work", () => { + render( + , + ); + expect(screen.getByText("Failed injections")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Acme Production" })).toHaveAttribute( + "href", + "/staff/instances/i1", + ); + }); + + it("says so plainly when there is nothing to do", () => { + render(); + expect(screen.getByText(/nothing to do/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run components/Queue.test.tsx +``` + +Expected: FAIL — cannot resolve `./Queue`. + +- [ ] **Step 3: Write Queue** + +```tsx +import Link from "next/link"; +import clsx from "clsx"; + +const TONE = { + expired: "border-l-expired text-expired", + warn: "border-l-warn text-warn", + accent: "border-l-accent text-accent", +} as const; + +export function Queue({ + title, + count, + tone, + items, +}: { + title: string; + count: number; + tone: keyof typeof TONE; + items: { label: string; href: string; meta: string }[]; +}) { + return ( +
+

{title}

+

{count}

+ {items.length === 0 ? ( +

Nothing to do here.

+ ) : ( +
    + {items.map((i) => ( +
  • + {i.label} + {i.meta} +
  • + ))} +
+ )} +
+ ); +} +``` + +- [ ] **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 ; + + const stale = (unlinked.data ?? []).filter( + (i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48, + ); + + return ( +
+

Operations

+
+ ({ + 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) : "", + }))} + /> + ({ + label: i.name || i.instance_id, + href: `/staff/instances/${i.instance_id}`, + meta: i.tier ?? "", + }))} + /> + ({ + label: s.instance_id || s.account_id, + href: `/staff/accounts/${s.account_id}`, + meta: `${daysRemaining(s.current_period_end)}d`, + }))} + /> + ({ + 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`, + }))} + /> +
+
+ ); +} +``` + +- [ ] **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("@/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({ui}); +} + +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(); + 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 ( +
+ setQ(e.target.value)} + hint="Name, email, Paddle customer ID, or an instance UUID." + /> +
+ + + + + + + + + + + {(data ?? []).map((a) => ( + + + + + + + ))} + +
AccountBilling emailStatusCreated
+ + {a.name} + + {a.billing_email}{a.status}{formatDate(a.created_at)}
+ {!isFetching && (data ?? []).length === 0 && ( +

+ No account matches that. Try the instance UUID from the customer’s email. +

+ )} +
+
+ ); +} +``` + +- [ ] **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 ( +
+

Accounts

+ +
+ ); +} +``` + +`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

Loading…

; + + return ( +
+
+

{data.account.name}

+

+ {data.account.billing_email} · {data.account.account_id} +

+
+ + +
    + {data.instances.map((i) => ( +
  • + + {i.name || i.instance_id} + + + {i.deployment} · {i.tier ?? "no tier"} · {i.status} + +
  • + ))} + {data.instances.length === 0 &&
  • None.
  • } +
+
+ + +
    + {data.subscriptions.map((s) => ( +
  • + {s.tier.replace("_", " ")} · {s.term} + + {s.status} · renews {formatDate(s.current_period_end)} + +
  • + ))} + {data.subscriptions.length === 0 &&
  • None.
  • } +
+
+ + +
    + {data.users.map((u) => ( +
  • + {u.email} + + {u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"} + +
  • + ))} + {data.users.length === 0 && ( +
  • + None — this is a cloud account, so its people sign in with their control-plane details. +
  • + )} +
+
+ + +
    + {data.audit.map((e, n) => ( +
  • + {e.action} · {e.actor} + {formatDate(e.created_at)} +
  • + ))} +
+
+
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} +``` + +- [ ] **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: `` + +- [ ] **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 { + 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( + , + ); + + 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(); + expect(screen.getByText(/new1/)).toBeInTheDocument(); + }); + + it("says so when an instance has never been licensed", () => { + render(); + 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 = { + 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 ( +

+ No licence has ever been issued for this instance, so it is read-only. +

+ ); + } + + return ( +
    + {licenses.map((l) => { + const dead = Boolean(l.superseded_by); + return ( +
  • +
    + + {formatDate(l.issued_at)} + + {formatStamp(l.issued_at)} +
    +
    + {dead && ( + + Superseded + + )} +

    + {l.tier.replace("_", " ")} + + {REASON[l.reason]} + +

    +

    + {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{" "} + {l.superseded_by.slice(0, 8)} + + )} +

    +
    +
  • + ); + })} +
+ ); +} +``` + +- [ ] **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(deployment === "cloud" ? "professional" : "self_hosted"); + const [term, setTerm] = useState("annual"); + const [newId, setNewId] = useState(""); + const [error, setError] = useState(); + + 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 ( +
+
+ + + +
+ +
+ setNewId(e.target.value)} + hint="Staff relinks are not capped — the customer cap exists to put you in the loop." + /> + +
+ + {error &&

{error}

} +
+ ); +} +``` + +- [ ] **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 = { + 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

Loading…

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

{data.instance.name || data.instance.instance_id}

+

{data.instance.instance_id}

+

+ + {data.account.name} + + + {" "}· {data.instance.deployment} · {data.instance.status} + {data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`} + +

+ {data.injection.applicable && inj && ( +

{inj.label}

+ )} +
+ +
+

Licence history

+ + +
+
+ ); +} +``` + +- [ ] **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 ( +
+

Licences

+
+ + +
+
+ + + + + + + + + + + + + {rows.map((l) => ( + + + + + + + + + ))} + +
IssuedInstanceTierReasonExpiresState
{formatDate(l.issued_at)} + + {l.instance_id.slice(0, 8)} + + {l.tier.replace("_", " ")}{l.reason.replace("_", " ")}{formatDate(l.expires_at)}{l.superseded_by ? "superseded" : "current"}
+
+
+ ); +} +``` + +- [ ] **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 ( +
+

Audit

+ setFilter(e.target.value)} hint="Action, actor or target." /> +
    + {rows.map((e, n) => ( +
  • + + {formatDate(e.created_at)} {formatStamp(e.created_at)} + + + {e.action} · {e.actor} + {e.target && ` · ${e.target}`} + {e.detail && ` · ${e.detail}`} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **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( + , + ); + + 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( + , + ); + 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 ( +
+

Change what {plan.name} grants?

+
    + {rows.map((r) => ( +
  • + {r.field} + {r.was} + → {r.now} +
  • + ))} +
+

+ This applies to licences issued from now on. The {issuedCount} licences already + issued keep what they were signed with until each is reissued. +

+
+ + +
+
+ ); +} +``` + +- [ ] **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(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 ( +
+

Plans

+ + {draft && original && ( + l.tier === draft.tier).length} + onConfirm={() => save.mutate(draft)} + onCancel={() => setDraft(null)} + /> + )} + +
+ {(plans.data ?? []).map((p) => ( +
+

{p.name}

+
+
servers
{limitLabel(p.limits.max_servers)}
+
secret groups
{limitLabel(p.limits.max_secret_groups)}
+
channels
{limitLabel(p.limits.max_channels)}
+
features
{p.features.join(", ") || "none"}
+
+ + {/* Guard rail two: deployment is shown, never edited. */} +

+ + Deployment is fixed at {p.deployment}. Moving a + tier between cloud and self-hosted is a code change, not a form field. +

+ +
+ + +
+
+ ))} +
+
+ ); +} +``` + +Those two buttons are the concrete edits staff actually need on day one; a general-purpose limits editor is deliberately not built until someone asks for it. + +- [ ] **Step 6: Run the whole suite** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run +``` + +Expected: `26 passed`, covering all ten of spec 4's listed tests. + +- [ ] **Step 7: Commit** + +```bash +git add adminsite/ +git commit -m "feat(adminsite): plan editing with both guard rails" +``` + +--- + +### Task 15: Image, compose, CI and docs + +**Files:** +- Create: `adminsite/Dockerfile`, `adminsite/.dockerignore` +- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md` + +- [ ] **Step 1: Write the Dockerfile** + +Context is `adminsite/`, like `web/` — this app has no shared-module dependency. + +```dockerfile +FROM node:26-alpine AS deps + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +FROM node:26-alpine AS builder + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Baked in at build time and must be reachable from the BROWSER, and present in +# admin's ADMIN_ORIGIN. Wrong here means every request fails at runtime. +ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 +ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL +ARG NEXT_PUBLIC_ADMIN_ENV=production +ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV + +RUN npm run build + +FROM node:26-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] +``` + +`adminsite/.dockerignore`: + +``` +node_modules +.next +.env +*.lic +``` + +- [ ] **Step 2: Add the compose service** + +In `deploy/docker-compose.site.yml`, after `admin`: + +```yaml + adminsite: + image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest + restart: unless-stopped + ports: + # 3002 is the marketing site; this takes 3004. + - 3004:3000 + depends_on: + - admin +``` + +- [ ] **Step 3: Add the image build** + +In `.gitea/workflows/server-deploy.yml`, after the admin step: + +```yaml + - name: Build and push adminsite image + run: | + IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/adminsite:latest" + docker build \ + --build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \ + --build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \ + -t "$IMAGE" \ + -f adminsite/Dockerfile adminsite/ + docker push "$IMAGE" +``` + +- [ ] **Step 4: Document it** + +In `CLAUDE.md`: + +- add `adminsite/` to the repository structure and the route table +- record that it is served at `vantage-hq.hostxtra.co.uk`, listens on `3000` and is published as `3004`, and that the host sits outside `*.vantage.hostxtra.co.uk` on purpose because that namespace is per-tenant instance subdomains +- add `ADMIN_API_URL` and `ADMIN_ENV` to the CI variables table beside `SITE_API_URL`, carrying the same browser-reachable warning +- record that admin's HTTP surface gained `GET /auth/me`, `POST /auth/signup`, `GET /api/staff/instances/:id` and `GET /api/staff/subscriptions`, and that `GET /api/instances/:id/license` now returns the blob to its owner + +- [ ] **Step 5: Build the image** + +```bash +MSYS_NO_PATHCONV=1 docker build -q \ + --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \ + -f adminsite/Dockerfile -t vantage-adminsite:test adminsite/ +``` + +Expected: an image ID. This is the check that catches a missing dependency the dev server tolerated. + +- [ ] **Step 6: Confirm the self-hosted deployment is untouched** + +```bash +grep -c "adminsite" deploy/docker-compose.yml +cd deploy && MSYS_NO_PATHCONV=1 docker compose -f docker-compose.yml config --services | sort | tr '\n' ' ' +``` + +Expected: `0`, then exactly `guacd redis server web`. + +- [ ] **Step 7: Commit** + +```bash +git add adminsite/ deploy/ .gitea/ CLAUDE.md +git commit -m "feat(adminsite): image, compose service and image build" +``` + +--- + +### Task 16: Full verification + +Everything in containers, against the stack from spec 3's Task 11. + +- [ ] **Step 1: Suite green and a clean production build** + +```bash +sh /tmp/noderun.sh adminsite npx vitest run +sh /tmp/noderun.sh adminsite npm run build +sh /tmp/noderun.sh adminsite npm run lint +``` + +Expected: `26 passed`; a successful standalone build; no lint errors. + +- [ ] **Step 2: Bring up the whole stack** + +Start Mongo, Redis, the control plane and admin exactly as in the spec-3 plan Task 11 steps 2–4, then adminsite pointed at admin, with admin's `ADMIN_ORIGIN` including it: + +```bash +docker rm -f vadminsite >/dev/null 2>&1 +MSYS_NO_PATHCONV=1 docker build -q \ + --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \ + --build-arg NEXT_PUBLIC_ADMIN_ENV=sandbox \ + -f adminsite/Dockerfile -t vantage-adminsite:test adminsite/ +MSYS_NO_PATHCONV=1 docker run -d --name vadminsite -p 3004:3000 vantage-adminsite:test +sleep 6 +curl -s -o /dev/null -w "adminsite: %{http_code}\n" localhost:3004/login +``` + +Admin must be started with `-e ADMIN_ORIGIN=http://localhost:3004`. Locally the origin is `localhost:3004`; in production it is `https://vantage-hq.hostxtra.co.uk`, and both must appear in `ADMIN_ORIGIN` if you want the local build to keep working against the deployed API. + +Expected: `200`. + +- [ ] **Step 3: Self-hosted purchase to working licence, timed, no documentation** + +In a browser at `http://localhost:3004`: sign up, open the verification link from admin's logs (`docker logs vadmin | grep verify`), sign in, link the instance UUID from a self-hosted install, download the licence, paste it into that install's `Settings → Licence`. + +Expected: the install reports `Valid · Self Hosted`. **Time it. Over five minutes means the flow needs work, not the plan.** + +- [ ] **Step 4: Cloud pass** + +Sign in as the cloud owner (`owner@example.com`), confirm the overview shows the instance as valid with days remaining, and that the instance's own `/settings/license` agrees. + +- [ ] **Step 5: Staff pass, the whole point of the ledger** + +Sign in with `I work at Vantage`, search accounts by the instance UUID, open the instance, read the ledger top to bottom, reissue, and confirm the control plane picks it up within a minute: + +```bash +curl -s localhost:8080/api/license -b /tmp/a.txt | grep -oE '"state":"[a-z]+"|"tier":"[a-z_]+"' +``` + +Expected: the ledger shows the previous licence overprinted `Superseded` and linked to the new one; the control plane reports `valid`. + +- [ ] **Step 6: Confirm the guards both ways** + +```bash +curl -s -o /dev/null -w "customer hitting staff API: %{http_code}\n" \ + localhost:8083/api/staff/accounts -b /tmp/c.txt +curl -s -o /dev/null -w "customer reading another account's instance: %{http_code}\n" \ + localhost:8083/api/instances/11111111-2222-3333-4444-555555555555/license -b /tmp/c.txt +``` + +Expected: `401` and `404`. Then in the browser, as a customer, visit `/staff` and confirm it redirects to `/` rather than showing a refusal. + +- [ ] **Step 7: Not-connected state** + +```bash +docker rm -f vadminsite-broken >/dev/null 2>&1 +MSYS_NO_PATHCONV=1 docker build -q \ + --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:9999 \ + -f adminsite/Dockerfile -t vantage-adminsite:broken adminsite/ +MSYS_NO_PATHCONV=1 docker run -d --name vadminsite-broken -p 3005:3000 vantage-adminsite:broken +``` + +Expected: `localhost:3005` renders the not-connected panel naming `ADMIN_API_URL` and `ADMIN_ORIGIN`, not a blank page or a spinner forever. + +- [ ] **Step 8: Environment badge and responsive check** + +Confirm the sandbox badge is hatched and visible on every screen in the `NEXT_PUBLIC_ADMIN_ENV=sandbox` build. Then at 375px width, walk the customer overview, instance detail and link flow — a customer hit by an expiry email opens this on a phone. + +- [ ] **Step 9: Clean up and commit** + +```bash +docker rm -f vadminsite vadminsite-broken vadmin vadmin-server vadmin-mongo vadmin-redis +docker rmi vantage-adminsite:test vantage-adminsite:broken +git add -A +git commit -m "chore: verify the admin site end to end" --allow-empty +``` + +--- + +## Rollout + +1. Point `vantage-hq.hostxtra.co.uk` at the host and terminate TLS in front of `3004`. +2. Set `ADMIN_API_URL` and `ADMIN_ENV` as Gitea variables **before the first build** — `ADMIN_API_URL` is baked into the image, so changing it later means a rebuild, not a restart. It must be browser-reachable and listed in admin's `ADMIN_ORIGIN`. +3. Add `https://vantage-hq.hostxtra.co.uk` to `ADMIN_ORIGIN` in the host `.env` and restart admin. +4. Deploy admin first (tasks 1–2 change its API), then adminsite. +5. `adminctl staff-add` per staff member if not already done. +6. **First real job: licence the existing cloud instances.** Staff → Accounts → create an account, attach the instance, issue. They stay read-only until that is done. + +## Risks + +| Risk | Mitigation | +|---|---| +| `ADMIN_API_URL` misconfigured at build | Explicit not-connected panel naming the variable and `ADMIN_ORIGIN`; verified in Task 16 Step 7 | +| Cross-origin cookies silently dropped | Both hosts stay under one registrable domain so `SameSite=Lax` still applies; documented in Global Constraints and exercised by every browser step in Task 16 | +| Staff action against the wrong environment | Hatched badge on every screen from the root layout; confirmation on plan changes | +| Customer session reaching staff data | Route-group guard (test 1) plus admin's own 401/404 (Task 16 Step 6). Two layers | +| Customer confused by the self-hosted flow | Named UUID location, client-side format check, success lands on the download; five-minute bar in Task 16 Step 3 | +| Plan edit mistaken for retroactive | Confirmation names each field and the count of licences already issued | +| Ledger unreadable once an instance has years of history | Newest first, superseded rows dimmed and overprinted; filterable global view on `/staff/licenses` |