diff --git a/claude.md b/claude.md index 26890ab..385a8ae 100644 --- a/claude.md +++ b/claude.md @@ -81,6 +81,25 @@ vantage/ │ ├── models/ # mirrors server org/user + pending signup │ ├── provision/ # slug rules mirrored from the control plane │ └── store/ # mongo: pending signups, org/user creation +├── admin/ # licensing authority: the only signer +│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP +│ ├── cmd/adminctl/ # staff-add; deliberately has no HTTP surface +│ └── internal/ +│ ├── api/ # customer + staff handlers, route table +│ ├── auth/ # staff, cloud-owner and self-hosted sessions +│ ├── inject/ # the ONE write path into the control plane +│ ├── licensing/ # Issue, LinkInstance, Relink +│ ├── mail/ # verification and licence delivery +│ └── models/ # accounts, instances, licences, plans +├── adminsite/ # staff + customer console (vantage-hq) +│ ├── app/(customer)/ # overview, instance, link, billing +│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit +│ ├── components/ # InstanceCard, Ledger, Queue, EnvBadge +│ └── lib/ # api client, session guards, formatters +├── shared/ # imported by server, sitesvc and admin +│ ├── license/ # payload, sign, verify, trusted keys, plans +│ ├── models/ # Instance, User, Settings +│ └── cmd/lkctl/ # issue and inspect licences by hand ├── proto/vantage/v1/vantage.proto ├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs ├── deploy/ # docker-compose.yml, agent.service @@ -127,7 +146,9 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat ### Marketing site and sitesvc -`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint. +`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint. + +`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable and listed in admin's `ADMIN_ORIGIN`. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play. `sitesvc/` (port `8082`) owns both flows end to end: @@ -245,6 +266,39 @@ org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users --- +## Admin REST API (`admin`, :8083) + +A separate service with its own session cookie (`admin_session`) and its own database. Unauthenticated: + +``` +GET /healthz +GET /auth/me # who am I; 401 drives the UI's redirects +POST /auth/staff/login /auth/login /auth/logout +POST /auth/signup # self-hosted only; honeypot + rate limited +GET /auth/verify?token=… +``` + +Customer-session (`/api`), every instance resolved through `ownedInstance`: + +``` +GET /account # account, instances, max_relinks +POST /instances/link · /instances/:id/relink +GET /instances/:id/license · /instances/:id/license/download +GET /subscriptions +``` + +Staff-session (`/api/staff`): + +``` +GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID +GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state +POST /instances/:id/issue · /instances/:id/relink +GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:tier +GET /health/injection +``` + +**Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters. + ## MongoDB Collections `servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations` @@ -358,7 +412,9 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a | `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request | | `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter | -`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds the public marketing site on `3001` and is only used on vantage.hostxtra.co.uk. +`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk. + +`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only. --- @@ -380,6 +436,18 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`. +There are **three separate visual identities**, and the split is deliberate: + +| App | Ground | Accent | Themes | +| --- | --- | --- | --- | +| `web/` | `#0f1117` | indigo `#6366f1` | dark only, locked | +| `site/` | token-based | brand navy `#0b2a58` / `#5b9be8` | light + dark | +| `adminsite/` | **the same tokens as `site/`** | brand navy | light + dark, light default | + +`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. **Change them in both files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. Tailwind in `adminsite/` maps `var(--…)` references only, so no component may carry a hex value. `site/` names the semantic three `--up`/`--pend`/`--down` for monitor state; `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state — same colours. + +`adminsite/` defaults to **light** on purpose: `web/` is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to `#5b9be8`, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label. + | Route | Purpose | | --------------------------------------------------------------- | ----------------------------------------------------------------------- | | `/setup` | First-run bootstrap: create the first org and owner | @@ -411,7 +479,7 @@ GOOS=linux GOARCH=amd64 go build \ ### `server-deploy.yml` — triggered on every push to `main` -Builds and pushes four images to the Gitea container registry: `server`, `web`, `site` and `sitesvc`. +Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`. Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host: @@ -439,6 +507,8 @@ git push origin main # server + web deploy | `API_URL` | Variable | baked into the `web` image at build time | | `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. | | `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured | +| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into the `adminsite` image. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. Must also be in admin's `ADMIN_ORIGIN`. | +| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. | ---