refactor: move Vantage HQ out to the vantage-admin repository
admin/ and adminsite/ are extracted with their history to
gitea.hostxtra.co.uk/vantage/vantage-admin, where they are named server/
and web/ for what they are rather than for the services they run. Their
images move with them, to vantage/vantage-admin/{server,web}.
Nothing here imported them, so the cut is clean: the only coupling was
always at runtime, through admin writing into the control plane's
database. The parts of that contract this side enforces are unchanged and
still documented here — hq-sourced users, POST /license answering 409
cloud_managed, and FREE_INSTANCE_REAP_AFTER needing to match.
LICENSE_SIGNING_KEY now appears in no compose file in this repository.
Keeping it out used to be a rule someone had to remember; it is the
repository boundary now.
docker-compose.site.yml loses both services and gains a note on how the
host composes the three files together.
This commit is contained in:
@@ -78,22 +78,6 @@ vantage/
|
||||
│ └── internal/
|
||||
│ ├── api/ # contact
|
||||
│ └── store/ # Mongo connect helper
|
||||
├── 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, HQ customer and cloud-owner sessions
|
||||
│ ├── inject/ # licence write path into the control plane
|
||||
│ ├── cloudprov/ # instance write path: creates instances + owners
|
||||
│ ├── licensing/ # Issue, LinkInstance, Relink
|
||||
│ ├── mail/ # admin's boot-time shared/mail Sender
|
||||
│ └── models/ # accounts, instances, licences, plans
|
||||
├── adminsite/ # staff + customer console (vantage-hq)
|
||||
│ ├── app/(customer)/ # overview, instance, link, billing
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, pricing, audit
|
||||
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── docsite/ # user documentation (Docusaurus, static)
|
||||
│ ├── docs/ # getting-started, vantage, hq, reference, operations
|
||||
│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-*
|
||||
@@ -105,20 +89,38 @@ vantage/
|
||||
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
|
||||
```
|
||||
|
||||
**`shared/` is not in this repository.** It is the private module
|
||||
**Two repositories carry parts of Vantage that this one does not.**
|
||||
|
||||
| Repository | What it holds |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `vantage-shared` | the private Go module below — `mail`, `license`, `models`, `provision`, `backup`, `grpc/pb`, … |
|
||||
| `vantage-admin` | Vantage HQ: the licensing authority (`server/`, was `admin/`) and its console (`web/`, was `adminsite/`) |
|
||||
|
||||
`vantage-admin` is **not** a build dependency of anything here — there is no
|
||||
import in either direction, deliberately (see "Grants project, they do not
|
||||
federate"). It reaches this codebase two ways at runtime, both by writing
|
||||
directly into the control plane's MongoDB: `inject` for three licence fields
|
||||
and `cloudprov` for instances and their owners. The parts of that contract this
|
||||
repository must honour are documented where they bite — `users.auth_source ==
|
||||
"hq"` and `services.ErrHQManaged`, `POST /license` answering 409
|
||||
`cloud_managed`, and `FREE_INSTANCE_REAP_AFTER` needing to match admin's value.
|
||||
The rest lives in that repository's own CLAUDE.md.
|
||||
|
||||
**`shared/` is not in this repository either.** It is the private module
|
||||
`gitea.hostxtra.co.uk/vantage/vantage-shared`, and it holds `mail/` (the one
|
||||
email system: transport plus templates), `license/` (payload, sign, verify,
|
||||
trusted keys, plans), `models/` (Instance, User, Settings), `provision/`,
|
||||
`backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and
|
||||
`cmd/lkctl/`. Five modules here depend on it — `server`, `agent`, `admin`,
|
||||
`sitesvc`, `vantagectl` — each pinning a version in its own `go.mod`. It was a
|
||||
`cmd/lkctl/`. Four modules here depend on it — `server`, `agent`, `sitesvc`,
|
||||
`vantagectl` — each pinning a version in its own `go.mod`, as does
|
||||
`vantage-admin`. It was a
|
||||
directory in this repository until it was extracted with its history; the
|
||||
`replace ../shared` directives and the `./shared` entry in `go.work` are gone
|
||||
with it.
|
||||
|
||||
**A version pin is now the coupling, and that is the point.** While it was a
|
||||
directory, every service in a given commit built against exactly one `shared/`,
|
||||
and a change there rebuilt three images at once whether or not they were ready
|
||||
and a change there rebuilt several images at once whether or not they were ready
|
||||
for it. Now a service moves when somebody bumps its pin, which is a commit under
|
||||
that service's own directory — so the existing per-directory rebuild triggers
|
||||
already cover it, and there is no longer any way to ship a service against a
|
||||
@@ -134,11 +136,9 @@ arg, because an arg survives in the builder layer's history and this one is a
|
||||
Gitea token. Locally, either a netrc or
|
||||
`git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
|
||||
|
||||
**Three build contexts shrank as a result.** `admin`, `sitesvc` and `vantagectl`
|
||||
build from their own directory now; only `server` still builds from the
|
||||
repository root, and only because its runtime stage copies `default_steps/`.
|
||||
One side effect worth knowing: `admin/.dockerignore` was inert while the context
|
||||
was the root, and is live now.
|
||||
**Two build contexts shrank as a result.** `sitesvc` and `vantagectl` build from
|
||||
their own directory now; only `server` still builds from the repository root,
|
||||
and only because its runtime stage copies `default_steps/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -762,11 +762,16 @@ all — the same requirement licence verification already meets.
|
||||
|
||||
### Marketing site and sitesvc
|
||||
|
||||
Every Paddle variable — `PADDLE_CLIENT_TOKEN`, `PADDLE_ENV`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` — now belongs to `vantage-admin` and is set there. None is read by anything in this repository.
|
||||
|
||||
`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`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
|
||||
|
||||
`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. 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.
|
||||
|
||||
**`ADMIN_ORIGIN` must list every browser origin that calls admin — currently two**: `https://vantage-hq.hostxtra.co.uk` for the console, and `https://vantage.hostxtra.co.uk` because the marketing site's `/start` form posts account signups to admin directly. It is comma-separated. A missing origin does not produce a 403: `cors()` simply omits the `Access-Control-Allow-Origin` header and still answers the preflight `204`, so the browser blocks the request and **admin logs nothing at all**. Symptom is a CORS preflight failure on an endpoint that works fine under curl.
|
||||
The HQ console is no longer built here at all — it is `web/` in
|
||||
`vantage-admin`, published as `3004` and 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`.
|
||||
|
||||
`sitesvc/` (port `8082`) now owns only the contact flow:
|
||||
|
||||
@@ -776,7 +781,7 @@ all — the same requirement licence verification already meets.
|
||||
|
||||
Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below.
|
||||
|
||||
`site`, `sitesvc`, `admin` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
`site`, `sitesvc` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead. So is Vantage HQ, which is now excluded by construction — it is a different repository with its own compose fragment.
|
||||
|
||||
### Documentation site
|
||||
|
||||
@@ -1017,67 +1022,31 @@ Free exists in both deployments, so it is no longer cloud-only by construction.
|
||||
|
||||
---
|
||||
|
||||
## Admin REST API (`admin`, :8083)
|
||||
## Vantage HQ (`vantage-admin`)
|
||||
|
||||
A separate service with its own session cookie (`admin_session`) and its own database. Unauthenticated:
|
||||
Lives in its own repository now, with its own session cookie (`admin_session`),
|
||||
its own database and its own console at `vantage-hq.hostxtra.co.uk`. Its REST
|
||||
surface, its Paddle integration and its `plans`/`catalogue`/`entitlements`
|
||||
model are documented there, not here.
|
||||
|
||||
```
|
||||
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=…
|
||||
POST /auth/accept-invite # an invitee sets their own password
|
||||
POST /api/paddle/webhook # Paddle events; signature-verified, idempotent, no session
|
||||
```
|
||||
What matters on this side is the small set of things it does to the control
|
||||
plane, each of which this codebase enforces:
|
||||
|
||||
Customer-session (`/api`), every instance resolved through `ownedInstance`:
|
||||
|
||||
```
|
||||
GET /account # account, instances, max_relinks
|
||||
POST /instances # create a cloud instance (Free tier, one Free per account per deployment)
|
||||
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
|
||||
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
|
||||
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/entitlement
|
||||
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
|
||||
POST /instances/self-hosted # link (or reuse) the install's real UUID for a paid checkout
|
||||
PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin)
|
||||
POST /billing/portal # mint a Paddle customer-portal URL
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
GET /subscriptions
|
||||
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
|
||||
PUT /account/password # propagates to every projected user
|
||||
GET,POST /instances/:id/members # cloud only
|
||||
PUT /instances/:id/members/:uid/role · DELETE /instances/:id/members/:uid
|
||||
```
|
||||
|
||||
Reading is open to any signed-in member; every mutation above except
|
||||
`/account/password` (which is your own) sits behind `RequireAccountRole(owner,
|
||||
admin)`. `:uid` is the **`customer_users.user_id`**, not the projected
|
||||
control-plane user_id — the portal never has to know that one.
|
||||
|
||||
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
|
||||
PUT /instances/:id/name # rename any instance, no cooldown
|
||||
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
|
||||
GET,PUT /catalogue
|
||||
GET,PUT /instances/:id/entitlement
|
||||
GET /health/injection · /health/billing
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
### Billing (Paddle)
|
||||
|
||||
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
|
||||
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. **Self-hosted purchase requires a standing control plane**: the customer pastes their install's real instance ID, `POST /instances/self-hosted` links it (or reuses one this account already owns, which is how Free upgrades to paid in place), and the checkout's `custom_data` names that UUID from the first event — so the webhook issues with no claim step and there is **no self-hosted placeholder**. A licence binds to the install's UUID, so buying before the install exists only ever deferred the same requirement behind a second identity to rewrite. `Placeholder` is now a cloud-only flag; a non-cloud placeholder reaching `handleSubscription` is a pre-change row and fails loudly rather than being guessed at.
|
||||
- **Licences are injected, not pasted.** `inject` writes three licence fields
|
||||
straight into `instances`. `GET /license` reports `deployment`, and **`POST
|
||||
/license` answers 409 `cloud_managed` when it is `cloud`** — the refusal
|
||||
cannot break injection, it only stops a customer pasting over a licence they
|
||||
do not own.
|
||||
- **Instances and owners are provisioned through `shared/provision`**, the same
|
||||
code path bootstrap uses, so there is one implementation of the slug rules
|
||||
and reserved names rather than two — see "Shared provisioning".
|
||||
- **Members are projected, not federated** — `users.auth_source: "hq"` with
|
||||
`hq_user_id` set, refused for role changes and deletion by
|
||||
`services.ErrHQManaged`. See "Grants project, they do not federate", which is
|
||||
the contract in full.
|
||||
- **`FREE_INSTANCE_REAP_AFTER` must match admin's value.** Admin names the date
|
||||
in its warning emails; this side performs the delete, because it is the only
|
||||
service that knows which collections carry `instance_id`.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
@@ -1102,11 +1071,13 @@ Notes that are not obvious from the structs:
|
||||
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
|
||||
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
|
||||
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. A row carries a `scope`: `plan` rows name a `deployment` and `tier` and belong to that plan alone, `shared` rows leave both empty and are sold by every paid plan. **How many rows a component needs follows from how many Paddle products it is** — the base fee is a different product per plan, every add-on is one product at one price, so the catalogue is four base rows plus five shared rows, nine instead of twenty-four, and an add-on's price ID is typed once rather than four times. `models.CatalogueFor` is the seam: it returns a plan's base row plus every shared row, and **nothing may filter the catalogue by `deployment` and `tier` itself** or it sees a plan priced by its base fee alone. `adminsite/lib/catalogue.ts`'s `rowsForPlan` is the TypeScript half of that and must change in the same commit, the same shape of hazard as `web/lib/targets.ts`. `models.MigrateSharedCatalogue` runs at boot after `SeedCatalogue`, merges the old per-plan copies onto the shared row and deletes them; it **refuses rather than guesses** when the four copies disagree, because four rows meant to be one price and are not is a pricing decision somebody made and picking one silently moves a customer's bill. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
|
||||
|
||||
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more shared `KindFeature` row in `SeedCatalogue`'s `seedRows` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on the row's natural key `(kind, deployment, tier, limit_key, feature_key)` — a shared row's empty deployment and tier are part of that key, not a wildcard — so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
|
||||
Admin's database is its own and lives with `vantage-admin` — `accounts`,
|
||||
`admin_instances`, `licenses`, `subscriptions`, `plans`, `catalogue`,
|
||||
`entitlements`, `paddle_events`, `staff_users`, `customer_users`,
|
||||
`instance_members`, `admin_audit`. Nothing here reads or writes it. Note in
|
||||
particular that `instance_members` is admin's _index_ of the control-plane
|
||||
`users` rows it projected, not the authority for them: the row in this
|
||||
database is the access.
|
||||
|
||||
### Migrations
|
||||
|
||||
@@ -1236,11 +1207,11 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
|
||||
|
||||
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
|
||||
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — 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 three more — `site` (3003), `sitesvc` (8082) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk. **Vantage HQ is a third file**, `deploy/docker-compose.yml` in the `vantage-admin` repository, which adds `admin` (8083) and `adminsite` (3004); the host composes all three together.
|
||||
|
||||
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
|
||||
|
||||
`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`. `server` now reads the same three, so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it, so in Docker those credentials remain admin's alone.
|
||||
`LICENSE_SIGNING_KEY` appears in **no compose file in this repository**, and must never be added to one: admin is the only signer, and it now lives in `vantage-admin` along with its own compose fragment. Neither `docker-compose.yml` nor `docker-compose.site.yml` should ever mention `admin` or `adminsite` again — the separation used to be a rule someone had to remember, and is now the repository boundary. `server` reads `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD` so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it.
|
||||
|
||||
---
|
||||
|
||||
@@ -1268,49 +1239,28 @@ All four apps are **one visual system**, anchored on the logo navy. What differs
|
||||
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
|
||||
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
|
||||
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
|
||||
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
| `adminsite/`\* | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
|
||||
`adminsite/app/globals.css` and `docsite/src/css/custom.css` hold `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
|
||||
\* `adminsite/` is `web/` in the **`vantage-admin` repository** now, and is listed here because the palette is one system across all four apps regardless of which repository they sit in.
|
||||
|
||||
`docsite/src/css/custom.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values — and so does `vantage-admin/web/app/globals.css`. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically** — and one of those four is now in another repository, so "the same commit" is no longer even possible. That makes this worse than it was, not better: the drift window is however long it takes to push twice.
|
||||
|
||||
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
|
||||
|
||||
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
|
||||
|
||||
Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
|
||||
Tailwind in all of them maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
|
||||
|
||||
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the three token blocks still be diffed by eye. `web/` also adds three tokens site/ has no use for: `--accent-hover` and `--down-hover` (site/ brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
|
||||
|
||||
`web/` is locked to dark and `adminsite/` defaults to **light**, and that pairing is the point: an operator with both open should never mistake one for the other before clicking Reissue. Now that both are drawn from the same palette the distinction rests **entirely** on the ground, so do not make dark the adminsite default and do not give web/ a light theme. State never reads by colour alone in either: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either.
|
||||
`web/` is locked to dark and the HQ console defaults to **light**, and that pairing is the point: an operator with both open should never mistake one for the other before clicking Reissue. Now that both are drawn from the same palette the distinction rests **entirely** on the ground, so do not make dark the HQ console's default and do not give web/ a light theme. State never reads by colour alone in either: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either.
|
||||
|
||||
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
|
||||
|
||||
**Plans and the catalogue are one page, `/staff/pricing`.** They were two nav
|
||||
entries and the split asked staff to hold one half in their head while looking
|
||||
at the other: a tier's allowance is what the metered component charges above,
|
||||
and a base fee means nothing without the allowance it includes. The page is
|
||||
`PlansSection` then `CatalogueSection`, in the order the decision is made —
|
||||
what a tier grants, then what it costs. `next.config.ts` keeps permanent
|
||||
redirects from `/staff/plans` and `/staff/catalogue`, which are bookmarked in
|
||||
staff browsers. **The tier list is cards, not forms**: six plans with five
|
||||
number fields, a select, a checkbox and four feature toggles each was forty-odd
|
||||
controls on one screen, and the page could not be read for the thing it exists
|
||||
to answer. A card states what the tier grants and `Modal` — a native
|
||||
`<dialog>`, for the focus trap and Escape handling a hand-rolled overlay gets
|
||||
wrong — is where it is changed. Every feature key renders on every card, lit or
|
||||
unlit: no tier bundles one today, so the unlit row is the information.
|
||||
|
||||
**The catalogue's coverage ledger is not decoration.** A missing production
|
||||
price is invisible in a grid of text inputs — every cell looks like every other
|
||||
until twenty-six characters of each are read — and it is the one thing staff
|
||||
come to the page to check before a launch, so each component draws one filled
|
||||
or empty square per environment and term.
|
||||
|
||||
**The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan.
|
||||
|
||||
Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions.
|
||||
|
||||
`InstanceRecord` is one component open or closed, and it **replaced** `InstanceCard`. Closed it is a row; open it adds licence contents, members and actions. It defaults open when the instance is the only one or needs attention, and a manual toggle is remembered per instance in `localStorage`. Do not reintroduce a second summary component — the split is what left a one-instance account showing a third of a row and nothing else.
|
||||
The HQ console's own shell, its `/staff/pricing` page and the catalogue coverage
|
||||
ledger are documented in `vantage-admin`. They are still built from these
|
||||
tokens, which is the only reason they are mentioned here at all.
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
@@ -1364,7 +1314,7 @@ GOOS=linux GOARCH=amd64 go build \
|
||||
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`. **`vantagectl` is deliberately not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
|
||||
Builds and pushes five images to the Gitea container registry: `server`, `web`, `site`, `sitesvc` and `docsite`. **`admin` and `adminsite` are no longer among them** — they are built by `vantage-admin`'s own workflow, as `vantage/vantage-admin/server` and `vantage/vantage-admin/web`. **`vantagectl` is deliberately not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
|
||||
|
||||
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
|
||||
|
||||
@@ -1378,9 +1328,8 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
|
||||
| Image | Rebuilds when |
|
||||
| ---------------------------- | -------------------------------- |
|
||||
| `server` | `server/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
|
||||
| `web` · `site` · `docsite` | their own directory only |
|
||||
|
||||
**No path in this table names `shared/` any more**, and no fan-out rule replaces
|
||||
it: `vantage-shared` is an external module pinned per service, so a service
|
||||
@@ -1435,16 +1384,11 @@ git push origin main # server + web deploy
|
||||
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
|
||||
| `SITE_URL` | Variable | browser URL of the marketing site. Also set in `vantage-admin`, where it is baked into the console so `/login` can point at `/start`. **Signup has no page in the console at all** — one signup form, on `site/`. |
|
||||
| `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 |
|
||||
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into the `site` image — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. `vantage-admin` sets a variable of the same name for its own console; they must agree. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
| `PADDLE_CLIENT_TOKEN` | Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. |
|
||||
| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. |
|
||||
| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. |
|
||||
|
||||
Reference in New Issue
Block a user