Compare commits

...
82 Commits
Author SHA1 Message Date
mrhid6 574057811f chore: remove go.work
Chart Release / chart (push) Successful in 17s
Server Deploy / deploy (push) Successful in 2m33s
2026-09-08 09:10:48 +00:00
mrhid6 e0cc3988fc refactor: move vantagectl to vantage-ctl
vantagectl/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-ctl.
The command keeps the name vantagectl; only the repository and the image
path change, to vantage/vantage-ctl.

chart-release.yml's render checks are repointed at the new image. The
chart itself has no default backup.image and fails loudly without one, so
an existing cluster keeps working until someone changes the value.

go.work stays, now with a single use ./server entry: without it a go.work
further up the developer's filesystem is picked up instead.
2026-09-08 09:07:14 +00:00
mrhid6 44d9036440 refactor: move the agent and its installer to vantage-agent
agent/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-agent,
with installer/ alongside it, and agent-release.yml goes with them.

Releases now come from that repository, so the six places this server
generates or reads a release URL are repointed: both install scripts,
both update scripts, and the latest-version lookup in dispatch.go. The
agent/v* tag prefix is unchanged — those scripts grep for it.

Agents built before this move have the old mrhid6/vantage path compiled
into their self-update and will 404 on the push-button update. The
remedy is the /update one-liner, which this server generates and which
therefore has to ship first.
2026-09-08 09:00:22 +00:00
mrhid6 1c6d9e8495 refactor: move proto/ to vantage-shared
vantage.proto documents the hand-written types in shared/grpc/pb, and
nothing compiles it. Keeping it in a different repository from the Go
types it describes meant the one rule holding them together — add the
message to both in the same commit — could not be followed at all.

server's rebuild trigger loses proto/, which it only carried as
insurance against exactly that split.
2026-09-08 08:44:47 +00:00
mrhid6 f9dec9b230 refactor: move the public host out to vantage-site and vantage-docs
site/ and sitesvc/ become web/ and server/ in vantage-site; docsite/
becomes the root of vantage-docs. Their images move with them, to
vantage/vantage-site/{web,server} and vantage/vantage-docs.

Nothing here imported any of them, and sitesvc turned out to read no
database at all, so both cuts are clean. docker-compose.site.yml is
deleted rather than emptied: every service it held now ships with the
repository that builds it, and deploy/docker/docker-compose.yml is once
again exactly a self-hosted install.

Corrects four comments that named sitesvc for work it no longer does.
2026-09-08 08:41:11 +00:00
mrhid6 872699c38c 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.
2026-09-08 08:13:55 +00:00
mrhid6 eb32d367c8 feat: Removed comments in workflow
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 4m44s
2026-09-08 07:47:42 +00:00
mrhid6 a134f8443a fix: download vantage-shared before asking the module cache where it is
go list -m -f '{{.Dir}}' reports an empty Dir and exits 0 for a module that
is not in the cache, so on a cold runner swag was handed an empty --dir and
failed several steps later with 'dir:  does not exist'. Download first, and
fail loudly if the path is still not there.
2026-09-08 07:47:00 +00:00
mrhid6 ee1f9f3b32 refactor: consume vantage-shared as an external private module
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Failing after 1m11s
shared/ is extracted to gitea.hostxtra.co.uk/vantage/vantage-shared and
pinned at v0.1.0 by server, agent, admin, sitesvc and vantagectl. The
replace directives and the ./shared entry in go.work are gone.

Every Go build now needs a credential for the private module: CI writes a
netrc per job from REGISTRY_USER + RELEASE_TOKEN and sets GOPRIVATE, and
the four Go Dockerfiles take it as a BuildKit secret rather than a build
arg, which would survive in the builder layer's history. RELEASE_TOKEN
needs read access to the vantage org.

admin, sitesvc and vantagectl now build from their own directory; only
server still needs the repository root, for default_steps/. The rebuild
triggers in server-deploy.yml lose their shared/ patterns, since a
service now moves when its own go.mod pin does.
2026-09-08 07:42:58 +00:00
mrhid6 5326639918 fix: declare grpc dependency in shared go.mod
The workspace supplied it; a standalone build of the module could not
resolve google.golang.org/grpc at all.
2026-09-08 07:36:13 +00:00
mrhid6 3bf80a117b refactor: rename shared module to gitea.hostxtra.co.uk/vantage/vantage-shared 2026-09-08 07:34:53 +00:00
mrhid6 189a8fa963 feat: Move grpc pbs to shared
Chart Release / chart (push) Successful in 21s
Agent Release / build (push) Successful in 3m16s
Server Deploy / deploy (push) Successful in 5m32s
Agent Release / msi (push) Successful in 3m25s
2026-09-07 15:50:25 +00:00
mrhid6 7b13944c24 fix: Build the vantagectl image on its release tag, not on every push to main
Chart Release / chart (push) Successful in 29s
vantagectl Release / build (push) Successful in 1m28s
vantagectl Release / image (push) Successful in 1m52s
Server Deploy / deploy (push) Successful in 3m46s
vantagectl is a released tool, not a running service. An operator restoring a
database should be able to name the version they ran, and ":latest, rebuilt
whenever main moved" cannot be named after the fact.

The image now builds in vantagectl-release.yml on a vantagectl/v* tag, tagged
with that version as well as latest, with VERSION passed through so the binary
inside reports the tag rather than "dev". server-deploy.yml no longer builds
it and is back to seven images.

The cost is that a shared/ fix reaches the image only at the next release
rather than the next push to main. That is the intended trade and is written
down in CLAUDE.md next to the trigger table.
2026-09-07 15:11:39 +00:00
mrhid6 e7384d334a feat: Add vantagectl, the control plane backup and restore CLI
Chart Release / chart (push) Successful in 33s
vantagectl Release / build (push) Successful in 5m56s
Server Deploy / deploy (push) Successful in 9m40s
Vantage encrypts SSH private keys, vault secrets, SSO client secrets and
console credentials with KEY_ENCRYPTION_KEY. That key is in no backup and is
not recoverable, so restoring a database without it produces a control plane
whose every secret is permanently unreadable.

vantagectl dumps and restores a whole database and stamps a SHA-256
fingerprint of the key into every archive, so a restore refuses rather than
producing that database. The key itself is never written.

- shared/cryptobox: AES-GCM extracted so the server and the CLI share one
  implementation rather than a copy that can drift
- shared/backup: manifest, tar+gzip archive with per-member checksums
  verified before the first write, dump, restore, verify
- vantagectl: its own module, so cobra stays out of the server, admin and
  sitesvc module graphs
- container image, tagged release workflow, CI rebuild trigger
- optional Helm CronJob, off by default
- operator documentation, reconciled with the existing backups page
2026-09-07 14:52:13 +00:00
mrhid6 be299845ca fix: document inspect and --confirm-db's actual behaviour 2026-09-07 14:45:37 +00:00
mrhid6 d83061786c fix: validate manifest collection names and route archive accessors through safeJoin 2026-09-07 14:45:37 +00:00
mrhid6 b28a2263bb fix: read KEY_ENCRYPTION_KEY in archive-only verify; never leave a partial archive 2026-09-07 14:45:36 +00:00
mrhid6 b6f45390c4 fix: correct verify's ciphertext field map against the models 2026-09-07 14:45:36 +00:00
mrhid6 2e4bc687d4 fix: replay index specs verbatim instead of reconstructing them 2026-09-07 14:45:36 +00:00
mrhid6 d328d3aaca docs: Reconcile backups and backup-and-restore pages
backups.md kept its store-level table and danger note but now points to
vantagectl as the supported path, with mongodump/mongorestore demoted to
an explicit fallback and a warning that a plain dump records no key
fingerprint. backup-and-restore.md links back for the store-level
overview.
2026-09-07 14:32:31 +00:00
mrhid6 89cc524f3f docs: Document backup and restore
The page leads with KEY_ENCRYPTION_KEY rather than mentioning it in a
note, because holding a good database dump and no key is the way this goes
wrong.
2026-09-07 14:29:03 +00:00
mrhid6 f0f2600bed feat: Add an optional scheduled backup CronJob to the chart
Off by default: a backup with nowhere durable to land is a false sense of
safety and the chart cannot know where that is. NOTES.txt says so when it
is off.

No restore manifest ships: a restore must never be something a helm
upgrade can trigger.
2026-09-07 14:17:42 +00:00
mrhid6 884fd189fb fix: Report the real reason verify falls back to archive-only checks
resolveGlobals can fail for two distinct reasons — no MongoDB URI, or no
resolvable database name — and verify.go was printing a hardcoded
no-URI note regardless of which one occurred, misleading an operator
whose URI was fine but whose database name could not be resolved.
2026-09-07 14:11:58 +00:00
mrhid6 e08cc2f928 feat: Build and publish vantagectl
The scratch runtime stage copies an explicit /tmp: restore extracts an
archive there before verifying it, and a scratch image has none.

shared/ now fans out to four Go images rather than three.
2026-09-07 14:09:00 +00:00
mrhid6 3ce963b0cd feat: Add the vantagectl restore and verify subcommands
--force requires a typed database name on a terminal and --confirm-db
without one, so a copy-pasted restore command carries its intended target
and cannot destroy a different database.

Also silences cobra's own error print (root.go) so a failure is reported
once by main.go instead of twice, and pins the Changed()-based env
fallback in resolveGlobals with a test for an explicitly empty --db.
2026-09-07 14:04:25 +00:00
mrhid6 461a79277d feat: Add the vantagectl backup and inspect subcommands
Progress output goes to stderr so --out - stays a clean pipe into restic,
age or aws s3 cp. Archive names carry no colon, because these get copied
onto Windows shares.
2026-09-07 13:57:04 +00:00
mrhid6 d9f7fa6993 feat: Add the vantagectl module and its cobra root
Its own module rather than a package under shared, so cobra and pflag stay
out of the module graphs of server, admin and sitesvc, which never use
them.
2026-09-07 13:45:20 +00:00
mrhid6 060fa64339 feat: Add backup verify with a live decrypt probe
A fingerprint comparison proves two archives agree about a key. Only
opening real ciphertext from the target proves the key in hand reads the
data, which is the question an operator actually has.
2026-09-07 11:26:34 +00:00
mrhid6 30d83c4c32 feat: Add the backup restore
Every refusal happens before the first write: format, checksums, key
policy, then target inspection. A unique index that will not build aborts,
because the unique indexes here are tenant-isolation properties rather
than optimisations.
2026-09-07 11:23:27 +00:00
mrhid6 a918b1bdc1 feat: Add the backup dump
Collections are enumerated live rather than from a list, so a collection
added later is backed up with no code change. Documents are written as the
raw BSON the driver returned, so Decimal128, ObjectId, DateTime and binary
subtypes survive byte for byte.
2026-09-07 11:16:38 +00:00
mrhid6 66b1a041ba feat: Add the backup archive writer and reader
Open extracts and verifies every member against the manifest before the
reader is usable, so a corrupt archive is refused before a restore writes
its first document rather than halfway through.
2026-09-07 11:10:17 +00:00
mrhid6 d55ed2b19a feat: Add the backup archive manifest
KeyFingerprint is a pointer so an archive that recorded no key is a state
restore can report, not a default it silently treats as a match.
2026-09-07 11:08:02 +00:00
mrhid6 8135c8d781 feat: Add key fingerprinting for backup archives
Fingerprint hashes the raw key bytes rather than the hex string, so the
same key written in different cases fingerprints identically.
2026-09-07 11:03:48 +00:00
mrhid6 577b060b8a feat: Extract AES-GCM into shared/cryptobox
services/crypto.go keeps its function names and its KEY_ENCRYPTION_KEY
lookup and delegates the cipher, so vantagectl's verify probe can decrypt
with the same implementation rather than a second copy.
2026-09-07 10:55:54 +00:00
mrhid6 1028a2e43a docs: Add the backup-restore implementation plan
Thirteen tasks, each ending in a testable deliverable and a commit.

Also drops --log-level from the spec: the tool's whole output is what it is
telling the operator, and a level that could hide a key warning is worth not
having.
2026-09-07 10:30:56 +00:00
mrhid6 eba93a812e docs: Correct Dockerfile location and specify the verify probe
vantagectl/Dockerfile follows the repo's per-module convention rather than
living under deploy/docker.

verify's live check needs AES-GCM open, so the cipher primitives move to
shared/cryptobox and services/crypto.go delegates, rather than a second
copy of the cipher existing in another module.
2026-09-07 10:10:04 +00:00
mrhid6 83b7256b60 docs: Design for control plane backup and restore
Standalone vantagectl CLI (cobra, own module) that dumps and restores a
whole Vantage MongoDB database, stamping a sha256 fingerprint of
KEY_ENCRYPTION_KEY into the manifest so a restore cannot silently produce
a database whose secrets are unreadable.

The key itself never enters the archive.
2026-09-07 10:07:50 +00:00
mrhid6 9d17f539b5 fix: Fixed card header margin
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 1m0s
2026-09-07 08:36:18 +00:00
mrhid6 28f746c7e2 feat: Compact vitals
Chart Release / chart (push) Successful in 27s
Server Deploy / deploy (push) Successful in 1m4s
2026-09-07 08:26:07 +00:00
mrhid6 9d218cb19f feat: Reduce server vitials panel height
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m10s
2026-09-07 08:14:47 +00:00
mrhid6 f9ef9c4929 fix: Fixed mobile scroll bar
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m17s
2026-09-07 08:02:09 +00:00
mrhid6 049e005873 feat: Updated monitor chart
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 1m9s
2026-08-25 15:02:04 +00:00
mrhid6 c440b59b93 feat: Updated affected components on status page incidents
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Canceled after 6m59s
2026-08-25 14:56:50 +00:00
mrhid6 3e4865884c feat: Updated plans and catalogue pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m58s
2026-08-25 14:35:47 +00:00
mrhid6 270d55e6a6 feat: Updated status page title 2026-08-25 14:11:21 +00:00
mrhid6 7a0a1953f6 fix: Fixed api url on web
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 7m37s
2026-08-25 13:53:59 +00:00
mrhid6 3e4ccc9720 feat: Added more debug logging
Chart Release / chart (push) Successful in 16s
Server Deploy / deploy (push) Successful in 7m1s
2026-08-25 13:26:23 +00:00
mrhid6 e5947489e4 fix: Fixed status page published switch 2026-08-25 13:26:10 +00:00
mrhid6 0a7a10aeed feat: Added status pages to license page 2026-08-25 13:10:28 +00:00
mrhid6 28b813ba64 feat: sell status pages as a per-instance licence feature
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 9m30s
2026-08-25 09:25:05 +00:00
mrhid6 68160dc681 docs: correct the status page URL for self-hosted, trim to what ships
- The status page URL was given only as `<instance>.vantage.<tld>`, which a
  self-hosted install does not serve. Both deployments are now described.
- The banner is documented as one notice: the editor exposes no level picker
  and the view renders every level identically.
- `pending` added to the component states, which a monitor with no result yet
  renders.
- Delete page documented alongside un-publish.
- `TRUSTED_PROXIES` names the LAN case: with the RFC1918 default, a client on a
  private range reaching the server directly is itself trusted and can spoof
  `X-Forwarded-For` — and now `X-Forwarded-Host`. Narrow it to the proxy.
- CLAUDE.md: scopes are nine resources, not eight; `status-pages` added to the
  REST route table; the host-resolution rules recorded under Status pages.
2026-08-25 09:05:06 +00:00
mrhid6 32e7420d89 fix: wire status page and incident delete, stop promising a name we do not publish
- The display-name placeholder showed the monitor's own name, reading as "leave
  this blank and we will use it". The server deliberately does the opposite: a
  blank `display_name` publishes the raw monitor id, because publishing an
  internal name has to be a decision. The placeholder now says "Public name
  (required)" and Save is refused until every component has one, so nobody adds
  five monitors and discovers five UUIDs on their public page. The server
  fallback is unchanged.
- `deleteStatusPage` and `deleteStatusIncident` existed in the api client and
  were wired to nothing, and the page address is immutable — delete was the
  only correction for a typo and there was no way to reach it. The editor
  header gains a typed-confirmation Delete page, and each incident row a
  confirmed delete, both on the existing ConfirmDialog.
- The create modal's address hint had lost its em dash and read as a broken
  sentence.
2026-08-25 09:05:06 +00:00
mrhid6 7e1d67dba4 fix: file live outages as active, refuse "operational" over zero components
- A derived monitor outage with no `resolved_at` went to `History`, so an
  ongoing disruption was listed under "Past incidents" while the component pill
  beside it read Down. Unresolved now goes to `ActiveIncidents`.
- `overallState` returned `up` when nothing was counted: "all systems
  operational" claimed from no evidence at all. A page with no components now
  reports `no_data`, which the view already renders as "Status unknown".
- `EnsureStatusPageIndexes` returned on the first failure, so a transient
  failure on the `status_pages` index left `status_incidents` with no unique
  `(instance_id, incident_id)` index — a correctness property, not a scan
  optimisation. All three are attempted and the failures joined.
2026-08-25 09:04:54 +00:00
mrhid6 da1dc90ac5 fix: resolve the public status page's tenant from a trusted X-Forwarded-Host
The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
2026-08-25 09:04:47 +00:00
mrhid6 72c9492223 docs: status pages 2026-08-25 08:44:38 +00:00
mrhid6 fa67d839cd fix: status page editor error handling, maintenance validation and UTC display 2026-08-25 08:40:05 +00:00
mrhid6 1452928b75 feat: status page authoring UI 2026-08-25 08:32:43 +00:00
mrhid6 bf10023f35 fix: empty slices rather than null on the unavailable status snapshot 2026-08-24 19:42:16 +00:00
mrhid6 f4f41e400b feat: public status page 2026-08-24 14:53:42 +00:00
mrhid6 3abbdc41d6 feat: status page authoring API
Adds owner|admin routes under /api/status-pages for authoring status pages
and their incidents/maintenance windows, gated by the status_pages licence
feature. Adds the "status" token scope resource and the ten route-scope
entries, and regenerates the committed OpenAPI document.

Also types ErrPageInvalid as a sentinel for status page/incident validation
failures (previously bare errors), so statusPageError maps them to 400
instead of 500, and createStatusIncident/updateStatusIncident route through
the shared error mapper rather than hand-rolling a 400 for any service error.
2026-08-24 14:43:17 +00:00
mrhid6 6ba54f690c fix: default TRUSTED_PROXIES in shipped deployments and route /public/ through ingress 2026-08-24 14:35:29 +00:00
mrhid6 a3c6b2a305 feat: public status page endpoint with per-address rate limit 2026-08-24 14:29:24 +00:00
mrhid6 21a2d077d8 feat: cached public status snapshot with licence gate 2026-08-24 14:24:07 +00:00
mrhid6 6263c7e16f fix: clear resolved_at when reopening a status incident via appended update 2026-08-24 14:21:26 +00:00
mrhid6 161835802d feat: authored status incidents and maintenance windows 2026-08-24 14:18:06 +00:00
mrhid6 6f998ff506 feat: status page CRUD and cache invalidation 2026-08-24 14:15:25 +00:00
mrhid6 d192589790 fix: maintenance repaint no longer zeroes no_data uptime; strengthen redaction test 2026-08-24 14:12:38 +00:00
mrhid6 21c2bb2646 feat: public status snapshot assembly and redaction boundary 2026-08-24 14:07:10 +00:00
mrhid6 9c0bbd13dd feat: status page id validation and cache key 2026-08-24 14:02:46 +00:00
mrhid6 1c15961309 feat: status page schema, licence feature and indexes 2026-08-24 14:00:00 +00:00
mrhid6 383b763a66 docs: attach approved status page mockup to the implementation plan 2026-08-24 13:52:07 +00:00
mrhid6 3e99a9df33 docs: public status pages implementation plan 2026-08-24 13:42:44 +00:00
mrhid6 f1b6f90345 docs: public status pages design spec 2026-08-24 13:32:24 +00:00
mrhid6 2a660697c5 docs: Updated troubleshotting doc
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m4s
2026-08-24 12:50:44 +00:00
mrhid6 0c08dda635 feat: Useragent
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 6m5s
2026-08-24 12:17:46 +00:00
mrhid6 22b99ff895 feat: Monitor grath zoom
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 6m31s
2026-08-24 10:58:50 +00:00
mrhid6 2fab784ba7 feat: Monitor groups and chart information
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 6m35s
2026-08-24 10:30:23 +00:00
mrhid6 83cdf92575 feat: Updated edit monitor page
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 43s
2026-08-24 09:25:43 +00:00
mrhid6 aa1c8e4aa1 feat: Hide secrets on api and channels
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 8m5s
2026-08-14 12:23:36 +00:00
mrhid6 ac61015cc0 fix: Fixed incorrect openapi doc
Chart Release / chart (push) Canceled after 0s
Server Deploy / deploy (push) Successful in 9m51s
2026-08-13 13:04:44 +00:00
mrhid6 a0fbf5b9ba fix: Word and colour Windows workloads correctly across the UI
Server Deploy / deploy (push) Canceled after 0s
Chart Release / chart (push) Canceled after 0s
Agent Release / build (push) Successful in 14m1s
Agent Release / msi (push) Successful in 1m22s
2026-08-13 12:57:37 +00:00
mrhid6 ddf0814803 docs: Fix Windows package-inventory and poll-loop claims in CLAUDE.md 2026-08-13 12:28:51 +00:00
412 changed files with 16074 additions and 65056 deletions
-117
View File
@@ -1,117 +0,0 @@
name: Agent Release
on:
push:
tags:
- "agent/v*"
jobs:
build:
runs-on: ubuntu-docker
container: node:26
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: agent/go.sum
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT
- name: Build
working-directory: agent
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
mkdir -p dist
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-amd64 ./cmd
GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-arm64 ./cmd
GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-windows-amd64.exe ./cmd
- name: Checksums
working-directory: agent/dist
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: |
agent/dist/vantage-agent-linux-amd64
agent/dist/vantage-agent-linux-arm64
agent/dist/vantage-agent-windows-amd64.exe
agent/dist/checksums.txt
msi:
needs: build
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: agent/go.sum
- name: Extract version
id: version
shell: pwsh
run: |
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
# MSI ProductVersion must be numeric x.x.x.x
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
- name: Build agent exe
working-directory: agent
shell: pwsh
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
$env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o ../installer/vantage-agent-windows-amd64.exe ./cmd
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.*
- name: Build MSI
working-directory: installer
shell: pwsh
run: |
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
- name: Attach MSI to release
working-directory: installer
shell: pwsh
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$tag = [uri]::EscapeDataString("${{ github.ref_name }}")
$headers = @{ Authorization = "token $env:TOKEN" }
# gitea-release-action can't find a slashed tag, so append via the API directly
$rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
foreach ($f in "vantage-agent.msi", "checksums-msi.txt") {
$name = [uri]::EscapeDataString($f)
Invoke-RestMethod -Headers $headers -Method Post -InFile $f `
-ContentType "application/octet-stream" `
-Uri "$api/releases/$($rel.id)/assets?name=$name"
}
+18
View File
@@ -76,6 +76,13 @@ jobs:
fi
echo "ok: reaper configured in cloud mode only"
- name: Render with backups enabled
run: |
helm template test "$CHART_DIR" \
--set backup.enabled=true \
--set backup.image=gitea.hostxtra.co.uk/vantage/vantage-ctl:latest \
--set backup.pvcName=vantage-backups > /dev/null
- name: Render against external Redis and MongoDB
run: |
helm template test "$CHART_DIR" \
@@ -140,10 +147,21 @@ jobs:
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set server.env.grpcHost=agents.example.com:443
refuses "an ingress that leaves /api unrouted" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=false
refuses "gRPC ingress while grpcHost is still in-cluster" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.host=agents.example.com
refuses "backup enabled with no pvcName" \
--set backup.enabled=true \
--set backup.image=gitea.hostxtra.co.uk/vantage/vantage-ctl:latest
refuses "backup enabled with no image" \
--set backup.enabled=true \
--set backup.pvcName=vantage-backups
- name: Read the chart version
id: chart
+45 -73
View File
@@ -6,8 +6,8 @@ on:
- main
# Manual runs rebuild everything: there is no "before" commit to diff
# against, which the change detection below treats as "build it all". That
# is also the escape hatch for a repo VARIABLE change — editing API_URL or
# ADMIN_ENV pushes no commit, so nothing would rebuild on its own.
# is also the escape hatch for a repo VARIABLE change — editing HQ_URL
# pushes no commit, so nothing would rebuild on its own.
workflow_dispatch:
jobs:
@@ -71,23 +71,38 @@ jobs:
fi
}
# The three Go images build from the repo root and COPY
# shared/ plus their own directory, so shared/ rebuilds all
# three. proto/ is in server's list as insurance: the
# generated pb is committed under server/, but a proto change
# that someone regenerates in the same push should not depend
# on that ordering.
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
flag sitesvc '^(sitesvc/|shared/|go\.work)'
flag admin '^(admin/|shared/|go\.work)'
# shared/ is gone from this repository: it is the private
# module gitea.hostxtra.co.uk/vantage/vantage-shared, pinned
# per service in its own go.mod. A change over there reaches
# a service when somebody bumps that pin, which is a commit
# under the service's own directory and so already matches
# below. There is no longer a directory whose change fans out
# to three images, and no longer a way to ship a service
# against a shared/ it was never built with.
#
# proto/ is gone too. It documents the wire types, and it
# moved to sit beside the hand-written pb it describes, so
# that a message added to one is added to the other in the
# same commit. Nothing here reads it.
flag server '^(server/|default_steps/|go\.work)'
# The three Next images and the docs site use their own
# directory as the build context, so nothing outside it can
# affect them.
# web/ uses its own directory as the build context, so
# nothing outside it can affect it. It is the only front end
# left here: the marketing site and the docs went to
# vantage-site and vantage-docs, the HQ console to
# vantage-admin.
flag web '^web/'
flag site '^site/'
flag adminsite '^adminsite/'
flag docsite '^docsite/'
# vantage-shared is private, so every Go build below needs a
# credential for it. A netrc is written once here rather than a
# token being passed as a build arg, which would survive in the
# builder layer's history.
- name: Write the module fetch credential
run: |
umask 077
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
> "$HOME/.netrc"
- name: Log in to registry
run: |
@@ -102,14 +117,23 @@ jobs:
go-version: "1.26"
cache: true
cache-dependency-path: server/go.sum
env:
GOPRIVATE: gitea.hostxtra.co.uk/*
- name: Verify the OpenAPI document is current
if: steps.changed.outputs.server == 'true'
env:
GOPRIVATE: gitea.hostxtra.co.uk/*
run: |
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
cd server
swag init --generalInfo cmd/main.go --dir ./,../shared \
--output internal/api/docs --outputTypes json --v3.1
go mod download gitea.hostxtra.co.uk/vantage/vantage-shared
SHARED_DIR="$(go list -m -f '{{.Dir}}' gitea.hostxtra.co.uk/vantage/vantage-shared)"
if [ -z "$SHARED_DIR" ] || [ ! -d "$SHARED_DIR" ]; then
echo "vantage-shared source not in the module cache: '$SHARED_DIR'" >&2
exit 1
fi
swag init --generalInfo cmd/main.go --dir "./,$SHARED_DIR" --output internal/api/docs --outputTypes json --v3.1
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
git diff --exit-code internal/api/docs/openapi.json
@@ -117,8 +141,8 @@ jobs:
if: steps.changed.outputs.server == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
# Root context: server depends on the shared module.
docker build -t "$IMAGE" -f server/Dockerfile .
docker build --secret id=netrc,src="$HOME/.netrc" \
-t "$IMAGE" -f server/Dockerfile .
docker push "$IMAGE"
- name: Build and push web image
@@ -131,55 +155,3 @@ jobs:
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Build and push site image
if: steps.changed.outputs.site == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
-t "$IMAGE" \
-f site/Dockerfile site/
docker push "$IMAGE"
- name: Build and push sitesvc image
if: steps.changed.outputs.sitesvc == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
# Root context: sitesvc depends on the shared module.
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
docker push "$IMAGE"
- name: Build and push admin image
if: steps.changed.outputs.admin == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
# Root context: admin depends on the shared module.
docker build -t "$IMAGE" -f admin/Dockerfile .
docker push "$IMAGE"
- name: Build and push adminsite image
if: steps.changed.outputs.adminsite == 'true'
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 }}" \
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
-t "$IMAGE" \
-f adminsite/Dockerfile adminsite/
docker push "$IMAGE"
- name: Build and push docsite image
if: steps.changed.outputs.docsite == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
# DOCS_BASE_URL must match the proxy location that routes to
# this container and the directory the image serves from.
docker build \
-t "$IMAGE" \
-f docsite/Dockerfile docsite/
docker push "$IMAGE"
+456 -238
View File
@@ -39,18 +39,6 @@ Multi-tenancy: every domain document carries `org_id`, and every service query i
```
vantage/
├── agent/
│ ├── cmd/main.go # flags: -generate-key
│ └── internal/
│ ├── checker/ # monitor check execution
│ ├── config/ # config.yaml load/save
│ ├── exec/ # workflow step execution
│ ├── grpc/ # client + generated pb
│ ├── inventory/ # CPU/mem/disk collection (linux/other)
│ ├── keys/ # authorized_keys read/diff/write
│ ├── monitors/ # agent-run monitor loop
│ ├── sync/ # poll loop + command stream
│ └── updates/ # OS package update check/apply
├── server/
│ ├── cmd/main.go
│ └── internal/
@@ -68,48 +56,76 @@ vantage/
│ ├── app/login, app/setup # unauthed routes
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
│ └── lib/ # api client, guac console, query client
├── site/ # public marketing site
│ ├── app/ # one directory per route
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
│ ├── assets/ # image sources, not served
│ └── Dockerfile # same shape as web/: standalone, node, 3000
├── sitesvc/ # public form: contact mail only
│ ├── cmd/main.go
│ └── 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, plans, 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-*
│ ├── sidebars.ts # authored by hand, not autogenerated
│ └── nginx.conf # serves the build under /docs
├── shared/ # imported by server, sitesvc and admin
│ ├── mail/ # the one email system: transport + tmpl templates
│ ├── 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
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
├── deploy/ # docker-compose.yml, Helm chart
└── .gitea/workflows/ # server-deploy.yml, chart-release.yml
```
**Five repositories carry parts of Vantage that this one does not.** What is
left here is the control plane and its UI, and nothing else.
| 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-site` | the marketing site (`web/`, was `site/`) and its contact-form service (`server/`, was `sitesvc/`) |
| `vantage-docs` | the user documentation, at the repository root (was `docsite/`) |
| `vantage-agent` | the agent, at the repository root (was `agent/`), and the Windows `installer/` |
| `vantage-ctl` | `vantagectl`, the backup and restore CLI, at the repository root (was `vantagectl/`) |
**None of the three is a build dependency of anything here**, and nothing here
is a dependency of them. `vantage-site` and `vantage-docs` are wholly
independent — the contact-form service stores nothing and reads no database, so
the split cost nothing. `vantage-admin` is the only one with a live coupling,
and there is still 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/`, and `proto/vantage/v1/vantage.proto`, which documents `grpc/pb`
and moved there to sit beside it. **One** module here depends on it — `server`
pinning a version in its own `go.mod`, as do `vantage-admin`, `vantage-site`,
`vantage-agent` and `vantage-ctl`. 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 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
`shared/` it was never built against. The cost is the obvious one: a fix in
`vantage-shared` is live nowhere until each consumer's pin is bumped, and
nothing in this repository will remind you.
Every Go build now needs a credential for it — `GOPRIVATE=gitea.hostxtra.co.uk/*`
plus a netrc. CI writes one per job from `REGISTRY_USER` + `RELEASE_TOKEN`
(**that token needs read access to the `vantage` org, not only `mrhid6`**), and
the four Go Dockerfiles take it as a **BuildKit secret** rather than a build
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/`.
**`server` still builds from the repository root**, and only because its runtime
stage copies `default_steps/`. It is the only image this repository builds from
a context wider than one directory.
`go.work` survives with a single `use ./server` entry. That looks pointless and
is not: without it, a `go.work` further up the developer's filesystem is picked
up instead and the build fails with `directory prefix . does not contain modules
listed in go.work`.
---
## Subsystems
@@ -383,9 +399,11 @@ one wire shape, worded per platform in the UI, which is the only layer that
knows the host's OS. The platform split lives entirely in the agent, as build
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
and `logs_` pairs); the control plane is OS-blind and needed no changes.
Windows collection runs PowerShell through `agent/internal/winexec`, and every
script emits JSON that a build-tag-free parser reads, so the parsers are tested
on Linux — the agent module has no Windows CI.
Windows collection runs PowerShell through the agent's `internal/winexec`. Every
script that reports data emits JSON that a build-tag-free parser reads, so
those parsers are tested on Linux — the agent module has no Windows CI. The
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
are exercised only by running the agent on Windows.
**Not gated by licence**: this reads as core fleet management, so v1 ships
everywhere with no `HasFeature` check. If that changes the check belongs at
@@ -437,9 +455,9 @@ than an empty list.
Logs are capped at **500 lines and 256KB, whichever binds first** — a line count
alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The
cap is mirrored in `services.MaxWorkloadLogLines` because `agent/` is a separate
module with an `internal/` tree and the constant cannot be shared; change one,
change the other. There is **no follow mode**: the browser console already gives
cap is mirrored in `services.MaxWorkloadLogLines` because the agent is a
separate module — a separate repository now — with an `internal/` tree, and the
constant cannot be shared; change one, change the other. There is **no follow mode**: the browser console already gives
a real terminal where `docker logs -f` works properly. Log reads and control
actions are **owner|admin and audited**, unlike the read-only snapshot — a
container's stdout is arbitrary and cannot be masked the way a workflow's can.
@@ -448,15 +466,195 @@ container's stdout is arbitrary and cannot be masked the way a workflow's can.
is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). There
is no history: a workload list is state, not a record.
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
A message added to one must be added to the other and to the `.proto`, in the
same commit.
**The wire contract is not in this repository at all.** `shared/grpc/pb` is
hand-written JSON-tagged structs over the custom codec in `shared/grpc/codec`,
and `proto/vantage/v1/vantage.proto` is documentation of them rather than a
generator input — nothing compiles it. Both live in `vantage-shared`, together,
because that co-location is the only thing making "add the message to both in
the same commit" possible.
It is **one** `pb` package serving both sides. There used to be two
(one in the agent, one in the server) and they had already
drifted: the agent's `UnimplementedVantageServer` was three methods stale and
carried no `ReportWorkloads` at all. The agent links the server half as dead
code, which the linker drops.
A message added to `vantage-shared` is not a message either side has until its
pin is bumped. What that buys is a mismatch that is a compile error rather than
two copies that both compiled and disagreed on the wire. What it costs is
ordering — **a wire change is three steps**: release `vantage-shared`, bump the
pin in `server/` (live at the next push to main), bump the pin in `agent/` (live
only at the next `agent/v*` tag). The control plane runs ahead of the fleet in
between, which was true before too; it is now explicit in two `go.mod` files
rather than implicit in a shared directory.
### Status pages
Two collections: `status_pages` is the page itself — title, banner, published
flag, and an ordered list of sections each holding entries that pair a
`monitor_id` with a per-page display name. `status_incidents` holds both
operator-authored incidents and maintenance windows, sharing one document
shape because they share a timeline, an impact and a set of affected
components; each carries an explicit `page_ids` rather than deriving it from
`affected_monitors`, because adding a monitor to a page later must not
retroactively republish that monitor's old incidents to a new audience.
**`services.assembleSnapshot` is the redaction boundary, and it is the only
one.** It takes a `snapshotInput` built from already-fetched
`models.Monitor`/`models.Rollup`/`models.Incident` documents and returns a
`StatusSnapshot` built entirely from a parallel, deliberately smaller
vocabulary (`PublicComponent`, `PublicIncident`, …) that has no field for a
target URL, host, port, expected status, keyword, failure message,
certificate expiry, latency, runner or notification channel — `models.Monitor`
itself never reaches an anonymous caller, only the handful of fields
`assembleSnapshot` chooses to copy out of it. Being a pure function of already-
fetched data (no DB calls inside it) is what makes the boundary testable
without a database, which is the only thing standing between an editor adding
a field to `PublicComponent` and that field being a hostname.
**An incident may only name components the page already carries.**
`services.checkAffectedOnPages` refuses an `affected_monitors` entry that no
page in the incident's `page_ids` lists, and the editor offers only the saved
page's components — labelled by their per-page display name, since that is the
name the reader sees. Naming an arbitrary monitor would publish a machine the
page deliberately does not, which is the same leak `assembleSnapshot`'s
redaction boundary exists to prevent, reached from the authoring side instead
of the read side. It is a separate pass rather than part of `validateIncident`
because it reads the database and `validateIncident` is a pure function of the
document. A component dropped from the page **after** an incident named it
makes the next edit of that incident fail, deliberately: the editor renders the
stale entry flagged and checked so it is one click from being dropped, and the
alternative is a page quietly publishing a component it no longer has.
Monitor-detected outages are **derived at read time, never copied**: each
snapshot assembly reads recent `incidents` for the page's monitors and folds
them into the timeline alongside the authored ones. There is no second
incidents table for automatic ones and no reconciliation between two records
of the same outage. A maintenance window in progress **repaints how a day is
drawn, never the uptime number** — `buildDays` computes each day's up/down
state and the 90-day percentage from rollups first, and
`applyMaintenanceRepaint` only overwrites today's display state afterward, so
a component that stayed up throughout a maintenance window still shows as up
in its history.
The public route, `GET /public/status/:pageId`, is mounted on the gin **root**,
outside `/api`, on purpose: `/api` carries `auth.Middleware`, `RequireScopes`,
`RateLimitTokens` and `RequireActiveLicense` by virtue of where it is mounted,
and a public route living there would need four exemptions — each one a hole a
later change to any of those four could widen back open. A missing page, an
unpublished page, and a page on the wrong host all answer the same 404;
inventing a distinct code for "exists but unpublished" would itself leak that
the page exists. A lapsed licence or a tier lacking `status_pages` answers 200
with `available:false` and a `reason`, never a 403 or a blank page — the
reader is a member of the public who can do nothing about either condition and
deserves an explanation, not a browser error.
**The instance is resolved from `X-Forwarded-Host`, not `Host`.** The public
page is server-rendered by `web/`, and the SSR fetch cannot set `Host` at all:
it is a forbidden header name and undici drops it silently, so the Go server
saw `server:8080` and every status page 404'd on every deployment. `web/`
forwards the visitor's host in `X-Forwarded-Host` (and their address in
`X-Forwarded-For`, or the whole deployment shares one rate-limit bucket), and
`publicStatusInstance` honours that header **only when `c.RemoteIP()` is in
`TRUSTED_PROXIES`** — it selects a tenant, so an untrusted peer must not be
able to name one. It uses `RemoteIP()` and not `ClientIP()` deliberately: the
latter is reconstructed from the very headers being judged.
**A host naming no slug falls back to the sole instance on a non-cloud
deployment.** `hostSlug` requires `<slug>.vantage.<tld>`; a self-hosted install
at `vantage.acme.com` or an IP has no slug and would otherwise 404 forever. It
has exactly one instance, resolved with the same count-then-read bootstrap
uses, cached alongside the slug lookups. More than one instance is a 404, not a
guess. A host that *does* name a slug which does not exist stays a 404 —
falling back there would serve one tenant's page on another's address.
Assembled snapshots are cached in Redis for **30 seconds**, keyed per
instance and page, and every authoring write (`UpdateStatusPage`,
`DeleteStatusPage`, and every incident mutation) invalidates its page's entry
immediately rather than waiting out the TTL — an operator posting an update
mid-incident should not wonder for half a minute whether it saved. A cache
miss, on Redis being down or on any read error, degrades to reassembly rather
than an error: the status page has to survive the outage it exists to report.
The public endpoint itself is rate limited to **120 requests per minute per
client address**, answering 429 with `Retry-After`, on the same fixed-window
pattern as `RateLimitTokens`.
**`TRUSTED_PROXIES` is load-bearing for that limiter, not cosmetic.** `main.go`
always calls `gin.SetTrustedProxies` with it; left unset, gin trusts no proxy
and `c.ClientIP()` falls back to the direct peer address — which, sat behind a
real reverse proxy, is the proxy's own address for every visitor. The rate
limiter then keys on one address for the whole fleet of readers, and the first
burst of legitimate traffic during an incident is what trips it. Set it to the
proxy's real address or CIDR, not merely a private range guess; the shipped
compose file and Helm chart default it to the RFC1918 ranges, which is right
for their own bundled reverse proxy but wrong the moment another one is
inserted in front. The same setting also decides the address recorded in
`audit_logs` and `console_sessions`.
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent
downloads and replaces itself, from
`<gitea>/vantage/vantage-agent/releases/download/<tag>/…`.
**That repository path is compiled into the agent, not sent to it**, and it
changed when the agent moved out of this repository. Agents built before that
move look for `mrhid6/vantage`, where releases are no longer published, so the
push-button update in the UI fails for them with a 404. They are not stranded:
`/install`, `/install.ps1`, `/update` and `/update.ps1` are generated **here**,
at request time, so re-running the update one-liner on a host moves it onto a
build that knows the new address, after which self-update works again.
The ordering matters. This server must be deployed with the new paths *before*
the one-liner is any use, because it is this server that hands out the URL.
The six generators — two install scripts, two update scripts,
`GET /api/agent/latest-version` in `services/dispatch.go`, and the tag lookup
inside each — all name that repository. They must agree with wherever
`agent-release.yml` actually publishes, and nothing checks that they do.
### Backup and restore
`vantagectl` is `vantage-ctl` now — its own repository, with the command still
named `vantagectl`. It is not a subcommand of `server` and never was: `server`
imports the whole control-plane dependency graph, `spf13/cobra` has no business
in a process that also terminates gRPC streams, and above all **it has to run
when the control plane does not**. A backup or restore against a database with
no server container alive is the normal case, so it cannot be a mode of the
binary whose failure is the reason you reached for it.
Almost none of its logic is in that repository either: dump, restore, verify,
manifest and fingerprint are `shared/backup` in `vantage-shared`, and
`internal/cmd` holds only argument parsing and operator-facing output. That
split is what would let `server` import `shared/backup` later — a scheduled
in-process backup, say — without a second implementation to keep in sync.
`shared/cryptobox` is the same move one layer down: it is the **single**
AES-256-GCM implementation, and `server/internal/services/crypto.go` delegates
to it rather than keeping its own copy that `shared/backup` would otherwise have
had to duplicate to decrypt a probe value during `verify`.
**The one thing this repository owes it is `backup.ciphertextFields`**, which
lives in `vantage-shared` and mirrors `server/internal/models` **by hand**
`shared/` is a separate module and cannot import it. The map naming each
collection's `*_enc` fields (`keys`, `secrets`, `auth_providers`,
`console_sessions`) must change in the same commit as any of those bson tags,
and that commit is now in a different repository from the tags it tracks. Wrong
field names are **silent**: `verify`'s live probe finds no ciphertext and
reports "this database stores no ciphertext yet", so the one gate that catches
what a key fingerprint cannot becomes a no-op. `settings` is deliberately in
neither that map nor `CiphertextCollections()` — its ESO read token is a
SHA-256 hash, not ciphertext.
The chart's optional backup CronJob runs that image, and **`backup.image` has
no default** — the template `fail`s without one rather than guessing, so a
cluster set up before the move keeps working until someone changes the value.
It is `gitea.hostxtra.co.uk/vantage/vantage-ctl:latest` now, was
`mrhid6/vantage/vantagectl:latest`; `chart-release.yml`'s render checks name the
new path.
The rest — the refusals around `--force` and `--confirm-db`, the key
fingerprint, live collection enumeration, verbatim index replay, the `.partial`
rename — is documented in `vantage-ctl`.
### API tokens and OpenAPI
@@ -465,9 +663,9 @@ only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
token, and for the same reason: nothing downstream ever needs the plaintext
back. It belongs to the user who created it, and its role can never exceed
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
true across a demotion rather than only at issuance. Scopes are eight
true across a demotion rather than only at issuance. Scopes are nine
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
`workloads`, `settings` — each split into `:read` and `:write`, with `:write`
`workloads`, `status`, `settings` — each split into `:read` and `:write`, with `:write`
satisfying a `:read` requirement on the same resource so a caller does not have
to hold both. Any signed-in member may mint and revoke their **own** tokens —
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
@@ -512,41 +710,47 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
reference page has to work on an air-gapped install with no outbound access at
all — the same requirement licence verification already meets.
### Marketing site and sitesvc
### The public host
`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.
`sitesvc/` (port `8082`) now owns only the contact flow:
| Form | Endpoint | Effect |
| ------- | ------------------- | ----------------------------------------------------------------------- |
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
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.
### Documentation site
`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident.
Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong.
**vantage.hostxtra.co.uk is not served by this repository.** The marketing site
and its contact-form service are `vantage-site`; the documentation at `/docs` is
`vantage-docs`; the HQ console at `vantage-hq.hostxtra.co.uk` is
`vantage-admin`. Each carries its own compose fragment, and the host composes
them on top of this one:
```bash
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# self-hosted install — the control plane and nothing else
docker compose -f deploy/docker/docker-compose.yml up -d
# vantage.hostxtra.co.uk — control plane plus the public site
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
# vantage.hostxtra.co.uk — every repository's fragment together
docker compose \
-f vantage/deploy/docker/docker-compose.yml \
-f vantage-site/deploy/docker-compose.yml \
-f vantage-docs/deploy/docker-compose.yml \
-f vantage-admin/deploy/docker-compose.yml \
up -d
```
`docker-compose.site.yml` is gone from this repository: every service it held
now lives with the repository that builds it. The self-hosted exclusion used to
be a rule about which file a service went in; it is the repository boundary now.
**The reverse proxy in front is shared and belongs to none of them.** On
vantage.hostxtra.co.uk that is an Nginx Proxy Manager, and its routing spans
repositories: `/docs` to `vantage-docs` — a location that must sort **above**
the catch-all — and everything else on that host to `vantage-site`. A
self-hosted install needs its own; see the compose note below for what it must
route.
One coupling survives the split and is easy to miss: the marketing site's
`/start` form posts account signups **straight to `vantage-admin`**, not to
anything here. The control plane is not touched until the customer later creates
a cloud instance from the portal.
### Signup and verification
Signup is **account-first**: it creates an HQ account and an unverified `customer_user` in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (`POST /api/instances`, see Admin REST API) does an org, or rather an `instance`, come to exist — provisioned by `cloudprov`, with the owner's password hash copied from the HQ user rather than shared. `site_pending_signups` is gone; sitesvc no longer has a signup flow at all.
Signup is **account-first**: it creates an HQ account and an unverified `customer_user` in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (`POST /api/instances`, see Admin REST API) does an org, or rather an `instance`, come to exist — provisioned by `cloudprov`, with the owner's password hash copied from the HQ user rather than shared. `site_pending_signups` is gone; the contact-form service has no signup flow at
all, and lives in another repository besides.
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
- Links expire after 24 hours (`VerifyWindow`).
@@ -569,16 +773,17 @@ left unspent.
### Email
`shared/mail` is the only email system. It owns the SMTP conversation, the RFC
5322 envelope and the look of every message; `server`, `admin` and `sitesvc`
each import it and none of them builds a subject line, a MIME part or a colour.
5322 envelope and the look of every message; the control plane, `vantage-admin`
and `vantage-site` each import it and none of them builds a subject line, a MIME
part or a colour.
Before this existed the transport was copied three times, and the copies had
already diverged once — the 465-implicit-TLS fix landed in one of them while
the others silently delivered nothing.
`Sender` is a value, not a singleton: `server/internal/notify` builds one per
notification channel from the channel document in Mongo, while `sitesvc` builds
one at boot and `admin` holds one in `admin/internal/mail.Default`, alongside
its other boot-time singletons. Callers only ever see typed methods —
notification channel from the channel document in Mongo, while `vantage-site`'s
service builds one at boot and `vantage-admin` holds one in its own
`internal/mail.Default`, alongside its other boot-time singletons. Callers only ever see typed methods —
`SendVerification`, `SendExpiring`, `SendMonitorAlert`, `SendEnquiry` and the
rest, grouped by owner into `account.go`, `licence.go`, `billing.go`,
`monitor.go` and `contact.go`.
@@ -698,7 +903,7 @@ keeps succeeding and the fleet list still shows the server `active`. The agent's
watchdog arms only **after** it has seen a first ping, so an older server that
sends none is treated as working rather than put into a reconnect loop.
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `vantage-shared`, in `proto/vantage/v1/vantage.proto` beside the `grpc/pb` types it describes.
---
@@ -746,6 +951,10 @@ workloads GET /workloads · GET /servers/:id/workloads
POST /servers/:id/workloads/refresh
POST /servers/:id/workloads/:wid/action (owner|admin)
GET /servers/:id/workloads/:wid/logs (owner|admin)
status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (owner|admin)
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
@@ -765,71 +974,35 @@ 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
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -850,9 +1023,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. `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.
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
@@ -869,9 +1046,13 @@ Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failu
## Agent Lifecycle
The agent is `vantage-agent` now; its internals are documented there. What the
control plane depends on:
### Config file
Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`.
Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`.
Directory `0700`, file `0600`.
```yaml
server_url: "vantage.yourdomain.com:9090"
@@ -895,15 +1076,17 @@ tls: true
```
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
```
### Install
Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, runs as root.
Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent as a service via NSSM.
Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`,
`Restart=always`, runs as root — written by the install script this server
generates, not shipped as a file. Windows: MSI built by `vantage-agent`'s CI
(WiX), or its `installer/setup.ps1` registering the agent as a service via NSSM.
---
@@ -938,24 +1121,13 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor — the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front |
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
**sitesvc** (`deploy/docker-compose.site.yml` only):
| Name | Required | Notes |
| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
| `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 |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` on vantage.hostxtra.co.uk only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
### Ingress (Helm, Traefik)
@@ -967,9 +1139,9 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `ingress.api.paths` (when `api.enabled`) | `/api`, `/auth` → `<release>-server:8080`, bypassing the Next proxy |
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site, which lives in `vantage-site` and which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.api.enabled` routes `/api` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
@@ -979,11 +1151,11 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
---
`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.
**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.
`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.
`deploy/docker/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. **That is the whole of a self-hosted install**, and it is now the only compose file here. vantage.hostxtra.co.uk adds three fragments from three other repositories — `vantage-site` (`site` 3003, `sitesvc` 8082), `vantage-docs` (`docsite` 3005) and `vantage-admin` (`admin` 8083, `adminsite` 3004) — composed together as shown under "The public host".
`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. `docker-compose.yml` should never mention `admin` or `adminsite` again — the separation used to be a rule someone had to remember, and is the repository boundary now. `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.
---
@@ -1007,32 +1179,64 @@ Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client
All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
| App | Ground | Accent | Themes |
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
| `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 |
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| App | Ground | Accent | Themes |
| --------------- | -------------------------- | -------------------------------- | --------------------------- |
| `web/` (here) | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `vantage-site` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `vantage-admin` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| `vantage-docs` | 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.
Only the first row is in this repository. The other three are listed because
the palette is one system across all four front ends regardless of which
repository they sit in.
`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.
`vantage-site`'s `web/app/globals.css` is the **origin**: it is the only one
carrying both light and dark values in full, and the other three copy its token
blocks **verbatim** — same names, same values. `web/` here holds the same tokens
but only the **dark** values, since it does not switch. **Nothing enforces the
match, and the four now sit in four repositories, so "change them in the same
commit" is not merely unenforced but impossible.** The drift window is however
long it takes to push four times. Treat a token change as an announcement
rather than a refactor.
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.
`vantage-docs` 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
the 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. Its
one concession is a favicon, which must, for the same reason the email layout
must: a browser tab cannot read a token.
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 has its own subject: `vantage-site` calls the semantic three
`--up`/`--pend`/`--down` for monitor state, `vantage-admin` aliases them to
`valid`/`warn`/`expired` for licence state, and `web/` here 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 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.
`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.
There is a **fifth** copy, and it is the one people forget:
`shared/mail/templates/layout.html.tmpl` in `vantage-shared` 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.
`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.
`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 four token blocks still be diffed by eye, which matters more now that they cannot be diffed by `git`. `web/` also adds three tokens the marketing site has no use for: `--accent-hover` and `--down-hover` (it 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.
**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.
`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.
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.
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to the shared 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.
`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 |
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
@@ -1074,39 +1278,50 @@ lives — one copy instead of the three that existed while they were apart.
## CI/CD — Gitea Actions
### `agent-release.yml` — triggered by `agent/v*` tags
Builds `linux/amd64`, `linux/arm64`, `windows/amd64`, writes `checksums.txt`, creates a Gitea release. A second `msi` job on `windows-2022` packages the WiX installer.
```bash
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-amd64 ./cmd
```
### `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`.
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. That is now the whole of this workflow. Everything else that was once built here belongs to the repository that owns it — `vantage-site`, `vantage-docs`, `vantage-admin` and `vantage-ctl` each publish their own, and `vantage-agent` cuts releases rather than images.
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:
```bash
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
# self-hosted
cd /opt/vantage && docker compose -f deploy/docker/docker-compose.yml pull && \
docker compose -f deploy/docker/docker-compose.yml up -d --remove-orphans
# vantage.hostxtra.co.uk — all four repositories' fragments, see "The public host"
```
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
| Image | Rebuilds when |
| ---------------------------- | ----------------------------------------- |
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
| `admin` | `admin/`, `shared/`, `go.work` |
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
| Image | Rebuilds when |
| ---------------------------- | -------------------------------- |
| `server` | `server/`, `go.work` |
| `web` | `web/` only |
`shared/` fans out to all three Go images because each of their Dockerfiles copies `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone.
**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
rebuilds when its own `go.mod` moves, which its own directory pattern already
matches. What that removes is the failure where a `shared/` edit rebuilt three
images and one of them was not ready; what it adds is that nothing here reminds
you a pin is stale.
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
Every Go build in these workflows writes a netrc from `REGISTRY_USER` +
`RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`.
There is exactly **one** such place left here: `server-deploy.yml`'s single job.
The other repositories each carry their own, one per job, because jobs do not
share a filesystem — `vantage-agent`'s `msi` job is the one to remember, because
it is Windows, where Go reads `%USERPROFILE%\_netrc` and not `.netrc`. The docker builds pass
it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read
access to the `vantage` org** on top of its existing scopes; without it every Go
build fails at `go mod download` with a 404 on the module, which reads like a
missing tag rather than a missing permission. A change to the workflow file rebuilds everything, since
a build arg is baked into the image. So does anything that leaves no
trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a
force-push whose old head is gone.
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `HQ_URL`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
@@ -1124,33 +1339,34 @@ helm install vantage vantage/vantage --version 0.1.0
### Tagging
```bash
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
git push origin main # server + web deploy
git push origin main # server + web deploy
```
Only two things are tagged here now. Elsewhere: `vantage-agent` keeps the
`agent/v*` prefix, because the control plane greps release tag names for exactly
that string; `vantage-ctl` dropped its prefix for a bare `v*`, because nothing
reads it programmatically.
### Secrets / variables
| Name | Type | Value |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart). **This is the only token any workflow authenticates with** — `docker login` and the chart publish both pair it with `REGISTRY_USER` |
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart) and **read access to the `vantage` org**, which is where the private `vantage-shared` module lives — without that last one every Go build fails at `go mod download` with what looks like a missing tag. **This is the only token any workflow authenticates with** — `docker login`, the chart publish and the module netrc all pair it with `REGISTRY_USER` |
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
| ~~`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` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
| `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. |
| ~~`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. |
| `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. |
`SITE_URL`, `SITE_API_URL`, `SITE_CONTACT_EMAIL`, `ADMIN_API_URL`, `ADMIN_ENV`,
`DOCS_URL`, `DOCS_BASE_URL`, `APP_URL` and every `PADDLE_*` name are set on the
repository that bakes them in — `vantage-site`, `vantage-docs` or
`vantage-admin` — and none of them is read by anything here. Two are set in
**two** repositories and must agree: `ADMIN_API_URL` (`vantage-site` bakes it
into the marketing site's signup form, `vantage-admin` into its own console) and
`SITE_URL`.
---
@@ -1171,9 +1387,11 @@ git push origin main # server + web deploy
- **Windows agents cover the fleet-management path** — register, heartbeat, run
steps, report inventory, OS updates through the Windows Update COM API, and
workloads (services plus containers, with control and logs). They still do no
`authorized_keys` management, and no package inventory or CVE matching: the
vulnerability feeds this project uses carry no Windows data, so a Windows host
correctly reports `unsupported` rather than a clean bill of health.
`authorized_keys` management, and no package inventory or CVE matching: a
Windows agent never calls `ReportPackages`, so no `server_packages` document
exists for it and it reports no package inventory at all — a different,
earlier state than the `unsupported` a Linux distribution reaches when its
family has no security feed.
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
-2
View File
@@ -1,2 +0,0 @@
.env
*.lic
-30
View File
@@ -1,30 +0,0 @@
# Context is the repository root; admin depends on the shared module.
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY shared/go.mod shared/go.sum ./shared/
COPY admin/go.mod admin/go.sum ./admin/
RUN cd admin && go mod download
COPY shared/ ./shared/
COPY admin/ ./admin/
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
FROM alpine:3.20 AS runner
RUN apk add --no-cache ca-certificates && \
addgroup --system --gid 1001 admin && \
adduser --system --uid 1001 --ingroup admin admin
COPY --from=builder /out/admin /usr/local/bin/admin
COPY --from=builder /out/adminctl /usr/local/bin/adminctl
USER admin
EXPOSE 8083
ENV PORT=8083
CMD ["/usr/local/bin/admin"]
-84
View File
@@ -1,84 +0,0 @@
// Command adminctl performs the operations that deliberately have no HTTP
// surface.
//
// adminctl staff-add --email=you@example.com --name="You" --password=...
//
// There is no staff signup endpoint. A licensing authority that can be joined
// over the internet is not one.
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/google/uuid"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
)
func main() {
godotenv.Load()
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
cfg, err := config.Load()
if err != nil {
fatal("configuration: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.Connect(ctx, cfg); err != nil {
fatal("database: %v", err)
}
switch os.Args[1] {
case "staff-add":
staffAdd(ctx, os.Args[2:])
default:
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
}
func staffAdd(ctx context.Context, args []string) {
fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
email := fs.String("email", "", "staff email (required)")
name := fs.String("name", "", "display name")
password := fs.String("password", "", "password, at least 12 characters (required)")
fs.Parse(args)
if *email == "" || len(*password) < 12 {
fatal("--email and a --password of at least 12 characters are required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
fatal("hash: %v", err)
}
u := models.StaffUser{
UserID: uuid.NewString(),
Email: strings.ToLower(strings.TrimSpace(*email)),
PasswordHash: string(hash),
Name: *name,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
fatal("create staff user: %v", err)
}
fmt.Printf("created staff user %s\n", u.Email)
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}
-129
View File
@@ -1,129 +0,0 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/lifecycle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/joho/godotenv"
)
func main() {
godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration error: %v", err)
}
licensing.SetSigningKey(cfg.SigningKey)
api.SetAppLoginURL(cfg.AppLoginURL)
if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil {
log.Fatalf("paddle init: %v", err)
}
mail.Init(sharedmail.Sender{
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
PublicURL: cfg.PublicURL,
})
if !mail.Enabled() {
log.Println("warning: SMTP not configured; verification and licence emails will fail")
}
auth.InitRedis(cfg.RedisAddr, cfg.RedisUsername, cfg.RedisPassword)
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := auth.Ping(pingCtx); err != nil {
pingCancel()
log.Fatalf("redis: %v", err)
}
pingCancel()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if err := db.Connect(ctx, cfg); err != nil {
cancel()
log.Fatalf("database: %v", err)
}
cancel()
log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)
idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := db.EnsureIndexes(idxCtx); err != nil {
idxCancel()
log.Fatalf("indexes: %v", err)
}
// Legacy plans are re-keyed BEFORE the seed, so the seed's fresh
// (self_hosted, professional) row cannot collide with the legacy self_hosted
// row's rename on deployment_tier_unique.
if err := models.MigrateLegacyPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("migrate legacy plans: %v", err)
}
if err := models.SeedPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("plan seed: %v", err)
}
if err := models.SeedCatalogue(idxCtx); err != nil {
idxCancel()
log.Fatalf("seed catalogue: %v", err)
}
if err := models.Backfill(idxCtx); err != nil {
idxCancel()
log.Fatalf("backfill: %v", err)
}
idxCancel()
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
billing.StartPlaceholderReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
lifecycle.Start(reconcileCtx, cfg.ReapAfter)
srv := &http.Server{
Addr: cfg.Addr,
Handler: api.Routes(cfg),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("admin listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-stopCtx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
_ = srv.Shutdown(shutdownCtx)
log.Println("admin stopped")
os.Exit(0)
}
-55
View File
@@ -1,55 +0,0 @@
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
-149
View File
@@ -1,149 +0,0 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-264
View File
@@ -1,264 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// checkoutOptions serves everything the browser configurator needs to price a
// plan: the active plans (base allowances), the full catalogue (component prices
// in the running environment), and the environment name so the client can refuse
// a mismatch. The client token itself is baked into the adminsite build, never
// served from here.
func checkoutOptions(c *gin.Context) {
ctx := c.Request.Context()
plans := []models.Plan{}
if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
_ = cur.All(ctx, &plans)
}
rows, err := models.AllCatalogue(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"plans": plans,
"catalogue": rows,
"env": paddle.Get().Env(),
})
}
// createSelfHostedCheckout prepares a paid self-hosted checkout against the
// customer's REAL install UUID, and hands that id back for the checkout's
// custom_data.
//
// A licence binds to the install's UUID, so the buyer must have a control plane
// standing before they pay — the same precondition self-hosted Free already has.
// That is what removes the placeholder: there is no temporary identity to
// rewrite afterwards, the subscription's custom_data names the real instance
// from the first event, and the webhook issues with no claim step.
//
// An id this account already owns is REUSED rather than refused: upgrading a
// Free self-hosted install to a paid plan is the same purchase form, and
// refusing it would mean the only route to Professional was to unlink first.
// A UUID belonging to anyone else is still 409, from the unique index.
func createSelfHostedCheckout(c *gin.Context) {
s := auth.Current(c)
var body struct {
InstanceID string `json:"instance_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.InstanceID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
instanceID := strings.TrimSpace(body.InstanceID)
name := strings.TrimSpace(body.Name)
ctx := c.Request.Context()
var existing models.Instance
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&existing)
switch {
case err == nil:
if existing.Deployment != license.DeploymentSelfHosted {
c.JSON(http.StatusBadRequest, gin.H{
"error": "that instance is a cloud instance; change its plan from its own page"})
return
}
c.JSON(http.StatusOK, gin.H{"instance_id": existing.InstanceID})
return
case !errors.Is(err, mongo.ErrNoDocuments):
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
inst, err := licensing.LinkInstance(ctx, s.AccountID, instanceID, name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrAlreadyLinked) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.checkout_started", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "self-hosted", IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
// createCloudCheckout creates a PAID cloud placeholder and hands back its id so
// the browser can open a Paddle checkout keyed to it. Nothing is provisioned yet:
// a cloud instance costs real infrastructure, so it is created only once payment
// is confirmed, by the subscription webhook (billing.handleSubscription).
//
// This mirrors the self-hosted placeholder, with one difference that matters:
// admin owns the cloud UUID, so the id generated here is the id the instance
// will keep. Provisioning on the webhook reuses it (provision.CreateInstanceWithID),
// which is why there is no claim-and-rewrite step and the subscription's
// custom_data never goes stale. PendingOwnerUserID remembers who bought it so the
// webhook can make them the instance owner.
//
// An abandoned checkout therefore leaves only this row — no infrastructure — the
// same cheap, reap-safe state a self-hosted placeholder leaves.
func createCloudCheckout(c *gin.Context) {
s := auth.Current(c)
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
ctx := c.Request.Context()
inst := models.Instance{
InstanceID: uuid.NewString(),
AccountID: s.AccountID,
Name: strings.TrimSpace(body.Name),
Deployment: license.DeploymentCloud,
Status: models.StatusAwaitingLink,
Placeholder: true,
PendingOwnerUserID: s.UserID,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.cloud_placeholder_created", AccountID: s.AccountID,
Target: inst.InstanceID, IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
// updateEntitlement sets an instance's DESIRED configuration and pushes the
// resulting line items to Paddle. It does NOT issue — the resulting
// subscription.updated webhook does, from granted. An increase is prorated
// immediately by Paddle; a reduction is recorded as desired and takes effect at
// renewal, so this never shrinks a live licence.
func updateEntitlement(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
var body struct {
Tier string `json:"tier"`
Term string `json:"term"`
Servers int `json:"servers"`
Features []string `json:"features"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
return
}
plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
return
}
if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
return
}
desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// A live subscription is required to update. None means this instance has
// never been paid for — that is a checkout, not an update.
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
return
}
pItems := make([]paddle.LineItem, 0, len(items))
for _, it := range items {
pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
return
}
// Record desired now; the webhook Paddle sends back promotes to granted and
// reissues. Recording here makes the portal reflect the intent instantly
// rather than waiting on the round-trip.
limits, _, _ := catalogue.Resolve(ctx, plan, desired)
next := models.Entitlement{
InstanceID: inst.InstanceID, AccountID: inst.AccountID,
Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
Desired: desired, ResolvedLimits: limits,
}
ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
if ent != nil {
next.Granted = ent.Granted
next.GrantedAt = ent.GrantedAt
if desired.Servers < ent.Granted.Servers {
now := time.Now().UTC()
next.ScheduledChangeAt = &now
}
} else {
next.Granted = desired
}
if err := models.UpsertEntitlement(ctx, next); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "entitlement.requested",
AccountID: inst.AccountID, Target: inst.InstanceID})
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
}
// billingPortal mints a Paddle customer-portal URL. The account must already
// have a paddle_customer_id, which it learns from its first subscription webhook.
func billingPortal(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
var acc models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
return
}
if acc.PaddleCustomerID == "" {
c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
return
}
url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
-736
View File
@@ -1,736 +0,0 @@
package api
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ownedInstance resolves an instance and confirms the session's account owns it.
//
// EVERY customer handler that names an instance must go through this. It returns
// 404 for another account's instance rather than 403: a 403 confirms the
// instance exists, which is an existence oracle over customer data.
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
s := auth.Current(c)
if s == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return nil, false
}
var inst models.Instance
err := db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &inst, true
}
// 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
}
out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
if s.Kind == auth.KindCustomer {
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
out["account_role"] = u.AccountRole
}
}
c.JSON(http.StatusOK, out)
}
func getAccount(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
instances := []models.Instance{}
if err := cur.All(ctx, &instances); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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,
})
}
func linkInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
s := auth.Current(c)
inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrAlreadyLinked) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, inst)
}
func relinkInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
s := auth.Current(c)
lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrRelinkLimit) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
deliver(c, inst, lic)
c.JSON(http.StatusOK, lic)
}
func getInstanceLicense(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var lic models.License
if err := db.Admin("licenses").FindOne(c.Request.Context(),
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
// 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. The
// struct tag hides it, so the fields are listed explicitly.
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,
})
}
func downloadInstanceLicense(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var lic models.License
if err := db.Admin("licenses").FindOne(c.Request.Context(),
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
}
func listSubscriptions(c *gin.Context) {
s := auth.Current(c)
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
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)
}
// provisionCloudInstance provisions a real cloud instance in the control plane
// and records admin's row for it, WITHOUT issuing a licence. Both the Free
// create path and the paid-checkout path share it, so the provisioning — and its
// unwind-in-reverse rollback — has one definition rather than two that drift.
//
// It leaves the instance unlicensed on purpose: createInstance then issues Free,
// and createCloudCheckout leaves it for the paid subscription webhook to license.
// The returned rec carries no Tier or CurrentLicense; the caller sets those once
// it has issued.
//
// Errors are returned unwrapped for the provisioning step so the caller can still
// match provision.ErrEmailTaken / ErrNameRejected; later steps are wrapped.
func provisionCloudInstance(c *gin.Context, name string) (*models.Instance, error) {
ctx := c.Request.Context()
s := auth.Current(c)
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
return nil, fmt.Errorf("read account: %w", err)
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
return nil, err
}
rec := models.Instance{
InstanceID: inst.InstanceID,
AccountID: s.AccountID,
Name: inst.Name,
Slug: inst.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
// Unwind in reverse: the owner first, because RollbackInstance refuses
// an instance that still has users.
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
}
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
log.Printf("provisionCloudInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
return nil, fmt.Errorf("record instance: %w", err)
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("provisionCloudInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("provisionCloudInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
return &rec, nil
}
// cloudProvisionError maps the errors provisionCloudInstance can surface onto the
// customer-facing responses shared by the Free and paid-checkout paths.
func cloudProvisionError(c *gin.Context, err error) {
switch {
case errors.Is(err, provision.ErrEmailTaken):
// users.email is unique per instance, so this means the address already
// owns a user in an instance we are not creating — a legacy cloud tenant.
// Staff have to attach that one by hand.
c.JSON(http.StatusConflict, gin.H{
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
}
}
// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
// and email are deliberately NOT allowed to fail the request: the instance
// exists and the customer can sign in, they see the licence banner, and staff
// can issue by hand. Rolling back an instance the customer can already see would
// be worse than shipping it unlicensed.
func createInstance(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
ctx := c.Request.Context()
s := auth.Current(c)
// Pre-check the Free rule so we never create an instance we then cannot
// licence. licensing.Issue enforces it too; this is the friendly refusal.
//
// Scoped to cloud because that is what this endpoint creates. It MUST match
// checkFreeLimit's scoping — a pre-check stricter than the issuer refuses
// something that would have worked.
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": s.AccountID,
"deployment": license.DeploymentCloud,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
return
}
if n > 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this account already has a Free cloud instance"})
return
}
rec, err := provisionCloudInstance(c, name)
if err != nil {
cloudProvisionError(c, err)
return
}
inst := rec
// Past this point nothing fails the request.
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
Term: "monthly",
Reason: models.ReasonNew,
IssuedBy: "self-serve",
})
if err != nil {
log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
c.JSON(http.StatusCreated, rec)
return
}
inject.Deliver(ctx, lic)
if mail.Enabled() {
if err := mail.Default.SendInstanceReady(s.Email, inst.Name,
loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
}
}
rec.Tier = lic.Tier
rec.CurrentLicense = lic.LicenseID
c.JSON(http.StatusCreated, rec)
}
// renewInstance extends a Free licence by another term.
//
// Renewal is manual on purpose: it is the entire reclaim signal. An instance
// nobody renews is an instance nobody is using, and that is what makes the
// reaper safe to run at all.
func renewInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Tier != license.TierFree {
c.JSON(http.StatusBadRequest, gin.H{
"error": "only Free instances renew here; paid plans renew through billing"})
return
}
ctx := c.Request.Context()
var current models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
return
}
// Free renews on its deployment's only term: monthly for cloud, annual for
// self-hosted. Reading it from TermsFor rather than hardcoding is what stops
// a self-hosted instance being handed a one-month licence.
terms := license.TermsFor(inst.Deployment)
term := terms[len(terms)-1]
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
Term: term,
Reason: models.ReasonRenewal,
IssuedBy: "self-serve",
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Cloud is injected; self-hosted is delivered to the customer, because their
// database is theirs and we cannot write to it.
deliver(c, inst, lic)
// Clear the notice log so the next term starts the sequence again. Issue has
// already set status back to active.
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
Target: inst.InstanceID, IP: c.ClientIP()})
if mail.Enabled() {
if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
}
}
c.JSON(http.StatusOK, lic)
}
// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
// yields an empty string, and the email simply omits the link.
func loginURLFor(slug string) string {
if appLoginURL == "" {
return ""
}
return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
}
// appLoginURL is set once at boot from config.
var appLoginURL string
// SetAppLoginURL is called from main.
func SetAppLoginURL(v string) { appLoginURL = v }
// claimFree issues a Free licence on a linked self-hosted instance.
//
// The link step creates the row; this gives it a licence. They are separate
// because linking is about identity — proving which install is yours — and
// claiming is about entitlement, and a customer who links an install and then
// changes their mind should not have consumed their one Free allowance.
//
// Free is outside Paddle entirely, so there is no checkout, no subscription and
// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard;
// the count here exists to refuse politely before anything is written.
func claimFree(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
// Cloud Free is claimed at creation by POST /api/instances. Allowing it here
// too would be a second way to reach the same state, with its own bugs.
if inst.Deployment != license.DeploymentSelfHosted {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cloud instances get their Free licence when they are created"})
return
}
if inst.CurrentLicense != "" {
c.JSON(http.StatusConflict, gin.H{
"error": "this instance already has a licence"})
return
}
plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"})
return
}
if !plan.Active {
c.JSON(http.StatusForbidden, gin.H{
"error": "Free self-hosted is not currently offered"})
return
}
// The entitlement is written BEFORE the licence, so Issue snapshots it rather
// than falling back to the plan base. They are the same numbers today, but
// the ordering is what makes that a coincidence rather than a dependency.
if err := models.UpsertEntitlement(ctx, models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: license.DeploymentSelfHosted,
Tier: license.TierFree,
Term: "annual",
Desired: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
Granted: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
ResolvedLimits: plan.BaseLimits,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
// Annual, and not a choice. Self-hosted sells annual only because the
// term length is the revocation window for an offline licence.
Term: "annual",
Reason: models.ReasonNew,
IssuedBy: s.Email,
})
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrFreeLimit) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
deliver(c, inst, lic)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()})
c.JSON(http.StatusCreated, lic)
}
// renameInstance changes a cloud instance's name and moves it to the slug that
// name derives to.
//
// The control plane is written FIRST, because instances.slug carries the unique
// index and that index is what actually settles a race between two accounts
// reaching for the same name. Admin's own row follows; if that write fails the
// control plane is put back, because HQ printing a host that is not the host is
// worse than a failed rename.
//
// No licence is issued and Paddle is not called: a licence binds the instance
// UUID, and a rename does not change it.
func renameInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
if inst.Placeholder {
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
return
}
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
ctx := c.Request.Context()
// The unwind and the audit write run on a context detached from the request.
// The commonest reason the admin-side write fails at all is the caller
// walking away, and an unwind sharing that context fails with it — leaving
// the control plane renamed and admin's row not, which is the exact
// divergence this handler is arranged to prevent.
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
// and then acting lets two parallel PUTs both pass the check and then
// interleave their two-database writes, which ends with the two databases
// disagreeing about the host — a worse outcome than either rename losing.
// The conditional update IS the cooldown; there is no second reading of it.
now := time.Now().UTC()
var claimed models.Instance
err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
bson.M{
"instance_id": inst.InstanceID,
"account_id": inst.AccountID,
"$or": []bson.M{
{"renamed_at": bson.M{"$exists": false}},
{"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
},
},
bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
if err != nil {
if !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// No match means the cooldown is live or the row has gone; only a
// re-read tells those apart, and they are different answers.
var cur models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if cur.RenamedAt != nil {
until := cur.RenamedAt.Add(models.RenameCooldown)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
"retry_after": until,
})
return
}
// The row is here and its cooldown is spent, yet the claim matched
// nothing: it changed under us. Nothing has been written, so refuse
// rather than guess which way.
log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// releaseClaim puts renamed_at back to whatever the claim overwrote — the
// previous instant, or absent when there was none. Every failure past the
// claim owes the customer their rename back.
releaseClaim := func(after string) {
undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
if claimed.RenamedAt != nil {
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
}
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
defer cancel()
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
}
}
renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
releaseClaim("a taken slug")
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
return
case errors.Is(err, provision.ErrNameRejected):
releaseClaim("a rejected name")
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
releaseClaim("a failed control-plane rename")
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// A matched count of zero is a silent version of the same failure: the
// control plane moved and admin's row did not.
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
if err == nil && res.MatchedCount == 0 {
err = errors.New("admin_instances row matched nothing")
}
if err != nil {
// The control plane's own previous values, not admin's copy: admin's may
// be stale, and its slug is omitempty.
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
releaseClaim("a failed record write")
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
if renamed.Slug == prevSlug {
// The cooldown exists because a rename moves the DNS host; a cosmetic
// edit that derives to the same slug moves nothing, so it should not
// spend one. The claim is already written by this point — releasing it
// is how that is expressed now the check is atomic.
releaseClaim("a rename that did not move the host")
}
s := auth.Current(c)
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{
"instance_id": inst.InstanceID,
"name": renamed.Name,
"slug": renamed.Slug,
// The same builder the licence emails use, rather than a second opinion
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
"login_url": loginURLFor(renamed.Slug),
})
}
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
// Delivery failures are logged, never returned: the licence is already recorded,
// which is the part that must not be lost.
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
if inst.Deployment == license.DeploymentCloud {
inject.Deliver(c.Request.Context(), lic)
return
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
-187
View File
@@ -1,187 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
)
// entitlementBody is what a caller may set.
//
// Only Desired is writable. Granted is what a payment confirmed, and letting a
// form set it would let the portal grant itself a licence — which is the one
// thing this whole split exists to prevent. Staff promote Granted explicitly
// through a separate flag, because staff issuing a licence to somebody who has
// not paid is a real operation with a real reason, and it should be one they
// took on purpose and left an audit row for.
type entitlementBody struct {
Tier string `json:"tier"`
Term string `json:"term"`
Servers int `json:"servers"`
Features []string `json:"features"`
// Grant promotes Desired into Granted in the same write. Staff only.
Grant bool `json:"grant"`
}
// getEntitlement serves the customer's own view of one instance's configuration.
func getEntitlement(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
if errors.Is(err, models.ErrNoEntitlement) {
c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
func staffGetEntitlement(c *gin.Context) {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
return
}
ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
if errors.Is(err, models.ErrNoEntitlement) {
c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
// staffSetEntitlement writes an instance's configuration.
//
// This is the endpoint that makes metering usable before Paddle exists: staff
// configure, then issue. It does NOT issue — recording what an instance is
// allowed and signing a licence for it stay separate, so a bad configuration is
// a row to correct rather than a licence to supersede.
func staffSetEntitlement(c *gin.Context) {
ctx := c.Request.Context()
var body entitlementBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entitlement"})
return
}
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": "no such instance"})
return
}
tier := body.Tier
if tier == "" {
tier = inst.Tier
}
plan, err := models.GetPlan(ctx, inst.Deployment, tier)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("no plan for %s/%s", inst.Deployment, tier)})
return
}
if !termSold(inst.Deployment, body.Term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s", inst.Deployment, body.Term)})
return
}
if body.Servers < plan.BaseLimits.MaxServers &&
plan.BaseLimits.MaxServers != -1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s includes %d servers; cannot configure fewer",
plan.Name, plan.BaseLimits.MaxServers)})
return
}
desired := models.Config{
Servers: body.Servers,
Features: models.Features(body.Features).OrEmpty(),
}
// Start from whatever is already granted, so writing a desired change never
// silently alters what the instance is currently allowed.
granted := desired
existing, err := models.GetEntitlement(ctx, inst.InstanceID)
switch {
case err == nil:
if !body.Grant {
granted = existing.Granted
}
case errors.Is(err, models.ErrNoEntitlement):
// First write. There is nothing granted to preserve, so desired becomes
// granted — an instance with an entitlement nobody has granted would
// fall back to the plan base at issue time and confuse everyone.
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Only the limits are stored. Features are NOT snapshotted onto the
// entitlement: they live in Granted.Features, and Issue resolves them again
// against the catalogue at signing time. Storing a second copy here would
// give two answers to "which features does this instance have".
limits, _, err := catalogue.Resolve(ctx, plan, granted)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ent := models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: inst.Deployment,
Tier: tier,
Term: body.Term,
Desired: desired,
Granted: granted,
ResolvedLimits: limits,
}
// A reduction is a fact about the future, so it carries a date. There is no
// billing period to read yet — plan 5 sets this from the subscription — so
// staff-set reductions are marked as pending without one.
if desired.Servers < granted.Servers {
now := time.Now().UTC()
ent.ScheduledChangeAt = &now
}
if existing != nil {
ent.GrantedAt = existing.GrantedAt
}
if body.Grant {
ent.GrantedAt = time.Now().UTC()
}
if err := models.UpsertEntitlement(ctx, ent); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "entitlement.updated",
AccountID: inst.AccountID,
Target: inst.InstanceID,
Detail: fmt.Sprintf("tier=%s term=%s desired_servers=%d granted_servers=%d granted=%t",
tier, body.Term, desired.Servers, granted.Servers, body.Grant),
})
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
-237
View File
@@ -1,237 +0,0 @@
package api
import (
"errors"
"log"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// selfHostedRefusal is the one message every membership endpoint gives for a
// self-hosted instance. Their users live in their own deployment, which we
// cannot see and must not write to.
const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"
func listInstanceMembers(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
cur, err := db.Admin("instance_members").Find(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
members := []models.InstanceMember{}
if err := cur.All(ctx, &members); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, members)
}
// grantInstanceMember projects an account person into a cloud instance.
func grantInstanceMember(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
var body struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
if body.Role == "" {
body.Role = sharedmodels.RoleMember
}
if !sharedmodels.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
target, ok := accountUser(c, body.UserID)
if !ok {
return
}
if target.VerifiedAt == nil || target.PasswordHash == "" {
// The projection copies a hash. An unverified invitee has no hash, so
// the row would exist and be unusable — and an address nobody has
// proven they control would hold a login inside a real instance.
c.JSON(http.StatusConflict, gin.H{
"error": "they have not accepted their invitation yet"})
return
}
ctx := c.Request.Context()
s := auth.Current(c)
u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
target.PasswordHash, body.Role, target.UserID)
if err != nil {
if errors.Is(err, provision.ErrEmailTaken) {
c.JSON(http.StatusConflict, gin.H{
"error": "that address already has a user inside this instance"})
return
}
log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
return
}
m := models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: target.UserID,
ControlUserID: u.UserID,
Role: body.Role,
Email: target.Email,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
// Unwind the projection: a control-plane login nobody on this side
// records is a login nobody can revoke through the portal.
if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
target.Email, inst.InstanceID, rErr)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusCreated, m)
}
// memberRow loads one membership on an instance the caller owns.
func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
var m models.InstanceMember
if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
"instance_id": instanceID,
"customer_user_id": customerUserID,
}).Decode(&m); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &m, true
}
func updateInstanceMemberRole(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
if !ok {
return
}
ctx := c.Request.Context()
if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the instance's last owner; make someone else an owner first"})
return
}
}
if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
return
}
if _, err := db.Admin("instance_members").UpdateOne(ctx,
bson.M{"member_id": m.MemberID},
bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func revokeInstanceMember(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
if !ok {
return
}
ctx := c.Request.Context()
if m.Role == sharedmodels.RoleOwner {
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the instance's last owner; make someone else an owner first"})
return
}
}
if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
return
}
if _, err := db.Admin("instance_members").DeleteOne(ctx,
bson.M{"member_id": m.MemberID}); err != nil {
log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
-64
View File
@@ -1,64 +0,0 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"github.com/gin-gonic/gin"
)
// paddleWebhook is the ingress for every Paddle event.
//
// Order is load-bearing: read the RAW body first (the signature is over the
// exact bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature
// is 401 and processes nothing; a duplicate of a handled event is 200 and does
// nothing; a handler error is 500 so Paddle retries, and is recorded for staff.
func paddleWebhook(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
return
}
if !paddle.VerifySignature(cfg.PaddleWebhookSecret,
c.GetHeader("Paddle-Signature"), body) {
log.Printf("paddle webhook: bad signature from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
return
}
var ev billing.Event
if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"})
return
}
ctx := c.Request.Context()
claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
return
}
if !claimed {
// Already handled (or in flight). 200 so Paddle stops retrying.
c.JSON(http.StatusOK, gin.H{"duplicate": true})
return
}
if err := billing.Dispatch(ctx, ev); err != nil {
log.Printf("paddle webhook: handler %s failed for %s: %v",
ev.EventType, ev.EventID, err)
_ = models.MarkEventProcessed(ctx, ev.EventID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"})
return
}
_ = models.MarkEventProcessed(ctx, ev.EventID, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
-338
View File
@@ -1,338 +0,0 @@
package api
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// listAccountUsers returns the account's people, newest last.
//
// Any signed-in member may read this. Knowing who your colleagues are is not
// privileged, and hiding it would make the members panel unusable for the
// people it is meant to inform.
func listAccountUsers(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
users := []models.CustomerUser{}
if err := cur.All(ctx, &users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
// inviteAccountUser adds a person to the account.
//
// It never sets a password: see CreateInvitedUser. Only an owner may invite
// another owner, mirroring the control plane's own rule that an admin cannot
// mint someone with more power than themselves.
func inviteAccountUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
if body.Role == "" {
body.Role = models.AccountRoleMember
}
if !models.ValidAccountRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
me := auth.CurrentUser(c)
if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
return
}
ctx := c.Request.Context()
s := auth.Current(c)
// customer_users.email is globally unique, so an address already in use
// anywhere cannot be invited here. Say so plainly: unlike signup there is
// nothing to conceal, because the inviter already knows this address.
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "that address already has a Vantage HQ account"})
return
}
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
return
}
if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
log.Printf("invite %s to %s: %v", email, s.AccountID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"invited": true})
}
// accountUser loads one person and confirms they are on the caller's account.
func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
s := auth.Current(c)
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &u, true
}
// countOtherAccountOwners counts owners of an account other than one person.
func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
s := auth.Current(c)
return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
"account_id": s.AccountID,
"account_role": models.AccountRoleOwner,
"user_id": bson.M{"$ne": exceptUserID},
})
}
func updateAccountUserRole(c *gin.Context) {
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
target, ok := accountUser(c, c.Param("id"))
if !ok {
return
}
me := auth.CurrentUser(c)
if target.UserID == me.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
return
}
if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
me.AccountRole != models.AccountRoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
return
}
if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
others, err := countOtherAccountOwners(c, target.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the account's last owner; promote someone else first"})
return
}
}
ctx := c.Request.Context()
if _, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": target.UserID},
bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// deleteAccountUser removes a person and every instance they hold.
//
// Grants go first, and the whole request is refused if any of them would strand
// an instance with no owner. Removing the person but leaving their projected
// rows behind would leave working logins for someone the account has removed —
// the exact failure this endpoint exists to prevent.
func deleteAccountUser(c *gin.Context) {
target, ok := accountUser(c, c.Param("id"))
if !ok {
return
}
me := auth.CurrentUser(c)
if target.UserID == me.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
return
}
if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
return
}
if target.AccountRole == models.AccountRoleOwner {
others, err := countOtherAccountOwners(c, target.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the account's last owner; promote someone else first"})
return
}
}
ctx := c.Request.Context()
s := auth.Current(c)
cur, err := db.Admin("instance_members").Find(ctx,
bson.M{"customer_user_id": target.UserID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
members := []models.InstanceMember{}
if err := cur.All(ctx, &members); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Check every instance BEFORE deleting anything, so a refusal leaves the
// person exactly as they were rather than half-revoked.
for _, m := range members {
if m.Role != sharedmodels.RoleOwner {
continue
}
others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "they are the last owner of an instance; give someone else that instance's owner role first"})
return
}
}
for _, m := range members {
if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "could not remove their instance access; nothing was deleted"})
return
}
if _, err := db.Admin("instance_members").DeleteOne(ctx,
bson.M{"member_id": m.MemberID}); err != nil {
log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
}
}
if _, err := db.Admin("customer_users").DeleteOne(ctx,
bson.M{"user_id": target.UserID}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// changeAccountPassword sets one password and pushes it everywhere.
//
// HQ's hash is the single source of truth for every hq-sourced row, and the
// control plane has no local password-change path for them, so there is no
// competing writer.
//
// Propagation is best-effort ON PURPOSE. Failing the password change because
// one of three instances was briefly unreachable would leave the customer with
// the password they were trying to get rid of; hqsync repairs a stale instance
// within fifteen minutes, which is recoverable.
func changeAccountPassword(c *gin.Context) {
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
return
}
if len(body.NewPassword) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
return
}
s := auth.Current(c)
ctx := c.Request.Context()
var me models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
if _, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
pending := false
if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
pending = true
now := time.Now().UTC()
log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
} else {
log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
audit.Write(ctx, models.AuditEntry{
Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
Target: me.Email, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
}
-161
View File
@@ -1,161 +0,0 @@
// Package api mounts admin's HTTP surface.
//
// The route table is the single place scoping is guaranteed. Customer routes
// live behind RequireCustomer and every handler that names an instance calls
// ownedInstance. A new customer route that skips that helper is a scoping bug,
// so keep them together and review them together.
package api
import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
)
func Routes(cfg config.Config) http.Handler {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Use(cors(cfg.AllowedOrigins))
if cfg.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
r.POST("/auth/staff/login", auth.HandleStaffLogin)
// Every customer authenticates against admin's own customer_users. There is
// deliberately no path that looks a customer up in the control plane by
// email alone: HQ sign-in names no instance, so such a lookup could not be
// scoped, and users.email is no longer globally unique.
r.POST("/auth/login", auth.HandleCustomerLogin)
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/verify", auth.HandleVerify)
r.GET("/auth/me", getMe)
r.POST("/auth/signup", auth.HandleSignup)
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
// Public: Paddle carries no session cookie; its signature is its auth. Must
// NOT sit under the cust group's session middleware.
r.POST("/api/paddle/webhook", paddleWebhook(cfg))
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{
cust.GET("/account", getAccount)
// People. Reading is open to any member; changing anything is
// owner-or-admin, enforced per route rather than by splitting the group,
// so the guard is visible next to the route it guards.
cust.GET("/account/users", listAccountUsers)
cust.POST("/account/users",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
inviteAccountUser)
cust.PUT("/account/users/:id/role",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateAccountUserRole)
cust.DELETE("/account/users/:id",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
deleteAccountUser)
// Any member may change their own password — it is theirs. There is no
// endpoint for changing anyone else's.
cust.PUT("/account/password", changeAccountPassword)
cust.POST("/instances",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createInstance)
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.POST("/instances/:id/renew", renewInstance)
cust.POST("/instances/:id/claim-free",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimFree)
// Renaming moves the instance's DNS host, so it is owner-or-admin like
// every other instance mutation. Cloud only; the handler refuses the rest.
cust.PUT("/instances/:id/name",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
renameInstance)
cust.GET("/instances/:id/entitlement", getEntitlement)
cust.GET("/checkout/options", checkoutOptions)
// Paid self-hosted: links (or reuses) the customer's real install UUID so
// the checkout can name it. There is no placeholder and no claim step.
cust.POST("/instances/self-hosted",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createSelfHostedCheckout)
// Paid cloud: provisions a real instance the paid webhook then licenses.
cust.POST("/instances/cloud",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createCloudCheckout)
cust.PUT("/instances/:id/entitlement",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateEntitlement)
cust.POST("/billing/portal", billingPortal)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
cust.POST("/instances/:id/members",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
grantInstanceMember)
cust.PUT("/instances/:id/members/:uid/role",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateInstanceMemberRole)
cust.DELETE("/instances/:id/members/:uid",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
revokeInstanceMember)
cust.GET("/subscriptions", listSubscriptions)
}
staff := r.Group("/api/staff")
staff.Use(auth.RequireStaff())
{
staff.GET("/accounts", staffListAccounts)
staff.POST("/accounts", staffCreateAccount)
staff.GET("/accounts/:id", staffGetAccount)
staff.POST("/accounts/:id/users", staffCreateAccountUser)
staff.GET("/instances", staffListInstances)
staff.POST("/instances", staffCreateInstance)
staff.GET("/instances/:id", staffGetInstance)
staff.GET("/subscriptions", staffListSubscriptions)
staff.POST("/instances/:id/issue", staffIssue)
staff.POST("/instances/:id/relink", staffRelink)
staff.PUT("/instances/:id/name", staffRenameInstance)
staff.GET("/licenses", staffListLicenses)
staff.GET("/plans", staffListPlans)
// Plans are keyed on the pair now, so the path is too. A single :tier
// segment could name three rows.
staff.PUT("/plans/:deployment/:tier", staffUpdatePlan)
staff.GET("/catalogue", staffListCatalogue)
staff.PUT("/catalogue", staffUpdateCatalogue)
staff.GET("/instances/:id/entitlement", staffGetEntitlement)
staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
staff.GET("/health/billing", staffBillingHealth)
}
return r
}
func cors(allowed []string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" && slices.Contains(allowed, origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
-728
View File
@@ -1,728 +0,0 @@
package api
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func staffListAccounts(c *gin.Context) {
filter := bson.M{}
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
}
cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
accounts := []models.Account{}
if err := cur.All(c.Request.Context(), &accounts); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, accounts)
}
func staffCreateAccount(c *gin.Context) {
var body struct {
Name string `json:"name"`
BillingEmail string `json:"billing_email"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
return
}
acct := models.Account{
AccountID: uuid.NewString(),
Name: body.Name,
BillingEmail: body.BillingEmail,
Status: models.AccountActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, acct)
}
func staffGetAccount(c *gin.Context) {
ctx := c.Request.Context()
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
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)
}
// CustomerUser's password hash and both verify-token fields are json:"-",
// so no secret leaves here.
c.JSON(http.StatusOK, gin.H{
"account": acct,
"instances": instances,
"subscriptions": subs,
"users": users,
"audit": entries,
})
}
func staffListInstances(c *gin.Context) {
filter := bson.M{}
for param, field := range map[string]string{
"account_id": "account_id",
"deployment": "deployment",
"status": "status",
} {
if v := c.Query(param); v != "" {
filter[field] = v
}
}
if c.Query("expiring") == "true" {
// Instances whose licence expires within 14 days, for renewal chasing.
//
// Empty rather than nil: a nil slice marshals to `$in: null`, which
// Mongo rejects outright, so the quiet week when nothing is expiring is
// exactly when this query would have failed.
ids := []string{}
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
"superseded_by": bson.M{"$exists": false},
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
})
if err == nil {
var lics []models.License
if cur.All(c.Request.Context(), &lics) == nil {
for _, l := range lics {
ids = append(ids, l.InstanceID)
}
}
}
filter["instance_id"] = bson.M{"$in": ids}
}
cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
options.Find().SetLimit(500))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
instances := []models.Instance{}
if err := cur.All(c.Request.Context(), &instances); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, instances)
}
// staffCreateInstance attaches an instance to an account.
//
// For cloud, this ADOPTS an instance that already exists in the control plane —
// the control-plane row is the source of truth for its name and slug, and this
// refuses if no such instance exists, because an admin row pointing at nothing
// would issue licences nobody can use.
//
// For self-hosted it does the same job as the customer-facing link endpoint, so
// staff can link on a customer's behalf during support.
//
// This is how existing cloud instances get licensed: adopt, then issue.
func staffCreateInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
Deployment string `json:"deployment"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
return
}
ctx := c.Request.Context()
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
return
}
inst := models.Instance{
InstanceID: body.InstanceID,
AccountID: body.AccountID,
Name: body.Name,
Deployment: body.Deployment,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if body.Deployment == license.DeploymentCloud {
var remote sharedmodels.Instance
if err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
return
}
inst.Name = remote.Name
inst.Slug = remote.Slug
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
c.JSON(http.StatusCreated, inst)
}
// staffGetInstance is the "why did this stop working" screen's data: one
// instance, its account, its whole licence history newest first, and whether
// the control plane currently holds what we think it holds.
func staffGetInstance(c *gin.Context) {
ctx := c.Request.Context()
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var acct models.Account
_ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct)
lics := []models.License{}
if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID},
options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil {
_ = cur.All(ctx, &lics)
}
// Injection state is only meaningful for cloud. For self-hosted the
// customer holds the blob and there is nothing for us to have written.
injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud}
if inst.Deployment == license.DeploymentCloud {
var remote sharedmodels.Instance
err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID}).Decode(&remote)
switch {
case err != nil:
injection["state"] = "missing"
case inst.CurrentLicense == "":
injection["state"] = "none_issued"
default:
var current models.License
if db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&current) == nil &&
remote.LicenseBlob == current.Blob {
injection["state"] = "current"
} else {
injection["state"] = "stale"
}
}
injection["failed_at"] = inst.InjectFailedAt
}
c.JSON(http.StatusOK, gin.H{
"instance": inst, "account": acct, "licenses": lics, "injection": injection,
})
}
// staffListSubscriptions backs the past-due queue on the dashboard.
func staffListSubscriptions(c *gin.Context) {
filter := bson.M{}
if v := c.Query("status"); v != "" {
filter["status"] = v
}
if v := c.Query("account_id"); v != "" {
filter["account_id"] = v
}
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter,
options.Find().SetLimit(500))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
subs := []models.Subscription{}
if err := cur.All(c.Request.Context(), &subs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, subs)
}
func staffIssue(c *gin.Context) {
var body struct {
Tier string `json:"tier"`
Term string `json:"term"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
return
}
if body.Reason == "" {
body.Reason = models.ReasonManual
}
s := auth.Current(c)
lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
InstanceID: c.Param("id"),
Tier: body.Tier,
Term: body.Term,
Reason: body.Reason,
IssuedBy: s.Email,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var inst models.Instance
if db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
deliver(c, &inst, lic)
}
c.JSON(http.StatusCreated, lic)
}
// staffRelink has no attempt cap. The customer-facing limit exists to put a
// human in front of the fourth attempt; this is that human.
func staffRelink(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
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
}
lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, lic)
}
func staffListLicenses(c *gin.Context) {
filter := bson.M{}
if v := c.Query("instance_id"); v != "" {
filter["instance_id"] = v
}
if v := c.Query("account_id"); v != "" {
filter["account_id"] = v
}
cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
lics := []models.License{}
if err := cur.All(c.Request.Context(), &lics); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, lics)
}
// staffBillingHealth surfaces webhook handlers that failed and placeholders
// still awaiting their instance, so a customer who paid and got nothing is
// visible rather than stuck in a support queue.
//
// Placeholders are a cloud-only path now; any self-hosted row still listed here
// predates the checkout change and needs issuing by hand.
func staffBillingHealth(c *gin.Context) {
ctx := c.Request.Context()
failed := []models.PaddleEvent{}
if cur, err := db.Admin("paddle_events").Find(ctx,
bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
_ = cur.All(ctx, &failed)
}
unlinked := []models.Instance{}
if cur, err := db.Admin("admin_instances").Find(ctx,
bson.M{"placeholder": true, "status": models.StatusAwaitingLink}); err == nil {
_ = cur.All(ctx, &unlinked)
}
c.JSON(http.StatusOK, gin.H{
"failed_events": failed,
"failed_count": len(failed),
"unlinked_paid": unlinked,
"unlinked_count": len(unlinked),
})
}
func staffListPlans(c *gin.Context) {
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
plans := []models.Plan{}
if err := cur.All(c.Request.Context(), &plans); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, plans)
}
// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON.
// Existing licences snapshotted their plan at issue time and are unaffected —
// the same rule as workflow_runs.steps_snapshot.
//
// It writes no Paddle identifiers: those live in the catalogue, because a
// metered plan is priced by several components.
func staffUpdatePlan(c *gin.Context) {
var body models.Plan
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
return
}
deployment, tier := c.Param("deployment"), c.Param("tier")
set := bson.M{
"name": body.Name,
"base_limits": body.BaseLimits,
"base_features": body.BaseFeatures.OrEmpty(),
"support_level": body.SupportLevel,
"active": body.Active,
}
res, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"})
return
}
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "plan.updated",
Target: deployment + "/" + tier,
Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t",
body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors,
body.SupportLevel, body.Active),
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func staffListCatalogue(c *gin.Context) {
rows, err := models.AllCatalogue(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
// staffUpdateCatalogue sets the price IDs on one component.
//
// The component is addressed by its natural key rather than by an ObjectID, so
// the staff UI never has to hold a Mongo identifier and a seeded row can be
// updated the moment it exists. Only price IDs are writable: a row's kind, plan
// and key are seeded by SeedCatalogue, and letting a form invent a limit_key
// would let it invent a limit nothing enforces.
func staffUpdateCatalogue(c *gin.Context) {
var body struct {
Kind string `json:"kind"`
Deployment string `json:"deployment"`
Tier string `json:"tier"`
LimitKey string `json:"limit_key"`
FeatureKey string `json:"feature_key"`
PriceIDs map[string]map[string]string `json:"price_ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"})
return
}
// Refuse a price on a term the deployment does not sell. Storing one would
// mean a resolved self-hosted monthly price later, which the resolver treats
// as a configuration error — better to refuse it at the point somebody
// pastes it, while they are looking at the screen.
for env, byTerm := range body.PriceIDs {
for term, id := range byTerm {
if id == "" {
continue
}
if !termSold(body.Deployment, term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
body.Deployment, term, env)})
return
}
}
}
filter := bson.M{
"kind": body.Kind,
"deployment": body.Deployment,
"tier": body.Tier,
"limit_key": body.LimitKey,
"feature_key": body.FeatureKey,
}
res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter,
bson.M{"$set": bson.M{"price_ids": body.PriceIDs}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such component"})
return
}
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "catalogue.updated",
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
Detail: body.LimitKey + body.FeatureKey,
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func termSold(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
return true
}
}
return false
}
func staffAudit(c *gin.Context) {
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}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
entries := []models.AuditEntry{}
if err := cur.All(c.Request.Context(), &entries); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entries)
}
// staffInjectionHealth lists instances whose last injection failed. This is the
// page to look at when a customer says their cloud instance is read-only.
func staffInjectionHealth(c *gin.Context) {
cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
bson.M{"inject_failed_at": bson.M{"$exists": true}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
failed := []models.Instance{}
if err := cur.All(c.Request.Context(), &failed); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
}
// staffCreateAccountUser gives an account an HQ login.
//
// This is how a legacy cloud customer — one whose instance predates HQ accounts
// — gets into the portal, alongside the manual instance attach the spec README
// describes. It reuses CreateCustomerUser, so the row is unverified until the
// emailed link is opened and is rolled back if that email cannot be sent.
func staffCreateAccountUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "email and a password of at least 12 characters are required"})
return
}
ctx := c.Request.Context()
accountID := c.Param("id")
if n, err := db.Admin("accounts").CountDocuments(ctx,
bson.M{"account_id": accountID}); err != nil || n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
// Staff attaching a legacy customer are attaching the person who runs that
// account, so they get owner.
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
c.JSON(http.StatusCreated, gin.H{"pending": true})
}
// staffRenameInstance renames any instance, with no cooldown.
//
// It does NOT write renamed_at: a staff rename must not start the customer's
// 24h clock, or fixing a name for someone locks them out of fixing it further.
//
// On self-hosted it changes admin's label only. There is no control-plane row to
// write — the install is the customer's — and no slug, because self-hosted has
// no tenant subdomain.
//
// A cloud placeholder is refused outright rather than relabelled: it has no
// control-plane row yet, so a label-only rename here would be a name that the
// instance never gets when provisioning finally derives its slug from the
// checkout's name. The customer endpoint refuses it for the same reason.
func staffRenameInstance(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
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
}
if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
return
}
// The unwind and the audit write must survive the request being cancelled:
// an unwind on a dead context leaves the two databases disagreeing, which is
// the failure the unwind exists for.
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
set := bson.M{"name": name}
slug := inst.Slug
cloud := inst.Deployment == license.DeploymentCloud
// The control plane's own previous values, not admin's copy: admin's may be
// stale, and its slug is omitempty, so unwinding from it can write an empty
// slug into instances.
prevName, prevSlug := inst.Name, inst.Slug
if cloud {
renamed, pName, pSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
return
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
prevName, prevSlug = pName, pSlug
slug = renamed.Slug
set["slug"] = renamed.Slug
}
// A matched count of zero is the same failure quietly: the control plane
// moved and admin's row did not.
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
if err == nil && res.MatchedCount == 0 {
err = errors.New("admin_instances row matched nothing")
}
if err != nil {
if cloud {
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
}
-21
View File
@@ -1,21 +0,0 @@
// Package audit records who did what. Every issuance, link, relink and sign-in
// attempt lands here.
package audit
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
)
// Write never returns an error: an audit failure must not roll back the action
// it describes. It logs instead, loudly enough to notice.
func Write(ctx context.Context, e models.AuditEntry) {
e.CreatedAt = time.Now().UTC()
if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil {
log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err)
}
}
-346
View File
@@ -1,346 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"log"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// BcryptCost matches the control plane and sitesvc. Changing it here alone would
// make hashes inconsistent across services that may one day compare them.
const BcryptCost = 12
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
//
// It is shared/mail's constant rather than a second copy because the
// verification email states the number of hours: a window that disagreed with
// what the email promised would expire links early with no explanation.
const VerifyWindow = sharedmail.VerifyWindow
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return err
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return err
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expiry := time.Now().UTC().Add(VerifyWindow)
u := models.CustomerUser{
UserID: uuid.NewString(),
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
PasswordHash: string(hash),
AccountRole: accountRole,
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
return err
}
if err := mail.Default.SendVerification(u.Email, token); err != nil {
// Undo the insert. A row whose verification link was never delivered is
// worse than no row: it can never be signed in to, and it holds the
// unique index on email, so the customer cannot sign up again with the
// address they just used.
//
// Deliberately NOT on ctx. ctx is the HTTP request's, and the most likely
// reason we are here is that the mail server stalled until the browser
// gave up — which cancels ctx and makes this delete a silent no-op,
// stranding exactly the row it exists to remove. That happened in
// production against a port-465 server.
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
log.Printf("signup: FAILED to roll back customer_user %s (%s) after mail error: %v",
u.UserID, u.Email, dErr)
}
return err
}
return nil
}
// CreateInvitedUser creates a passwordless, unverified member of an existing
// account and emails them a link to set a password.
//
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
// never succeed, so the row cannot sign in and cannot usefully be projected
// into an instance until the invitee has been through /accept-invite. That is
// also why a grant refuses an unverified user.
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return err
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expiry := time.Now().UTC().Add(VerifyWindow)
u := models.CustomerUser{
UserID: uuid.NewString(),
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
AccountRole: accountRole,
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
return err
}
if err := mail.Default.SendInvite(u.Email, accountName, token); err != nil {
// Same rollback rule, and the same detached context, as signup: a row
// whose link was never delivered can never be signed in to and holds
// the unique index on email against the person it was meant for.
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
u.UserID, u.Email, dErr)
}
return err
}
return nil
}
// HandleAcceptInvite consumes an invitation token and sets the password.
//
// Verification and password-setting are one step for an invitee, because the
// link IS the proof of address and there is nothing to verify separately.
func HandleAcceptInvite(c *gin.Context) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
if len(body.Password) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
sum := sha256.Sum256([]byte(body.Token))
now := time.Now().UTC()
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
bson.M{
"verify_token_hash": hex.EncodeToString(sum[:]),
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
"$set": bson.M{"verified_at": now, "password_hash": string(hash)},
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
})
if err != nil || res.MatchedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
c.JSON(http.StatusOK, gin.H{"accepted": true})
}
// 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, models.AccountRoleOwner); err != nil {
// Roll the account back rather than strand one with no owner. Detached
// from ctx for the same reason as the user rollback above: a stalled mail
// server cancels the request, and a rollback that needs the request to
// still be alive is a rollback that fails exactly when it is needed.
log.Printf("signup: %s failed, rolling back account %s: %v", email, acct.AccountID, err)
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, dErr := db.Admin("accounts").DeleteOne(rbCtx, bson.M{"account_id": acct.AccountID}); dErr != nil {
log.Printf("signup: FAILED to roll back account %s: %v", acct.AccountID, dErr)
}
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})
}
// HandleVerify consumes a verification token.
func HandleVerify(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
sum := sha256.Sum256([]byte(token))
now := time.Now().UTC()
ctx := c.Request.Context()
hashed := hex.EncodeToString(sum[:])
// Peek first. An invited row has no password yet, so consuming its token
// here would verify an account nobody can sign in to and burn the only
// link that could fix it.
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx, bson.M{
"verify_token_hash": hashed,
"verify_token_expiry": bson.M{"$gt": now},
}).Decode(&u); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
if u.PasswordHash == "" {
c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
return
}
res, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{
"verify_token_hash": hashed,
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
"$set": bson.M{"verified_at": now},
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
})
if err != nil || res.MatchedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
c.JSON(http.StatusOK, gin.H{"verified": true})
}
// HandleCustomerLogin authenticates a self-hosted customer.
func HandleCustomerLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
ctx := c.Request.Context()
if !allowAttempt(email, c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
return
}
reject := func(reason string) {
audit.Write(ctx, models.AuditEntry{
Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
}
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
reject("unknown email")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
reject("bad password")
return
}
if u.VerifiedAt == nil {
// Distinct from genericAuthError on purpose: the address is already
// known to be theirs, so there is nothing to disclose, and "check your
// email" is the only useful thing to say.
c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
return
}
id, err := Save(ctx, Session{
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
return
}
SetCookie(c, id)
clearAttempts(email)
audit.Write(ctx, models.AuditEntry{
Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
}
-111
View File
@@ -1,111 +0,0 @@
package auth
import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
)
const ctxSession = "admin_session_obj"
// 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 {
id, err := c.Cookie(CookieName)
if err != nil || id == "" {
return nil
}
s, err := Get(c.Request.Context(), id)
if err != nil {
return nil
}
return s
}
// Current returns the session, or nil.
func Current(c *gin.Context) *Session {
if v, ok := c.Get(ctxSession); ok {
if s, ok := v.(*Session); ok {
return s
}
}
return nil
}
func RequireStaff() gin.HandlerFunc {
return func(c *gin.Context) {
s := Load(c)
if s == nil || s.Kind != KindStaff {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
c.Set(ctxSession, s)
c.Next()
}
}
// RequireCustomer admits both cloud and self-hosted customers. Every handler
// behind it scopes by AccountID via the helper in api/customer.go — never by
// remembering to filter.
func RequireCustomer() gin.HandlerFunc {
return func(c *gin.Context) {
s := Load(c)
if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
c.Set(ctxSession, s)
c.Next()
}
}
const ctxCustomerUser = "admin_customer_user"
// CurrentUser returns the calling customer's own row, loaded once per request
// by RequireAccountRole.
//
// It is nil behind RequireCustomer alone. A handler that needs the role must
// sit behind RequireAccountRole, which is the only thing that loads it.
func CurrentUser(c *gin.Context) *models.CustomerUser {
if v, ok := c.Get(ctxCustomerUser); ok {
if u, ok := v.(*models.CustomerUser); ok {
return u
}
}
return nil
}
// RequireAccountRole admits a customer holding one of the given account roles.
//
// The role is read from the database on every request rather than carried in
// the session. A session lives 24 hours; a demotion that only takes effect
// when someone signs out again is not a demotion.
func RequireAccountRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
s := Current(c)
if s == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
if !slices.Contains(roles, u.AccountRole) {
// 403 rather than 404 here: this is the caller's OWN account, so
// there is no existence to disclose — the 404 rule protects other
// accounts' resources, not the caller's view of their own.
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "your account role does not allow this"})
return
}
c.Set(ctxCustomerUser, &u)
c.Next()
}
}
-55
View File
@@ -1,55 +0,0 @@
package auth
import (
"sync"
"time"
)
// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
// hour. The email limit stops a targeted attack on one account; the IP limit
// stops a spray across many.
const (
emailLimit = 5
emailWindow = 15 * time.Minute
ipLimit = 20
ipWindow = time.Hour
)
var (
attemptMu sync.Mutex
byEmail = map[string][]time.Time{}
byIP = map[string][]time.Time{}
)
func prune(in []time.Time, cutoff time.Time) []time.Time {
out := in[:0]
for _, t := range in {
if t.After(cutoff) {
out = append(out, t)
}
}
return out
}
func allowAttempt(email, ip string) bool {
now := time.Now()
attemptMu.Lock()
defer attemptMu.Unlock()
byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))
if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
return false
}
byEmail[email] = append(byEmail[email], now)
byIP[ip] = append(byIP[ip], now)
return true
}
func clearAttempts(email string) {
attemptMu.Lock()
delete(byEmail, email)
attemptMu.Unlock()
}
-101
View File
@@ -1,101 +0,0 @@
// Package auth holds admin's three identities: staff, cloud customers and
// self-hosted customers. All three share one session store and one cookie.
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
CookieName = "admin_session"
SessionTTL = 24 * time.Hour
KindStaff = "staff"
KindCustomer = "customer"
)
type Session struct {
UserID string `json:"user_id"`
Kind string `json:"kind"`
Email string `json:"email"`
AccountID string `json:"account_id,omitempty"` // customers only
}
var rdb *redis.Client
// InitRedis connects the session store.
//
// Username and password may both be empty for an unauthenticated instance. For
// a legacy `requirepass` Redis, pass the password with an empty username —
// go-redis then sends AUTH with one argument instead of two.
func InitRedis(addr, username, password string) {
rdb = redis.NewClient(&redis.Options{
Addr: addr,
Username: username,
Password: password,
})
}
func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() }
func newID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func Save(ctx context.Context, s Session) (string, error) {
id, err := newID()
if err != nil {
return "", err
}
body, err := json.Marshal(s)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func Get(ctx context.Context, id string) (*Session, error) {
body, err := rdb.Get(ctx, "admin_session:"+id).Bytes()
if err != nil {
return nil, err
}
var s Session
if err := json.Unmarshal(body, &s); err != nil {
return nil, err
}
return &s, nil
}
func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) }
func SetCookie(c *gin.Context, id string) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
}
func ClearCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1,
})
}
-77
View File
@@ -1,77 +0,0 @@
package auth
import (
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// genericAuthError is returned for every failure mode — unknown email, wrong
// password, wrong role. Distinguishing them would confirm which addresses have
// accounts.
const genericAuthError = "email or password is incorrect"
func HandleStaffLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if !allowAttempt(email, c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
return
}
var u models.StaffUser
err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u)
if err != nil {
// Spend the same work as a real comparison so timing does not
// distinguish "no such user" from "wrong password".
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
return
}
id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
return
}
SetCookie(c, id)
clearAttempts(email)
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login", IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name})
}
// dummyHash is a valid bcrypt hash of a random value, compared against when no
// user exists so the timing profile matches.
const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u"
func HandleLogout(c *gin.Context) {
if id, err := c.Cookie(CookieName); err == nil && id != "" {
Destroy(c.Request.Context(), id)
}
ClearCookie(c)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
-196
View File
@@ -1,196 +0,0 @@
package billing
import (
"context"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// placeholderReconcileInterval is how often placeholders are swept: paid cloud
// ones a failed webhook left unprovisioned are completed, and stale unpaid ones
// of either deployment are reaped.
const placeholderReconcileInterval = 5 * time.Minute
// abandonedPlaceholderAfter is how long an unpaid placeholder may sit before it
// is treated as an abandoned checkout and deleted. Comfortably longer than a
// webhook's delivery lag, so a just-paid placeholder awaiting its subscription
// event is never mistaken for an abandoned one.
const abandonedPlaceholderAfter = 24 * time.Hour
// StartPlaceholderReconciler owns the after-checkout lifecycle of placeholders.
//
// It recovers the one failure the webhook cannot on its own — a confirmed payment
// whose provisioning handler errored, which is not retried once its event is
// claimed and which the inject reconciler (licences only) does not repair — by
// completing paid cloud placeholders here. And it reaps abandoned ones: a
// placeholder with no subscription past abandonedPlaceholderAfter is a checkout
// nobody finished, and deleting it loses nothing, because a placeholder has no
// control-plane footprint until it is paid for and provisioned.
func StartPlaceholderReconciler(ctx context.Context) {
go func() {
t := time.NewTicker(placeholderReconcileInterval)
defer t.Stop()
reconcilePlaceholders(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
reconcilePlaceholders(ctx)
}
}
}()
}
func reconcilePlaceholders(ctx context.Context) {
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"placeholder": true})
if err != nil {
log.Printf("placeholder reconcile: query: %v", err)
return
}
var placeholders []models.Instance
if err := cur.All(ctx, &placeholders); err != nil {
log.Printf("placeholder reconcile: decode: %v", err)
return
}
now := time.Now().UTC()
for _, inst := range placeholders {
var sub models.Subscription
paid := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub) == nil
if !paid {
// Never paid for. Reap once it is old enough to be an abandoned
// checkout rather than one still awaiting its subscription webhook.
if now.Sub(inst.CreatedAt) > abandonedPlaceholderAfter {
if _, err := db.Admin("admin_instances").DeleteOne(ctx,
bson.M{"instance_id": inst.InstanceID, "placeholder": true}); err != nil {
log.Printf("placeholder reconcile: reap abandoned %s: %v", inst.InstanceID, err)
} else {
log.Printf("placeholder reconcile: reaped abandoned placeholder %s", inst.InstanceID)
}
}
continue
}
// Paid, self-hosted: nothing to provision — the customer installs and
// links, and lifecycle chases them. Only cloud is completed here.
if inst.Deployment != license.DeploymentCloud {
continue
}
items := make([]catalogue.Item, 0, len(sub.Items))
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
log.Printf("placeholder reconcile: resolve items for %s: %v", inst.InstanceID, err)
continue
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
log.Printf("placeholder reconcile: complete %s: %v", inst.InstanceID, err)
continue
}
if err := promoteAndIssue(ctx, provisioned, match, models.ReasonNew); err != nil {
log.Printf("placeholder reconcile: issue %s: %v", inst.InstanceID, err)
continue
}
log.Printf("placeholder reconcile: completed paid cloud instance %s", inst.InstanceID)
}
}
// completeCloudPlaceholder provisions the cloud instance a paid placeholder stands
// for, once payment is confirmed, and returns the row promoted to a real instance.
//
// It is the payment-first half of the paid-cloud flow: createCloudCheckout made
// the placeholder before payment, this provisions it after. The control-plane
// instance is created with the placeholder's OWN id (cloudprov.CreateInstanceWithID),
// so nothing is rewritten and the subscription's custom_data still resolves this
// row on every later webhook.
//
// Every step is idempotent, because a webhook can be retried after this partly
// ran: provisioning converges rather than duplicates, and the row flip and
// membership insert are guarded on what they write. The caller then issues.
func completeCloudPlaceholder(ctx context.Context, inst *models.Instance) (*models.Instance, error) {
cu, err := placeholderOwner(ctx, inst)
if err != nil {
return nil, err
}
prov, err := cloudprov.CreateInstanceWithID(ctx, inst.InstanceID, inst.Name,
cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
return nil, fmt.Errorf("provision cloud instance %s: %w", inst.InstanceID, err)
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{
"$set": bson.M{"slug": prov.Slug, "status": models.StatusActive, "placeholder": false},
"$unset": bson.M{"pending_owner_user_id": ""},
}); err != nil {
return nil, fmt.Errorf("promote placeholder %s: %w", inst.InstanceID, err)
}
// Record the owner's membership. Best-effort and guarded on absence: the
// projected user is what grants access, so a missing row costs a line in the
// members panel, not access — and the boot backfill rebuilds it.
if ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID); err == nil {
if n, _ := db.Admin("instance_members").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID, "customer_user_id": cu.UserID}); n == 0 {
if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("completeCloudPlaceholder: record owner membership for %s: %v",
inst.InstanceID, err)
}
}
}
next := *inst
next.Slug = prov.Slug
next.Status = models.StatusActive
next.Placeholder = false
next.PendingOwnerUserID = ""
return &next, nil
}
// placeholderOwner resolves the customer_user who should own a provisioned cloud
// placeholder: the buyer recorded at checkout, or the account owner if that
// pointer is somehow missing.
func placeholderOwner(ctx context.Context, inst *models.Instance) (*models.CustomerUser, error) {
var cu models.CustomerUser
if inst.PendingOwnerUserID != "" {
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": inst.PendingOwnerUserID}).Decode(&cu); err == nil {
return &cu, nil
}
}
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"account_id": inst.AccountID, "account_role": models.AccountRoleOwner}).Decode(&cu); err != nil {
return nil, fmt.Errorf("no owner for account %s to provision %s: %w",
inst.AccountID, inst.InstanceID, err)
}
return &cu, nil
}
-24
View File
@@ -1,24 +0,0 @@
package billing
import (
"context"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
// self-hosted is emailed the blob (their database is theirs). This mirrors the
// api-side deliver helper but takes no gin context — webhooks have none, and the
// customer is not on the other end of the request.
func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) {
if inst.Deployment == license.DeploymentCloud {
inject.Deliver(ctx, lic)
return
}
if to != "" && mail.Enabled() {
_ = mail.Default.SendLicense(to, inst.Name, lic.Blob)
}
}
-51
View File
@@ -1,51 +0,0 @@
// Package billing turns verified Paddle webhooks into licence actions. It never
// verifies signatures (that is paddle.VerifySignature at the edge) and never
// signs (that is licensing.Issue); it decides what a subscription's current
// state means and calls the issuer.
package billing
import (
"context"
"encoding/json"
"fmt"
"time"
)
// Event is the decoded Paddle webhook envelope. Data is left raw so each handler
// decodes only the shape it needs.
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
Data json.RawMessage `json:"data"`
}
// Dispatch routes one event to its handler. Unknown event types are a no-op
// success: Paddle sends many we do not care about, and 200 stops it retrying.
func Dispatch(ctx context.Context, ev Event) error {
switch ev.EventType {
case "subscription.created", "subscription.updated", "subscription.activated":
return handleSubscription(ctx, ev)
case "subscription.canceled":
return handleCanceled(ctx, ev)
case "subscription.past_due":
return handlePastDue(ctx, ev)
case "transaction.completed":
return handleTransactionCompleted(ctx, ev)
case "transaction.payment_failed":
return handlePaymentFailed(ctx, ev)
case "customer.updated":
return handleCustomerUpdated(ctx, ev)
default:
return nil
}
}
// decode is a small helper so every handler decodes Data the same way.
func decode[T any](ev Event) (T, error) {
var v T
if err := json.Unmarshal(ev.Data, &v); err != nil {
return v, fmt.Errorf("decode %s: %w", ev.EventType, err)
}
return v, nil
}
-299
View File
@@ -1,299 +0,0 @@
package billing
import (
"context"
"errors"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// subscriptionData is the slice of Paddle's subscription payload we read. Fields
// we ignore are simply absent — encoding/json drops them.
type subscriptionData struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
CustomData struct {
AccountID string `json:"account_id"`
InstanceID string `json:"instance_id"`
} `json:"custom_data"`
CurrentBillingPeriod struct {
EndsAt time.Time `json:"ends_at"`
} `json:"current_billing_period"`
Items []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
Quantity int `json:"quantity"`
} `json:"items"`
}
func (d subscriptionData) lineItems() []catalogue.Item {
items := make([]catalogue.Item, 0, len(d.Items))
for _, it := range d.Items {
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
}
return items
}
// handleSubscription folds created/updated/activated into one job: make the
// world match the subscription's CURRENT state. That is what keeps out-of-order
// delivery correct — an updated arriving before its created still carries the
// full item list, so reading all of it is reading current state, not a
// transition.
func handleSubscription(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if d.CustomData.InstanceID == "" {
return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
if err != nil {
// A price we cannot map is a configuration error, not a customer error.
// Fail loudly so it is retried and surfaced rather than guessed.
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
}
// Resolve BEFORE recording. custom_data names whatever id the checkout was
// opened against, and a relink since then has rewritten the instance's
// identity and patched Paddle — but that patch is best-effort and any event
// already in flight still carries the old id. Writing it straight through
// would revert the subscription row and then fail to find the instance,
// wedging every renewal.
instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
if err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
sub := models.Subscription{
AccountID: d.CustomData.AccountID,
InstanceID: instanceID,
PaddleSubscriptionID: d.ID,
Tier: match.Tier,
Term: match.Term,
Status: d.Status,
CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt,
Items: toSubItems(d.lineItems()),
}
if err := upsertSubscription(ctx, sub); err != nil {
return err
}
// Learn the Paddle customer ID onto the account the first time we see it.
if d.CustomerID != "" && d.CustomData.AccountID != "" {
_, _ = db.Admin("accounts").UpdateOne(ctx,
bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
}
// A cloud placeholder is the payment-first path: the instance does not exist
// until this confirmed-payment event, so it is provisioned here and then
// issued (first term). Self-hosted has no placeholder — its checkout named
// the install's real UUID — so it falls straight through to issuance.
// An instance with no licence yet is a first purchase, not a change of plan.
// Self-hosted reaches that state through an ordinary link, so the placeholder
// flag no longer answers this on its own.
reason := models.ReasonEntitlementChange
if inst.CurrentLicense == "" {
reason = models.ReasonNew
}
if inst.Placeholder {
if inst.Deployment != license.DeploymentCloud {
return fmt.Errorf("instance %s is a non-cloud placeholder, which no longer exists", inst.InstanceID)
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
return err
}
inst = *provisioned
reason = models.ReasonNew
}
return promoteAndIssue(ctx, &inst, match, reason)
}
// resolveInstance finds the instance a webhook's custom_data names, following the
// identity trail when the id is one a relink or a cloud placeholder's
// provisioning has since replaced. It returns the instance's CURRENT id, which is the only id anything
// else should be written against.
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
var inst models.Instance
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": customDataID}).Decode(&inst)
if err == nil {
return inst.InstanceID, inst, nil
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return "", inst, err
}
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
return "", inst, err
}
return inst.InstanceID, inst, nil
}
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the only promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
if err != nil {
return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
}
granted := models.Config{
Servers: match.Servers,
Features: models.Features(match.Features).OrEmpty(),
}
limits, _, err := catalogue.Resolve(ctx, plan, granted)
if err != nil {
return err
}
if err := models.UpsertEntitlement(ctx, models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: inst.Deployment,
Tier: match.Tier,
Term: match.Term,
Desired: granted,
Granted: granted,
ResolvedLimits: limits,
}); err != nil {
return err
}
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: match.Tier,
Term: match.Term,
Reason: reason,
IssuedBy: "paddle",
})
if err != nil {
return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
}
deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
return nil
}
// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
//
// The instance stays active until its licence expires, when the existing
// lifecycle sweep lapses it. Flipping the instance to cancelled here would stop
// inject.Reconcile and the sweep repairing a licence that is still valid — the
// opposite of "keeps working until it expires".
func handleCanceled(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.ID},
bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.Default.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
// handlePastDue flags the subscription and notifies, but leaves the licence
// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
func handlePastDue(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.ID},
bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.Default.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
// handleCustomerUpdated syncs the billing email onto the account.
func handleCustomerUpdated(ctx context.Context, ev Event) error {
d, err := decode[struct {
ID string `json:"id"`
Email string `json:"email"`
}](ev)
if err != nil {
return err
}
if d.ID == "" || d.Email == "" {
return nil
}
_, err = db.Admin("accounts").UpdateOne(ctx,
bson.M{"paddle_customer_id": d.ID},
bson.M{"$set": bson.M{"billing_email": d.Email}})
return err
}
func toSubItems(items []catalogue.Item) []models.SubItem {
out := make([]models.SubItem, 0, len(items))
for _, it := range items {
out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return out
}
func upsertSubscription(ctx context.Context, sub models.Subscription) error {
_, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
bson.M{"$set": bson.M{
"account_id": sub.AccountID,
"instance_id": sub.InstanceID,
"tier": sub.Tier,
"term": sub.Term,
"status": sub.Status,
"current_period_end": sub.CurrentPeriodEnd,
"items": sub.Items,
}, "$setOnInsert": bson.M{
"subscription_id": uuid.NewString(),
"paddle_subscription_id": sub.PaddleSubscriptionID,
}},
options.UpdateOne().SetUpsert(true))
return err
}
// billingEmailFor reads the account's billing email for self-hosted delivery.
func billingEmailFor(ctx context.Context, accountID string) string {
var acc models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": accountID}).Decode(&acc); err != nil {
return ""
}
return acc.BillingEmail
}
// instanceNameFor is a best-effort display name for an email subject.
func instanceNameFor(ctx context.Context, instanceID string) string {
// Alias-aware: a cancellation can name an id a relink has replaced, and "your instance"
// in place of the name the customer chose reads like the wrong email.
_, inst, err := resolveInstance(ctx, instanceID)
if err != nil || inst.Name == "" {
return "your instance"
}
return inst.Name
}
-124
View File
@@ -1,124 +0,0 @@
package billing
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
)
type transactionData struct {
ID string `json:"id"`
SubscriptionID string `json:"subscription_id"`
Origin string `json:"origin"`
Items []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
Quantity int `json:"quantity"`
} `json:"items"`
}
// handleTransactionCompleted issues the next term's licence on a renewal.
//
// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's
// desired (smaller) configuration becomes granted. Mid-term reductions never
// shrink a live licence. On a first charge (origin not recurring) the
// subscription.created/updated handler already issued, so this is a no-op to
// avoid a double issue.
func handleTransactionCompleted(ctx context.Context, ev Event) error {
d, err := decode[transactionData](ev)
if err != nil {
return err
}
if d.Origin != "subscription_recurring" {
return nil
}
if d.SubscriptionID == "" {
return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID)
}
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil {
return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err)
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err)
}
// Prefer the transaction's own item list (authoritative for this period);
// fall back to the subscription's recorded items.
items := make([]catalogue.Item, 0, len(d.Items))
for _, it := range d.Items {
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
}
if len(items) == 0 {
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err)
}
// Collapse a scheduled reduction: desired becomes granted, and the pending
// marker is cleared, since a new term has begun. This is the only place a
// licence ever gets a smaller cap.
if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil {
return err
}
// Issue the next term. Renewal resets relink_count inside licensing.Issue.
if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil {
return err
}
// Clear lifecycle notices so the next term starts the sequence fresh (mirrors
// the self-serve renew path).
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"notices_sent": ""}})
return nil
}
// promoteScheduledReduction collapses a pending reduction into granted at
// renewal and clears scheduled_change_at. A no-op when nothing is pending — the
// match resolved from the renewal's items is authoritative either way, so this
// only keeps the entitlement's own bookkeeping honest.
func promoteScheduledReduction(ctx context.Context, instanceID string) error {
ent, err := models.GetEntitlement(ctx, instanceID)
if err != nil {
return nil // no entitlement to reconcile
}
if ent.ScheduledChangeAt == nil {
return nil
}
ent.Granted = ent.Desired
ent.ScheduledChangeAt = nil
return models.UpsertEntitlement(ctx, *ent)
}
// handlePaymentFailed records the failure for staff visibility. No licence
// action — the licence runs to its (grace-padded) expiry and Paddle retries.
func handlePaymentFailed(ctx context.Context, ev Event) error {
d, err := decode[transactionData](ev)
if err != nil {
return err
}
if d.SubscriptionID == "" {
return nil
}
_, err = db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.SubscriptionID},
bson.M{"$set": bson.M{"status": models.SubPastDue}})
return err
}
-120
View File
@@ -1,120 +0,0 @@
// Package catalogue turns an entitlement into the two things derived from it:
// the limits and features a licence grants, and the Paddle line items a
// subscription is made of.
//
// Both folds live here so the arithmetic exists once. The temptation is to
// compute limits in the issuer and quantities in the checkout, and then the two
// disagree about whether the base allowance is included in the number — which is
// a bug that bills a customer for three servers they were given.
package catalogue
import (
"context"
"errors"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
var (
// ErrUnknownPrice means an item named a price ID no catalogue row claims.
//
// This is always a configuration error and never a customer error: someone
// bought something at a price we cannot map to a plan. It must fail loudly
// rather than guess a tier — a guessed tier is a wrong licence with no
// record of why.
ErrUnknownPrice = errors.New("no catalogue row claims that price ID")
// ErrNoBaseItem means no item matched a base row, so the subscription names
// no plan. Quantities are meaningless without one.
ErrNoBaseItem = errors.New("no item matches a base price; the subscription names no plan")
// ErrTermNotSold means a price resolved to a term its deployment does not
// sell — in practice a self-hosted monthly price.
ErrTermNotSold = errors.New("that deployment does not sell that term")
// ErrUnpriced means a component needed for this configuration has no price
// ID in this environment. Refusing is correct: a checkout that silently
// drops a paid line item gives away the thing it was meant to charge for.
ErrUnpriced = errors.New("component has no price in this environment")
)
// Resolve folds a configuration into the limits and features a licence grants.
//
// Limits start at the plan's base and each metered component adds its configured
// amount. Features are the plan's base features plus the configured ones,
// deduplicated and filtered to keys the catalogue actually offers — a stale
// feature key in a stored entitlement must not survive into a signed payload.
func Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error) {
rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
if err != nil {
return license.Limits{}, nil, err
}
limits := plan.BaseLimits
offered := map[string]bool{}
for _, r := range rows {
switch r.Kind {
case models.KindLimit:
if err := addLimit(&limits, r.LimitKey, configured(cfg, r.LimitKey), plan.BaseLimits); err != nil {
return license.Limits{}, nil, err
}
case models.KindFeature:
offered[r.FeatureKey] = true
}
}
seen := map[string]bool{}
features := []string{}
for _, f := range plan.BaseFeatures {
if !seen[f] {
seen[f] = true
features = append(features, f)
}
}
for _, f := range cfg.Features {
if seen[f] || !offered[f] {
continue
}
seen[f] = true
features = append(features, f)
}
return limits, features, nil
}
// configured reads the configured total for one metered limit key.
//
// A switch rather than reflection, so every metered dimension is greppable and
// adding one is a visible edit here as well as a catalogue row.
func configured(cfg models.Config, limitKey string) int {
switch limitKey {
case models.LimitKeyServers:
return cfg.Servers
default:
return 0
}
}
// addLimit sets a metered limit to its configured total.
//
// The configured value is a TOTAL, not an increment, so this assigns rather than
// adds. A base of Unlimited is left alone: nothing can be added to no cap, and a
// plan that meters an already-unlimited dimension is a configuration mistake
// rather than something to compute around.
func addLimit(l *license.Limits, limitKey string, total int, base license.Limits) error {
switch limitKey {
case models.LimitKeyServers:
if base.MaxServers == license.Unlimited {
return nil
}
if total > base.MaxServers {
l.MaxServers = total
}
return nil
case "":
return fmt.Errorf("catalogue limit row has no limit_key")
default:
return fmt.Errorf("%w: limit_key %q", ErrUnknownPrice, limitKey)
}
}
-206
View File
@@ -1,206 +0,0 @@
package catalogue
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// Item is one Paddle line item: a price and how many of it.
type Item struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
// LineItems builds the subscription items for a configuration.
//
// The base row is always quantity 1. A metered row's quantity is the configured
// TOTAL minus the plan's base allowance, so a Professional customer at exactly
// three servers has a single-item subscription rather than one with a zero
// quantity Paddle would reject. A feature with no price in this environment
// produces no item and is granted free.
func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) {
if !sells(plan.Deployment, term) {
return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term)
}
rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
ErrUnpriced, plan.Deployment, plan.Tier)
}
wanted := map[string]bool{}
for _, f := range cfg.Features {
wanted[f] = true
}
items := []Item{}
for _, r := range rows {
switch r.Kind {
case models.KindBase:
id := r.PriceID(env, term)
if id == "" {
return nil, fmt.Errorf("%w: base price for %s/%s in %s",
ErrUnpriced, plan.Deployment, plan.Tier, env)
}
items = append(items, Item{PriceID: id, Quantity: 1})
case models.KindLimit:
qty := billable(cfg, r.LimitKey, plan.BaseLimits)
if qty <= 0 {
continue
}
id := r.PriceID(env, term)
if id == "" {
return nil, fmt.Errorf("%w: %s price for %s/%s in %s",
ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env)
}
items = append(items, Item{PriceID: id, Quantity: qty})
case models.KindFeature:
if !wanted[r.FeatureKey] {
continue
}
id := r.PriceID(env, term)
if id == "" {
// Free to toggle. Resolve() still grants it.
continue
}
items = append(items, Item{PriceID: id, Quantity: 1})
}
}
return items, nil
}
// billable is how many UNITS to charge for a metered dimension.
//
// The configured value is the total the customer sees, which includes the base
// allowance they were given. Charging for that base is the single most likely
// bug in this file, so the subtraction lives here and nowhere else.
func billable(cfg models.Config, limitKey string, base license.Limits) int {
switch limitKey {
case models.LimitKeyServers:
if base.MaxServers == license.Unlimited {
return 0
}
return cfg.Servers - base.MaxServers
default:
return 0
}
}
// Match is what an item list says about itself.
type Match struct {
Deployment string
Tier string
Term string
Servers int
Features []string
}
// ResolveItems maps a full item list back to a plan and a configuration.
//
// This replaces a price-ID-to-tier lookup, which cannot work once a subscription
// has several prices. The base item identifies the plan and the term; everything
// else is read relative to it. An item matching nothing fails the whole list.
//
// Only the running environment's IDs are consulted, so a production process
// cannot be talked into resolving a sandbox price by a forged or misrouted
// event.
//
// It is a function of the COMPLETE list, which is what keeps out-of-order
// delivery correct by construction: Paddle sends every item on every
// subscription event, so reading all of them is reading current state rather
// than a transition.
func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) {
all, err := models.AllCatalogue(ctx)
if err != nil {
return Match{}, err
}
// Pass 1: find the base item. Until we know the plan, no other item means
// anything — a quantity of 7 is 7 of what?
var m Match
found := false
for _, it := range items {
for _, r := range all {
if r.Kind != models.KindBase {
continue
}
for _, term := range []string{"monthly", "annual"} {
if r.PriceID(env, term) != it.PriceID || it.PriceID == "" {
continue
}
if found {
return Match{}, fmt.Errorf(
"item list names two plans: %s/%s and %s/%s",
m.Deployment, m.Tier, r.Deployment, r.Tier)
}
m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term
found = true
}
}
}
if !found {
return Match{}, ErrNoBaseItem
}
if !sells(m.Deployment, m.Term) {
return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue",
ErrTermNotSold, m.Deployment, m.Term)
}
plan, err := models.GetPlan(ctx, m.Deployment, m.Tier)
if err != nil {
return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w",
m.Deployment, m.Tier, err)
}
m.Servers = plan.BaseLimits.MaxServers
m.Features = []string{}
// Pass 2: everything else, relative to that plan. An item matching no row of
// this plan is a configuration error even if it matches some other plan's
// row — mixing two plans in one subscription is not a thing we sell.
rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier)
if err != nil {
return Match{}, err
}
for _, it := range items {
matched := false
for _, r := range rows {
if r.PriceID(env, m.Term) != it.PriceID {
continue
}
matched = true
switch r.Kind {
case models.KindBase:
// Already handled.
case models.KindLimit:
if r.LimitKey == models.LimitKeyServers {
m.Servers = plan.BaseLimits.MaxServers + it.Quantity
}
case models.KindFeature:
m.Features = append(m.Features, r.FeatureKey)
}
}
if !matched {
return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)",
ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier)
}
}
return m, nil
}
// sells reports whether a deployment offers a term.
func sells(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
return true
}
}
return false
}
-224
View File
@@ -1,224 +0,0 @@
// Package cloudprov provisions cloud instances in the control plane.
//
// This is admin's second and final write path into the control-plane database,
// alongside inject. It writes `instances` and `users` and nothing else. A third
// write target, or a write to any other collection from here, is a design change
// and not a refactor — see the spec's "Admin's control-plane write boundary".
//
// Every function here is called from a customer request, so each one leaves the
// control plane in a consistent state or not at all: the caller unwinds in
// reverse order on failure, and RollbackInstance refuses to delete an instance
// that has users.
package cloudprov
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// CreateInstance creates a control-plane instance and its owner.
//
// The owner's password hash is COPIED from the HQ account rather than shared.
// HQ remains the single source of truth: a password change there copies the new
// hash to every projected row (see SetPasswordHash), and hqsync repairs any that
// a failed write left stale. The control plane has no local password-change path
// for an hq-sourced row, so there is no competing writer.
//
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
return CreateInstanceWithID(ctx, uuid.NewString(), name, ownerEmail, ownerPasswordHash, hqUserID)
}
// CreateInstanceWithID provisions a cloud instance under a caller-supplied ID and
// its owner. It backs the paid-cloud flow, where the ID is a placeholder created
// before payment and provisioning runs on the confirmed-payment webhook (see
// provision.CreateInstanceWithID).
//
// It is idempotent, because a webhook can be retried after provisioning partly
// completed: the instance is created only if absent, and the owner only if the
// instance has none yet. A second call therefore converges to the same state
// rather than colliding on the per-instance email unique index.
func CreateInstanceWithID(ctx context.Context, instanceID, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
inst, err := provision.CreateInstanceWithID(ctx, db.ControlDB(), instanceID, name)
if err != nil {
return nil, err
}
// A retry that already created the owner must not create a second one.
if _, err := OwnerUserID(ctx, inst.InstanceID); err == nil {
return inst, nil
}
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
if err != nil {
if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
}
return nil, err
}
// hq_user_id is what phase 3 uses to find every row projected from one HQ
// user when its password changes. Set at creation so the owner is not a
// special case later.
if _, err := db.Control("users").UpdateOne(ctx,
bson.M{"user_id": u.UserID},
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
return nil, fmt.Errorf("set hq_user_id: %w", err)
}
return inst, nil
}
// DeleteUser removes one control-plane user. Used only to unwind a failed
// provision.
func DeleteUser(ctx context.Context, instanceID, userID string) error {
_, err := db.Control("users").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
return err
}
// RollbackInstance deletes an instance that has no users.
func RollbackInstance(ctx context.Context, instanceID string) error {
return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
}
// OwnerUserID returns the control-plane user_id of an instance's owner, so a
// caller can unwind a partial provision without re-deriving it.
func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
var u sharedmodels.User
err := db.Control("users").FindOne(ctx, bson.M{
"instance_id": instanceID,
"role": sharedmodels.RoleOwner,
}).Decode(&u)
if err != nil {
return "", err
}
return u.UserID, nil
}
// GrantUser projects an HQ person into a control-plane instance.
//
// The password hash is copied from customer_users rather than re-derived: HQ
// owns the password, and a grant that asked for a password again would create
// a second credential for one person.
//
// The row is written with auth_source "hq" and hq_user_id set, which is what
// makes the control plane refuse to edit it locally and what lets a password
// change find it later.
func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
email, passwordHash, role, sharedmodels.AuthHQ)
if err != nil {
return nil, err
}
if _, err := db.Control("users").UpdateOne(ctx,
bson.M{"user_id": u.UserID},
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
// Unwind: a projected row with no hq_user_id is invisible to revoke and
// to password propagation, which is worse than no row at all.
_, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
return nil, fmt.Errorf("set hq_user_id: %w", err)
}
u.HQUserID = hqUserID
return u, nil
}
// RevokeUser deletes the projected row for one person in one instance.
//
// Deleting rather than disabling is deliberate: the control plane has no
// concept of a disabled user, and a row that still exists is a row that can
// still sign in.
func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
_, err := db.Control("users").DeleteOne(ctx, bson.M{
"instance_id": instanceID,
"hq_user_id": hqUserID,
})
return err
}
// SetMemberRole changes a projected user's role inside one instance.
func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
if !sharedmodels.ValidRole(role) {
return fmt.Errorf("invalid role %q", role)
}
res, err := db.Control("users").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
bson.M{"$set": bson.M{"role": role}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return fmt.Errorf("no projected user in instance %s", instanceID)
}
return nil
}
// CountOtherOwners counts owners of an instance other than one HQ person.
//
// It counts CONTROL-PLANE owners, so an owner created locally inside the
// instance counts too. That matters: refusing to revoke the last HQ owner of
// an instance that has three local owners would be a refusal with no cause.
//
// $ne matches documents where the field is absent, which is exactly how a
// locally-created owner is stored.
func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
return db.Control("users").CountDocuments(ctx, bson.M{
"instance_id": instanceID,
"role": sharedmodels.RoleOwner,
"hq_user_id": bson.M{"$ne": exceptHQUserID},
})
}
// SetPasswordHash writes one hash to every row projected from one HQ person,
// across every instance, and reports how many it changed.
func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
res, err := db.Control("users").UpdateMany(ctx,
bson.M{"hq_user_id": hqUserID},
bson.M{"$set": bson.M{"password_hash": hash}})
if err != nil {
return 0, err
}
return res.ModifiedCount, nil
}
// ProjectedUsers returns every control-plane row projected from one HQ person.
// hqsync uses it to compare hashes.
func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
if err != nil {
return nil, err
}
var users []sharedmodels.User
if err := cur.All(ctx, &users); err != nil {
return nil, err
}
return users, nil
}
// RenameInstance changes a cloud instance's name and moves it to the slug that
// name derives to.
//
// It writes `instances` and nothing else, so admin's control-plane write
// boundary is unchanged. It issues no licence: a licence binds the instance
// UUID, which a rename never touches.
//
// The previous name and slug come back with the result because they are what an
// unwind must restore — admin's own copy can be stale, or slugless.
func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err error) {
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
}
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
// caller unwinding a rename whose admin-side write failed. Leaving the two
// databases disagreeing would have HQ print a host that is not the host.
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
}
-145
View File
@@ -1,145 +0,0 @@
// Package config parses and validates admin's environment.
//
// Everything required is checked at boot and the process refuses to start
// without it. A licensing service that cannot sign is worse than one that is
// down, because it looks healthy.
package config
import (
"fmt"
"net/url"
"os"
"strings"
"time"
)
type Config struct {
AdminMongoURI string
AdminDBName string
ControlMongoURI string
ControlDBName string
RedisAddr string
RedisUsername string
RedisPassword string
SigningKey string
PublicURL string
AppLoginURL string
AllowedOrigins []string
TrustProxy bool
Addr string
ReapAfter time.Duration
PaddleEnv string // "sandbox" or "production"
PaddleAPIKey string
PaddleWebhookSecret string
SMTPHost string
SMTPPort string
SMTPFrom string
SMTPUsername string
SMTPPassword string
}
// dbNameFromURI reads the database from a Mongo URI path.
//
// Both URIs must name their database inline rather than through a separate
// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous
// about which, and guessing wrong means writing licence fields into the wrong
// place.
func dbNameFromURI(raw, which string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s is not a valid URI: %w", which, err)
}
name := strings.TrimPrefix(u.Path, "/")
if name == "" {
return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which)
}
return name, nil
}
func Load() (Config, error) {
c := Config{
AdminMongoURI: os.Getenv("ADMIN_MONGO_URI"),
ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"),
RedisAddr: os.Getenv("REDIS_ADDR"),
// Optional: an unauthenticated Redis needs neither. Redis 6+ ACL auth
// takes both; a legacy `requirepass` instance takes the password alone
// and must leave the username empty.
RedisUsername: os.Getenv("REDIS_USERNAME"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
SigningKey: os.Getenv("LICENSE_SIGNING_KEY"),
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
AppLoginURL: os.Getenv("APP_LOGIN_URL"),
TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
Addr: ":" + envOr("PORT", "8083"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: envOr("SMTP_PORT", "587"),
SMTPFrom: os.Getenv("SMTP_FROM"),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
PaddleEnv: envOr("PADDLE_ENV", "sandbox"),
PaddleAPIKey: os.Getenv("PADDLE_API_KEY"),
PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"),
}
var missing []string
for name, v := range map[string]string{
"ADMIN_MONGO_URI": c.AdminMongoURI,
"CONTROL_MONGO_URI": c.ControlMongoURI,
"REDIS_ADDR": c.RedisAddr,
"LICENSE_SIGNING_KEY": c.SigningKey,
"PUBLIC_URL": c.PublicURL,
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
// An unverified webhook endpoint is one anyone can issue licences
// through, so the secret and API key are boot-required.
"PADDLE_API_KEY": c.PaddleAPIKey,
"PADDLE_WEBHOOK_SECRET": c.PaddleWebhookSecret,
} {
if v == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
}
var err error
if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil {
return Config{}, err
}
if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil {
return Config{}, err
}
if c.AdminMongoURI == c.ControlMongoURI {
return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database")
}
for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") {
if o = strings.TrimSpace(o); o != "" {
c.AllowedOrigins = append(c.AllowedOrigins, o)
}
}
// Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can
// name the real deletion date. An unparseable value is refused rather than
// silently treated as "off": a typo here would quietly stop every deletion
// warning while the control plane still deletes.
if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err)
}
c.ReapAfter = d
}
return c, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
-171
View File
@@ -1,171 +0,0 @@
// Package db holds admin's two MongoDB connections.
//
// Admin() is its own database and it owns every collection there. Control() is
// the control plane's database. Admin's access to it is narrow and lives in
// exactly two packages: inject writes three licence fields on `instances`, and
// cloudprov creates and rolls back `instances` and `users` when a customer
// provisions a cloud instance. Nothing else may write there, and a third write
// path is a design change rather than a refactor.
package db
import (
"context"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
adminDB *mongo.Database
controlDB *mongo.Database
)
func Connect(ctx context.Context, cfg config.Config) error {
ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI))
if err != nil {
return fmt.Errorf("connect admin mongo: %w", err)
}
if err := ac.Ping(ctx, nil); err != nil {
return fmt.Errorf("ping admin mongo: %w", err)
}
adminDB = ac.Database(cfg.AdminDBName)
cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI))
if err != nil {
return fmt.Errorf("connect control mongo: %w", err)
}
if err := cc.Ping(ctx, nil); err != nil {
return fmt.Errorf("ping control mongo: %w", err)
}
controlDB = cc.Database(cfg.ControlDBName)
// The control plane must already be deployed and migrated. Without the
// instances collection, injection would silently create it and write
// licence fields into a collection nothing reads.
names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"})
if err != nil {
return fmt.Errorf("inspect control database: %w", err)
}
if len(names) == 0 {
return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName)
}
return nil
}
func Admin(name string) *mongo.Collection { return adminDB.Collection(name) }
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }
// ControlDB exposes the control-plane database itself, because shared/provision
// takes a database rather than a collection.
//
// It is used by cloudprov and nothing else. Reach for Control(name) unless you
// are calling into shared/provision.
func ControlDB() *mongo.Database { return controlDB }
func Ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
// EnsureIndexes creates admin's unique indexes.
//
// These are a correctness property, not an optimisation. In particular
// admin_instances.instance_id unique is what stops the same self-hosted UUID
// being linked to two accounts — without it, two customers could both claim one
// instance and both be issued licences for it.
func EnsureIndexes(ctx context.Context) error {
unique := []struct {
coll string
field string
}{
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"paddle_events", "event_id"},
{"staff_users", "email"},
{"customer_users", "email"},
}
for _, u := range unique {
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: u.field, Value: 1}},
Options: options.Index().SetUnique(true).SetName(u.field + "_unique"),
}); err != nil {
return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err)
}
}
// Sparse: a subscription exists before Paddle assigns an ID, so empty must
// not collide with empty.
if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "paddle_subscription_id", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"),
}); err != nil {
return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err)
}
// plans was unique on tier alone until spec 7. Mongo will not replace an
// index implicitly, and the old one would refuse the second row of every
// tier, so it is dropped by name here. Dropping a missing index is not an
// error worth failing boot over — a fresh database has never had it.
if err := Admin("plans").Indexes().DropOne(ctx, "tier_unique"); err != nil {
log.Printf("index plans.tier_unique: not dropped (%v); expected on a fresh database", err)
}
for _, u := range []struct {
coll string
keys bson.D
name string
}{
{"plans", bson.D{{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}}, "deployment_tier_unique"},
{"catalogue", bson.D{
{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}, {Key: "kind", Value: 1},
{Key: "limit_key", Value: 1}, {Key: "feature_key", Value: 1},
}, "component_unique"},
{"entitlements", bson.D{{Key: "instance_id", Value: 1}}, "instance_id_unique"},
} {
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: u.keys,
Options: options.Index().SetUnique(true).SetName(u.name),
}); err != nil {
return fmt.Errorf("index %s.%s: %w", u.coll, u.name, err)
}
}
for _, idx := range []struct {
coll string
keys bson.D
}{
{"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}},
{"admin_instances", bson.D{{Key: "account_id", Value: 1}}},
{"admin_audit", bson.D{{Key: "created_at", Value: -1}}},
} {
if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil {
return fmt.Errorf("index %s: %w", idx.coll, err)
}
}
// One person holds at most one user in one instance. This is the property
// that makes a grant idempotent-by-refusal rather than silently doubling a
// projection, and it mirrors users' own (instance_id, email) uniqueness.
if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}},
Options: options.Index().SetUnique(true).
SetName("instance_customer_user_unique"),
}); err != nil {
return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err)
}
for _, keys := range []bson.D{
{{Key: "account_id", Value: 1}},
{{Key: "customer_user_id", Value: 1}},
} {
if _, err := Admin("instance_members").Indexes().CreateOne(ctx,
mongo.IndexModel{Keys: keys}); err != nil {
return fmt.Errorf("index instance_members: %w", err)
}
}
return nil
}
-116
View File
@@ -1,116 +0,0 @@
// Package hqsync keeps projected control-plane users consistent with the HQ
// people they were projected from.
//
// It is separate from inject on purpose. inject writes exactly three licence
// fields on `instances` and that narrowness is the reason admin's reach into
// the control plane is reviewable at all; a password repair pass bolted onto it
// would quietly turn it into "the package that writes whatever admin wants".
// This one goes through cloudprov, which is the sanctioned user write path.
package hqsync
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Interval matches inject's reconciler. Fifteen minutes is the worst-case
// staleness a password change can suffer, which the spec accepts as
// recoverable.
const Interval = 15 * time.Minute
// Reconcile compares every projected user's stored hash against the HQ hash it
// came from, and repairs mismatches.
//
// The comparison is on the hash string, not the password: two bcrypt hashes of
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
// re-hashing. That is also why propagation copies rather than re-derives.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
cur, err := db.Admin("customer_users").Find(ctx,
bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
if err != nil {
return 0, 0, err
}
var people []models.CustomerUser
if err := cur.All(ctx, &people); err != nil {
return 0, 0, err
}
for _, p := range people {
projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
if err != nil {
log.Printf("hqsync: read projections of %s: %v", p.Email, err)
continue
}
stale := false
for _, u := range projected {
checked++
if u.PasswordHash != p.PasswordHash {
stale = true
}
}
if !stale {
// Clear a stale failure flag: the instances agree, whatever the
// flag says. Nothing reads the flag to decide what to repair.
if p.HQSyncFailedAt != nil {
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": p.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
continue
}
n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
if err != nil {
log.Printf("hqsync: repair %s: %v", p.Email, err)
continue
}
repaired += int(n)
log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": p.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
return checked, repaired, nil
}
// Start runs once at boot, then on a ticker until ctx is cancelled.
//
// The boot pass is for the same reason inject's is: the likeliest moment for a
// half-applied write is a deploy or a crash, and waiting a full interval to
// notice means a customer's new password does not work somewhere for fifteen
// minutes after we already know how to fix it.
func Start(ctx context.Context) {
go func() {
runOnce(ctx)
t := time.NewTicker(Interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
runOnce(ctx)
}
}
}()
}
func runOnce(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
checked, repaired, err := Reconcile(runCtx)
if err != nil {
log.Printf("hqsync: %v", err)
return
}
if repaired > 0 {
log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
}
}
-178
View File
@@ -1,178 +0,0 @@
// Package inject writes licences onto control-plane instance documents.
//
// This is admin's ONLY write path into the control plane, and it touches exactly
// three fields on one collection. If this package ever grows a second write
// target, that is a design change and not a refactor.
package inject
import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ReconcileInterval is how often every cloud instance is compared against what
// admin believes it should hold.
//
// This job, not the issuance path, is what guarantees eventual consistency.
// Injection at issue time is best-effort; this is the backstop.
const ReconcileInterval = 15 * time.Minute
// Cloud writes the licence onto the control-plane instance document.
//
// Idempotent and safe to re-run: it is a single UpdateOne of three fields with
// no read-modify-write. Retries three times with backoff.
//
// The control plane caches licence state for 60 seconds, so this takes effect
// within a minute with no restart.
func Cloud(ctx context.Context, lic *models.License) error {
set := bson.M{"$set": bson.M{
"license_blob": lic.Blob,
"license_tier": lic.Tier,
"license_expiry": lic.ExpiresAt,
}}
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
res, err := db.Control("instances").UpdateOne(ctx,
bson.M{"instance_id": lic.InstanceID}, set)
if err == nil {
if res.MatchedCount == 0 {
return fmt.Errorf("no control-plane instance %s", lic.InstanceID)
}
return nil
}
lastErr = err
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
return fmt.Errorf("inject after 3 attempts: %w", lastErr)
}
// Deliver injects and records the outcome without ever failing the caller.
//
// A licence that is recorded but not injected is recoverable — the reconciler
// will fix it within 15 minutes, and staff can see it on the health endpoint.
// Failing the purchase because one write failed would be worse.
func Deliver(ctx context.Context, lic *models.License) {
if err := Cloud(ctx, lic); err != nil {
log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err)
now := time.Now().UTC()
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": lic.InstanceID},
bson.M{"$set": bson.M{"inject_failed_at": now}})
return
}
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": lic.InstanceID},
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
}
// Reconcile compares every active cloud instance's current licence against the
// blob actually stored in the control plane, and re-injects on mismatch.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
"deployment": license.DeploymentCloud,
"status": models.StatusActive,
"current_license": bson.M{"$ne": ""},
})
if err != nil {
return 0, 0, err
}
var instances []models.Instance
if err := cur.All(ctx, &instances); err != nil {
return 0, 0, err
}
for _, inst := range instances {
checked++
var lic models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense)
continue
}
var remote sharedmodels.Instance
if err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
// The control plane's reaper deletes lapsed Free instances. Record
// that here rather than re-logging it every fifteen minutes forever,
// and so the lifecycle sweep stops emailing about it.
if errors.Is(err, mongo.ErrNoDocuments) {
if _, uErr := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil {
log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr)
} else {
log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID)
}
continue
}
log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
continue
}
if remote.LicenseBlob == lic.Blob {
continue
}
log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID)
if err := Cloud(ctx, &lic); err != nil {
log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err)
continue
}
repaired++
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
}
return checked, repaired, nil
}
// StartReconciler reconciles once at boot, then on a ticker until ctx is
// cancelled.
//
// The pass at boot matters: injection failures are most likely around a deploy
// or a crash, and waiting a full interval to notice would leave a paying
// customer read-only for that long. It also means a restart is a supported way
// to force reconciliation.
func StartReconciler(ctx context.Context) {
go func() {
runOnce(ctx)
t := time.NewTicker(ReconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
runOnce(ctx)
}
}
}()
}
func runOnce(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
checked, repaired, err := Reconcile(runCtx)
if err != nil {
log.Printf("reconcile: %v", err)
return
}
if repaired > 0 {
log.Printf("reconcile: checked %d, repaired %d", checked, repaired)
}
}
-214
View File
@@ -1,214 +0,0 @@
// Package licensing issues licences. It is the only place that signs.
package licensing
import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
ErrUnknownTier = errors.New("unknown tier")
ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type")
ErrUnknownInstance = errors.New("instance not found")
)
type IssueInput struct {
InstanceID string
Tier string
Term string // "monthly" or "annual"; ignored when ExpiresAt is set
ExpiresAt time.Time // explicit expiry, used by relink to preserve the remaining term
Reason string
IssuedBy string // staff email, "system", or "paddle:<event id>"
}
// signingKey is set once at boot from LICENSE_SIGNING_KEY.
var signingKey string
func SetSigningKey(k string) { signingKey = k }
// Issue signs a licence, records it, supersedes its predecessor and updates the
// instance.
//
// It does NOT deliver. Recording and delivery are deliberately separate and
// ordered: a licence recorded but not delivered is recoverable, because the
// customer can download it. A licence delivered but not recorded is a support
// mystery with no paper trail. Callers deliver after this returns.
func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
if signingKey == "" {
return nil, errors.New("no signing key configured")
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil {
return nil, ErrUnknownInstance
}
// The plan is looked up by the INSTANCE's deployment, not by a caller's
// guess. That is what makes the deployment comparison below a consistency
// check rather than the thing that decides which plan applies.
plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier)
if err != nil {
return nil, ErrUnknownTier
}
// This single comparison is what makes Free cloud-only. Free's plan is
// deployment "cloud", so it can never be issued against a self-hosted
// instance, and verification on the instance would reject it anyway.
if plan.Deployment != inst.Deployment {
return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment)
}
if plan.Tier == license.TierFree {
if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil {
return nil, err
}
}
// What this licence grants comes from the instance's entitlement, not from
// the plan. The plan is only the base.
//
// An instance with no entitlement gets the plan's base, which covers staff
// manual issuance and anything predating the backfill. Falling back is
// deliberate: refusing here would make a missing row an outage rather than a
// default.
limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty())
ent, entErr := models.GetEntitlement(ctx, inst.InstanceID)
switch {
case entErr == nil:
// Granted, never Desired. A configuration nobody has paid for must not
// reach a signed payload.
limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted)
if err != nil {
return nil, fmt.Errorf("resolve entitlement: %w", err)
}
case errors.Is(entErr, models.ErrNoEntitlement):
log.Printf("licensing: instance %s has no entitlement; issuing plan base",
inst.InstanceID)
default:
return nil, fmt.Errorf("read entitlement: %w", entErr)
}
now := time.Now().UTC()
expires := in.ExpiresAt
if expires.IsZero() {
switch in.Term {
case "monthly":
expires = now.AddDate(0, 1, 0).Add(models.GracePeriod)
case "annual", "":
expires = now.AddDate(1, 0, 0).Add(models.GracePeriod)
default:
return nil, fmt.Errorf("unknown term %q", in.Term)
}
}
payload := license.License{
ID: uuid.NewString(),
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
InstanceName: inst.Name,
Tier: plan.Tier,
Deployment: plan.Deployment,
IssuedAt: now,
ExpiresAt: expires,
// Snapshotted, not referenced: editing a plan or an entitlement tomorrow
// must not change what this licence grants.
Limits: limits,
Features: features,
SupportLevel: plan.SupportLevel,
}
blob, err := license.Sign(payload, signingKey)
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
rec := models.License{
LicenseID: payload.ID,
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Tier: plan.Tier,
Deployment: plan.Deployment,
Limits: limits,
Features: models.Features(features).OrEmpty(),
IssuedAt: now,
ExpiresAt: expires,
Blob: blob,
IssuedBy: in.IssuedBy,
Reason: in.Reason,
}
if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil {
return nil, fmt.Errorf("record licence: %w", err)
}
// Supersede rather than delete. The history is the support tool.
if inst.CurrentLicense != "" {
if _, err := db.Admin("licenses").UpdateOne(ctx,
bson.M{"license_id": inst.CurrentLicense},
bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil {
return nil, fmt.Errorf("supersede previous licence: %w", err)
}
}
set := bson.M{
"current_license": rec.LicenseID,
"tier": plan.Tier,
"status": models.StatusActive,
}
if in.Reason == models.ReasonRenewal {
set["relink_count"] = 0 // the cap is per term
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
return nil, fmt.Errorf("update instance: %w", err)
}
audit.Write(ctx, models.AuditEntry{
Actor: in.IssuedBy,
Action: "license.issued",
AccountID: inst.AccountID,
Target: inst.InstanceID,
Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s",
plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID),
})
return &rec, nil
}
// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT.
//
// It used to be one per account, which was sufficient while Free existed only on
// cloud. With a self-hosted Free plan, an account-wide count would refuse a
// self-hosted Free instance to anyone holding a cloud one, and tell them about a
// limit they have not reached.
//
// Cancelled instances do not count: a customer who cancelled their Free instance
// is allowed another one.
func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error {
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": accountID,
"deployment": deployment,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
"instance_id": bson.M{"$ne": exceptInstanceID},
})
if err != nil {
return err
}
if n > 0 {
return ErrFreeLimit
}
return nil
}
-177
View File
@@ -1,177 +0,0 @@
package licensing
import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var (
ErrBadUUID = errors.New("that does not look like an instance ID")
ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
ErrRelinkLimit = errors.New("relink limit reached for this term; contact support")
)
// LinkInstance attaches a self-hosted instance UUID to an account.
//
// The duplicate error deliberately does not say WHICH account holds it. It is a
// small enumeration surface, but there is no reason to leave it open.
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
if _, err := uuid.Parse(instanceID); err != nil {
return nil, ErrBadUUID
}
// A self-hosted UUID must not collide with a cloud instance either.
if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
return nil, ErrAlreadyLinked
}
inst := models.Instance{
InstanceID: instanceID,
AccountID: accountID,
Name: name,
Deployment: license.DeploymentSelfHosted,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
// The unique index is what actually prevents two accounts owning
// one instance. The check above is a nicety; this is the guarantee.
return nil, ErrAlreadyLinked
}
return nil, err
}
audit.Write(ctx, models.AuditEntry{
Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
return &inst, nil
}
// RepointSubscriptions follows an instance identity rewrite: it moves every
// subscription row from the old id to the new one, then rewrites Paddle's copy
// of custom_data so future webhooks decode to the new id.
//
// The local rewrite is returned as an error — issuance reads the subscription
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
// customer must not be blocked from relinking by an outbound API
// failure, and the caller has already recorded the old id in
// previous_instance_ids, which is what makes the webhook path correct whether or
// not the patch lands.
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
}
cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
if err != nil {
log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
return nil
}
var subs []models.Subscription
if err := cur.All(ctx, &subs); err != nil {
log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
return nil
}
for _, s := range subs {
if s.PaddleSubscriptionID == "" {
continue
}
// Paddle replaces the whole custom_data object on a PATCH, so account_id
// is sent alongside rather than dropped.
if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
oldID, newID, s.PaddleSubscriptionID, err)
}
}
return nil
}
// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
// a way to extend a subscription.
//
// The old licence is not revoked, because offline verification has no
// revocation. It simply no longer matches any UUID the customer controls, and
// its binding stops it working on another machine anyway.
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
if _, err := uuid.Parse(newID); err != nil {
return nil, ErrBadUUID
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
return nil, ErrUnknownInstance
}
// The cap is a signal, not a defence. Its job is to put a human in front of
// the fourth attempt, so staff bypass it.
if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
return nil, ErrRelinkLimit
}
if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
return nil, ErrAlreadyLinked
}
// Preserve the remaining term from the current licence.
remaining := time.Now().UTC().Add(models.GracePeriod)
var current models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err == nil {
remaining = current.ExpiresAt
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": oldID},
bson.M{
"$set": bson.M{"instance_id": newID},
"$inc": bson.M{"relink_count": 1},
"$addToSet": bson.M{"previous_instance_ids": oldID},
}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrAlreadyLinked
}
return nil, fmt.Errorf("relink: %w", err)
}
// A relink rewrites the instance's identity, so two things have to follow it:
// the subscription rows that named the old id, and Paddle's own copy of
// custom_data. Without this a
// renewal after a relink cannot find its instance and the term never extends.
if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
return nil, err
}
actor := accountID
if staff {
actor = "staff"
}
audit.Write(ctx, models.AuditEntry{
Actor: actor, Action: "instance.relinked", AccountID: accountID,
Target: newID, Detail: "was " + oldID})
return Issue(ctx, IssueInput{
InstanceID: newID,
Tier: inst.Tier,
ExpiresAt: remaining,
Reason: models.ReasonRelink,
IssuedBy: actor,
})
}
-194
View File
@@ -1,194 +0,0 @@
// Package lifecycle marks lapsed Free instances and sends the renewal notices.
//
// It sends; it never deletes. Deletion belongs to the control plane, which is
// the only service that knows what an instance is made of. The two are kept
// apart on purpose: a bug here sends a wrong email, a bug there loses data.
package lifecycle
import (
"context"
"log"
"slices"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Interval is how often the sweep runs. Hourly is far finer than the daily
// granularity of the notices, which means a notice goes out within an hour of
// becoming due rather than up to a day late.
const Interval = time.Hour
// Notice keys, recorded on the instance so a restart cannot re-send one.
const (
noticeExpiring = "expiring"
noticeExpired = "expired"
noticeDelete7 = "delete_7"
noticeDelete1 = "delete_1"
)
// portalURL is the customer portal address used in notice emails.
var portalURL string
// SetPortalURL is called once at boot.
func SetPortalURL(v string) { portalURL = v }
// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails
// can name the real deletion date. Zero means the reaper is off, and the
// deletion notices are then suppressed — promising a deletion that will never
// happen would be a lie, and a scarier one than saying nothing.
var reapAfter time.Duration
// Run performs one sweep: mark lapsed instances, then send whatever notices are
// due. Errors on one instance never stop the others.
func Run(ctx context.Context) error {
now := time.Now().UTC()
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
"deployment": license.DeploymentCloud,
"tier": license.TierFree,
"status": bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}},
})
if err != nil {
return err
}
var instances []models.Instance
if err := cur.All(ctx, &instances); err != nil {
return err
}
for _, inst := range instances {
var lic models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
continue // no licence yet; nothing to expire
}
// Flip active -> lapsed once the licence is past its expiry.
if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive {
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil {
log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err)
}
}
if lic.ExpiresAt.Add(reapAfter).Before(now) {
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
}
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
}
}
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
if due == "" {
continue
}
if err := sendNotice(ctx, inst, lic, due); err != nil {
log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err)
continue
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err)
}
}
return nil
}
// dueNotice returns the most urgent unsent notice, or "".
//
// Most urgent first, so an instance that was missed for a week — because admin
// was down — sends the one that matters now rather than working through a
// backlog of stale warnings.
func dueNotice(now, expires time.Time, sent []string) string {
deleteOn := expires.Add(reapAfter)
if reapAfter > 0 {
if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) {
return noticeDelete1
}
if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) {
return noticeDelete7
}
}
if now.After(expires) && !slices.Contains(sent, noticeExpired) {
return noticeExpired
}
if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) {
return noticeExpiring
}
return ""
}
func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error {
if !mail.Enabled() {
return nil
}
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil {
return err
}
to := acct.BillingEmail
deleteOn := lic.ExpiresAt.Add(reapAfter)
switch key {
case noticeExpiring:
return mail.Default.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
case noticeExpired:
return mail.Default.SendExpired(to, inst.Name, portalURL, deleteOn)
case noticeDelete7:
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
case noticeDelete1:
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
}
return nil
}
// Start runs the sweep on a ticker until ctx is cancelled.
//
// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they
// disagree, the emails name a date the reaper does not honour — so they are
// documented as a pair in CLAUDE.md and set together in the compose file.
func Start(ctx context.Context, reapAfterDur time.Duration) {
reapAfter = reapAfterDur
go func() {
runOnce(ctx)
t := time.NewTicker(Interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
runOnce(ctx)
}
}
}()
}
func runOnce(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
if err := Run(runCtx); err != nil {
log.Printf("lifecycle: %v", err)
}
}
func accountEmail(ctx context.Context, accountID string) string {
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": accountID}).Decode(&acct); err != nil {
return ""
}
return acct.BillingEmail
}
-19
View File
@@ -1,19 +0,0 @@
// Package mail holds admin's configured email sender.
//
// The transport, the templates and the look all live in shared/mail, which the
// control plane and sitesvc use too — this package exists only so that admin's
// mail configuration is a boot-time singleton like licensing's signing key,
// paddle's client and auth's Redis handle, rather than a value threaded through
// api, auth, billing and lifecycle.
package mail
import "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
// Default is admin's sender. Set once by main; read everywhere else.
var Default mail.Sender
func Init(s mail.Sender) { Default = s }
// Enabled reports whether SMTP is configured. Callers check it to skip a send
// politely rather than logging a failure per message.
func Enabled() bool { return Default.Enabled() }
-369
View File
@@ -1,369 +0,0 @@
package models
import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
// SeedPlans.
//
// The original three rows were keyed on tier alone: (cloud,free),
// (cloud,professional) and a self_hosted TIER row. Spec 7 re-keys on
// (deployment, tier), makes self_hosted a deployment rather than a tier, and
// renames limits/features to base_limits/base_features.
//
// Ordering is the whole point. SeedPlans inserts a fresh (self_hosted,
// professional) row; if the legacy self_hosted row is only renamed afterwards it
// collides with that seed on deployment_tier_unique. Running here, before the
// seed, the rename lands first and the seed then no-ops on it.
//
// It is idempotent and self-healing: on a database where a previous boot already
// seeded (self_hosted, professional) before crashing, the legacy row can no
// longer be renamed onto it, so the legacy row is dropped instead — its
// instances are re-tiered and re-entitled from the surviving professional row.
func MigrateLegacyPlans(ctx context.Context) error {
// Legacy cloud rows may predate the deployment field entirely.
if _, err := db.Admin("plans").UpdateMany(ctx,
bson.M{"deployment": bson.M{"$exists": false},
"tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}},
bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil {
return err
}
// The legacy self_hosted TIER row becomes self-hosted Professional. If that
// target already exists (a prior partial boot seeded it), drop the legacy row
// rather than colliding — the seeded row carries the same professional base.
var legacy Plan
err := db.Admin("plans").FindOne(ctx, bson.M{"tier": license.TierSelfHosted}).Decode(&legacy)
switch {
case err == nil:
targetErr := db.Admin("plans").FindOne(ctx,
bson.M{"deployment": license.DeploymentSelfHosted, "tier": license.TierProfessional}).Err()
if targetErr == nil {
if _, err := db.Admin("plans").DeleteOne(ctx, bson.M{"_id": legacy.ID}); err != nil {
return err
}
log.Printf("backfill: dropped legacy self_hosted plan row; (self_hosted, professional) already present")
} else if errors.Is(targetErr, mongo.ErrNoDocuments) {
if _, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"_id": legacy.ID},
bson.M{"$set": bson.M{
"deployment": license.DeploymentSelfHosted,
"tier": license.TierProfessional,
"name": "Professional",
}}); err != nil {
return err
}
log.Printf("backfill: re-keyed legacy self_hosted plan to (self_hosted, professional)")
} else {
return targetErr
}
case errors.Is(err, mongo.ErrNoDocuments):
// No legacy row; a fresh database or an already-migrated one.
default:
return err
}
// limits/features become base_limits/base_features on any row still carrying
// the old names.
if _, err := db.Admin("plans").UpdateMany(ctx,
bson.M{"limits": bson.M{"$exists": true}},
bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil {
return err
}
// Support level is new, so nothing has one. Fill from the seed table rather
// than guessing: a plan row a human edited keeps every other field.
for _, deployment := range license.Deployments() {
for _, tier := range license.Tiers() {
p, ok := license.PlanFor(deployment, tier)
if !ok {
continue
}
if _, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"deployment": deployment, "tier": tier,
"support_level": bson.M{"$in": bson.A{nil, ""}}},
bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil {
return err
}
}
}
return nil
}
// Backfill brings pre-phase-3 data up to the membership model.
//
// It runs on every boot and is idempotent by construction: both passes filter
// on the absence of what they write. There is no migrations collection in
// admin, and adding one for two `$exists: false` queries would be more
// machinery than the job deserves.
//
// It lives in models rather than db for the same reason SeedPlans does: db is
// the connection layer and importing models there is an import cycle.
func Backfill(ctx context.Context) error {
// Pass 1: every existing customer_user created their own account, so they
// are all owners. A row with no account_role would otherwise be able to do
// nothing at all once the guards land — including managing the account it
// created.
res, err := db.Admin("customer_users").UpdateMany(ctx,
bson.M{"account_role": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"account_role": AccountRoleOwner}})
if err != nil {
return err
}
if res.ModifiedCount > 0 {
log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
}
// Pass 2: phase 2 created cloud instances and their owners without an
// instance_members row, because the collection did not exist. Reconstruct
// one per instance from the control-plane owner it actually created.
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
"deployment": license.DeploymentCloud,
"status": bson.M{"$ne": StatusDeleted},
})
if err != nil {
return err
}
var instances []Instance
if err := cur.All(ctx, &instances); err != nil {
return err
}
created := 0
for _, inst := range instances {
n, err := db.Admin("instance_members").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
return err
}
if n > 0 {
continue
}
// Only an hq-sourced owner can be reconstructed: a control-plane owner
// with no hq_user_id was created inside the instance and belongs to
// nobody on this side. Leaving it unrecorded is correct.
var owner sharedmodels.User
err = db.Control("users").FindOne(ctx, bson.M{
"instance_id": inst.InstanceID,
"role": sharedmodels.RoleOwner,
"hq_user_id": bson.M{"$nin": bson.A{nil, ""}},
}).Decode(&owner)
if err != nil {
if err != mongo.ErrNoDocuments {
return err
}
log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
continue
}
if _, err := db.Admin("instance_members").InsertOne(ctx, InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: owner.HQUserID,
ControlUserID: owner.UserID,
Role: sharedmodels.RoleOwner,
Email: owner.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
return err
}
created++
}
if created > 0 {
log.Printf("backfill: recorded %d pre-existing instance owners", created)
}
// Pass 3 (the plan re-key) now runs in MigrateLegacyPlans, called from main
// BEFORE SeedPlans. It has to: SeedPlans inserts a fresh
// (self_hosted, professional) row, and if the legacy self_hosted TIER row is
// only renamed afterwards it collides with that seed on deployment_tier_unique.
// Pass 4: instances carrying the self_hosted TIER move to Professional.
// Their deployment already says self_hosted, so only the tier is wrong.
res, err = db.Admin("admin_instances").UpdateMany(ctx,
bson.M{"tier": license.TierSelfHosted},
bson.M{"$set": bson.M{"tier": license.TierProfessional}})
if err != nil {
return err
}
if res.ModifiedCount > 0 {
log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount)
}
// Pass 5: give every instance an entitlement, reconstructed from its current
// licence. Filtering on the absence of a row is what makes this idempotent,
// and it means an entitlement a customer has since edited is never
// overwritten by a stale licence.
if err := backfillEntitlements(ctx); err != nil {
return err
}
// Pass 6: instances whose identity was rewritten before previous_instance_ids
// existed carry no trail, and Paddle's custom_data still names the id they
// were rewritten FROM — so their next webhook resolves to nothing. Both
// rewrites wrote an audit entry naming the old id, which is the only surviving
// record of it, so reconstruct the trail from those.
if err := backfillInstanceIDHistory(ctx); err != nil {
return err
}
return nil
}
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
// the two identity rewrites leave behind: a placeholder claim
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
// ("instance.relinked", detail "was <id>").
//
// $addToSet is what makes it idempotent, and it also means a chain of relinks
// accumulates rather than the last one winning. Entries are walked NEWEST first,
// matching on the current id or an already-recovered one: an instance relinked
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
// record B before the B entry has anything to attach A to.
func backfillInstanceIDHistory(ctx context.Context) error {
prefixes := map[string]string{
"instance.placeholder_linked": "from placeholder ",
"instance.relinked": "was ",
}
actions := make(bson.A, 0, len(prefixes))
for action := range prefixes {
actions = append(actions, action)
}
cur, err := db.Admin("admin_audit").Find(ctx,
bson.M{"action": bson.M{"$in": actions}},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return err
}
var entries []AuditEntry
if err := cur.All(ctx, &entries); err != nil {
return err
}
recorded := 0
for _, e := range entries {
prefix := prefixes[e.Action]
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
continue
}
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
if oldID == "" || oldID == e.Target {
continue
}
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"$or": bson.A{
bson.M{"instance_id": e.Target},
bson.M{"previous_instance_ids": e.Target},
}},
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
if err != nil {
return err
}
recorded += int(res.ModifiedCount)
}
if recorded > 0 {
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
}
return nil
}
// backfillEntitlements reconstructs an entitlement per instance from its licence.
//
// An Unlimited max_servers maps back to the plan's BASE allowance rather than to
// a huge number: an unlimited licence bought no server units, so the honest
// reconstruction of "how many did they pay for" is none. This makes a
// pre-metering Professional instance read as 3 servers, which is a REDUCTION in
// what it is allowed. That is deliberate and it is why this is a plan step and
// not a silent fix — see the task's confirmation step.
func backfillEntitlements(ctx context.Context) error {
cur, err := db.Admin("admin_instances").Find(ctx,
bson.M{"status": bson.M{"$ne": StatusDeleted}})
if err != nil {
return err
}
var instances []Instance
if err := cur.All(ctx, &instances); err != nil {
return err
}
created := 0
for _, inst := range instances {
n, err := db.Admin("entitlements").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
return err
}
if n > 0 {
continue
}
deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier)
if tier == "" {
// An instance awaiting its first licence has no tier. It gets an
// entitlement when one is issued, not before.
continue
}
plan, err := GetPlan(ctx, deployment, tier)
if err != nil {
log.Printf("backfill: instance %s names unknown plan %s/%s; skipped",
inst.InstanceID, deployment, tier)
continue
}
cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}}
if inst.CurrentLicense != "" {
var lic License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil {
if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 {
cfg.Servers = lic.Limits.MaxServers
}
cfg.Features = lic.Features.OrEmpty()
}
}
limits := plan.BaseLimits
limits.MaxServers = cfg.Servers
if err := UpsertEntitlement(ctx, Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: deployment,
Tier: tier,
Term: defaultTerm(deployment),
Desired: cfg,
Granted: cfg,
ResolvedLimits: limits,
}); err != nil {
return err
}
created++
}
if created > 0 {
log.Printf("backfill: created %d entitlements from current licences", created)
}
return nil
}
// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted
// sells annual only, so there is nothing to guess there.
func defaultTerm(deployment string) string {
if deployment == license.DeploymentSelfHosted {
return "annual"
}
return "monthly"
}
-142
View File
@@ -1,142 +0,0 @@
package models
import (
"context"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// Component kinds.
const (
// KindBase is the plan's own fee, always quantity 1.
KindBase = "base"
// KindLimit raises a named limit by one per unit of quantity.
KindLimit = "limit"
// KindFeature is an on/off feature key.
KindFeature = "feature"
)
// LimitKeyServers is the only metered limit today.
//
// A limit_key is a field name in license.Limits, which is what lets a second
// metered dimension be a catalogue row rather than a code change. There is
// deliberately no block size: with secret-group blocks dropped from the spec it
// would be 1 in every row that will ever exist.
const LimitKeyServers = "max_servers"
// CatalogueRow is one priceable component of one plan.
//
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
// PriceIDs means the component is free — a feature with no price is a toggle a
// customer may take at no charge, and giving it a price later is a staff edit
// rather than a migration or a deploy.
type CatalogueRow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Kind string `bson:"kind" json:"kind"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
// PriceIDs is environment -> term -> Paddle price ID, e.g.
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
//
// Nested by environment rather than kept in two collections, because
// promoting sandbox to production must be a configuration change and not a
// data migration. The running PADDLE_ENV picks the inner map.
PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"`
}
// PriceID returns the price for one environment and term, or "".
func (r CatalogueRow) PriceID(env, term string) string {
if r.PriceIDs == nil {
return ""
}
return r.PriceIDs[env][term]
}
// Priced reports whether this component costs anything in an environment.
func (r CatalogueRow) Priced(env string) bool {
for _, term := range []string{"monthly", "annual"} {
if r.PriceID(env, term) != "" {
return true
}
}
return false
}
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
// server limit, and one row per feature key.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
// "fix" this by adding zero-priced Free rows.
//
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
// in by staff and a redeploy must not blank them.
func SeedCatalogue(ctx context.Context) error {
paid := []string{license.TierProfessional, license.TierEnterprise}
for _, deployment := range license.Deployments() {
for _, tier := range paid {
rows := []CatalogueRow{
{Kind: KindBase, Deployment: deployment, Tier: tier},
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
}
for _, r := range rows {
filter := bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
}
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
bson.M{"$setOnInsert": bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
"price_ids": map[string]map[string]string{},
}},
options.UpdateOne().SetUpsert(true)); err != nil {
return err
}
}
}
}
return nil
}
// CatalogueFor returns every component of one plan.
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
cur, err := db.Admin("catalogue").Find(ctx,
bson.M{"deployment": deployment, "tier": tier})
if err != nil {
return nil, err
}
rows := []CatalogueRow{}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
return rows, nil
}
// AllCatalogue returns every row, for the staff editor.
func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) {
cur, err := db.Admin("catalogue").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
rows := []CatalogueRow{}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
return rows, nil
}
-139
View File
@@ -1,139 +0,0 @@
package models
import (
"context"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrNoEntitlement means the instance has no configuration row.
//
// Callers fall back to the plan's base rather than failing: staff manual
// issuance and any instance predating the backfill legitimately have none.
var ErrNoEntitlement = errors.New("instance has no entitlement")
// Config is one side of an entitlement — a complete statement of what an
// instance is allowed.
//
// Servers is the TOTAL the customer sees, not the number of units billed. The
// billed quantity is Servers minus the plan's base allowance, and it is computed
// where the line items are built rather than stored, so the two can never
// disagree about which of them included the base.
type Config struct {
Servers int `bson:"servers" json:"servers"`
Features Features `bson:"features" json:"features"`
}
// Entitlement is what one instance's customer configured.
//
// Both the subscription and the licence are derived from it; it is derived from
// nothing. Desired is what they last asked for; Granted is what a payment
// confirmed. A licence is only ever signed from Granted, so an abandoned
// checkout leaves a Desired that reached nothing.
type Entitlement struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Desired Config `bson:"desired" json:"desired"`
Granted Config `bson:"granted" json:"granted"`
// ResolvedLimits is BaseLimits with Granted folded in. It is stored rather
// than derived on read so the fold lives in exactly one place — deriving it
// at every read would put the arithmetic in the issuer, the portal and the
// staff console.
ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"`
// ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only
// when Desired grants less than Granted, and it is what lets the portal say
// "drops to 5 on 12 August" instead of guessing.
ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"`
GrantedAt time.Time `bson:"granted_at" json:"granted_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// Pending reports whether Desired and Granted disagree.
func (e Entitlement) Pending() bool {
if e.Desired.Servers != e.Granted.Servers {
return true
}
if len(e.Desired.Features) != len(e.Granted.Features) {
return true
}
have := map[string]bool{}
for _, f := range e.Granted.Features {
have[f] = true
}
for _, f := range e.Desired.Features {
if !have[f] {
return true
}
}
return false
}
// GetEntitlement reads one instance's configuration.
func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) {
var e Entitlement
err := db.Admin("entitlements").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&e)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNoEntitlement
}
if err != nil {
return nil, err
}
return &e, nil
}
// UpsertEntitlement writes an entitlement, creating it if absent.
//
// GrantedAt is only touched when Granted actually changes, which is what makes
// it answer "since when has this instance been allowed this" rather than "when
// was this row last written".
func UpsertEntitlement(ctx context.Context, e Entitlement) error {
now := time.Now().UTC()
set := bson.M{
"account_id": e.AccountID,
"deployment": e.Deployment,
"tier": e.Tier,
"term": e.Term,
"desired": e.Desired,
"granted": e.Granted,
"resolved_limits": e.ResolvedLimits,
"updated_at": now,
}
if e.ScheduledChangeAt != nil {
set["scheduled_change_at"] = *e.ScheduledChangeAt
}
if !e.GrantedAt.IsZero() {
set["granted_at"] = e.GrantedAt
} else {
set["granted_at"] = now
}
update := bson.M{"$set": set}
if e.ScheduledChangeAt == nil {
update["$unset"] = bson.M{"scheduled_change_at": ""}
}
_, err := db.Admin("entitlements").UpdateOne(ctx,
bson.M{"instance_id": e.InstanceID},
mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}),
options.UpdateOne().SetUpsert(true))
return err
}
// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one.
func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M {
update["$setOnInsert"] = onInsert
return update
}
-60
View File
@@ -1,60 +0,0 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Account roles.
//
// Deliberately the same three words as the control plane's own roles rather
// than a second vocabulary: a customer who reads "admin" in the portal and
// "admin" in their instance should not have to learn that they mean different
// things. They govern different scopes — this one governs the HQ account —
// but they mean the same thing about power.
//
// Billing stays owner-only. Owners and admins may invite people, create
// instances and grant instance access.
const (
AccountRoleOwner = "owner"
AccountRoleAdmin = "admin"
AccountRoleMember = "member"
)
func ValidAccountRole(r string) bool {
switch r {
case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember:
return true
}
return false
}
// AccountRoleAtLeastAdmin is the single definition of "may manage people and
// instances". Every guard calls this rather than comparing strings, so widening
// the rule is one edit.
func AccountRoleAtLeastAdmin(r string) bool {
return r == AccountRoleOwner || r == AccountRoleAdmin
}
// InstanceMember records that one HQ person holds a projected user inside one
// cloud instance.
//
// It is admin's index of the projection, not the authority: the control-plane
// `users` row IS the access. This row exists so the portal can list who is on
// an instance without reading the control plane, and so a password change can
// find every row to update without scanning every instance.
//
// ControlUserID is the projected users.user_id. Role is the role that user
// holds INSIDE the instance, which is not the person's account role.
type InstanceMember struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
MemberID string `bson:"member_id" json:"member_id"`
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
CustomerUserID string `bson:"customer_user_id" json:"customer_user_id"`
ControlUserID string `bson:"control_user_id" json:"control_user_id"`
Role string `bson:"role" json:"role"`
Email string `bson:"email" json:"email"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
-299
View File
@@ -1,299 +0,0 @@
// Package models holds admin's own documents.
//
// These are admin-owned and never shared with the control plane. The two
// structs that ARE shared — Instance and User on the control-plane side — come
// from shared/models, so there is no second copy of those shapes to drift.
package models
import (
"encoding/json"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Features is a list of feature flags that marshals as `[]` rather than `null`.
//
// A nil Go slice becomes JSON null. The Free plan has no features, so every
// Free licence served `"features": null`, and the portal — whose type said
// string[] — called .length on it and took the page down with it.
//
// The guarantee lives on the type rather than at each of the six places a
// licence or plan is serialised, because the seventh is the one that would have
// been forgotten. It also fixes rows already holding null in Mongo, since it
// applies at marshal time rather than at write time.
type Features []string
func (f Features) MarshalJSON() ([]byte, error) {
if f == nil {
return []byte("[]"), nil
}
return json.Marshal([]string(f))
}
// OrEmpty is the same guarantee for values headed to Mongo rather than to JSON,
// so a null never enters the database in the first place.
func (f Features) OrEmpty() Features {
if f == nil {
return Features{}
}
return f
}
// Instance statuses.
const (
StatusAwaitingLink = "awaiting_link"
StatusActive = "active"
StatusLapsed = "lapsed"
StatusCancelled = "cancelled"
// StatusDeleted marks an instance the control plane has reaped. The row is
// kept because the licence history references it and support questions
// outlive the instance.
StatusDeleted = "deleted"
)
// Account statuses.
const (
AccountActive = "active"
AccountSuspended = "suspended"
)
// Licence issuance reasons. These end up in support conversations, so they are
// stable identifiers rather than prose.
const (
ReasonNew = "new"
ReasonRenewal = "renewal"
ReasonTierChange = "tier_change"
ReasonRelink = "relink"
ReasonManual = "manual"
// ReasonEntitlementChange is a mid-term change to what an instance is
// allowed — servers added, a feature toggled — at the same expiry.
//
// It is deliberately NOT ReasonRenewal: a renewal resets relink_count
// because a new term has begun, and adding a server does not begin one.
ReasonEntitlementChange = "entitlement_change"
)
// Subscription statuses, mirrored from Paddle. Ours, not a vendor SDK's, so the
// billing package does not import anything Paddle.
const (
SubActive = "active"
SubCanceled = "canceled"
SubPastDue = "past_due"
SubTrialing = "trialing"
)
// Billing terms. These match catalogue price-ID keys and license.TermsFor.
const (
TermMonthly = "monthly"
TermAnnual = "annual"
)
// MaxRelinksPerTerm is the customer-facing relink cap.
//
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
// revoked, so a determined customer is not stopped by a counter. Its real job is
// to put a human in front of the fourth attempt.
const MaxRelinksPerTerm = 3
// GracePeriod is added to every licence expiry beyond the billing period end,
// so a renewal webhook arriving slightly late does not create a gap in which a
// paying customer's instance goes read-only.
const GracePeriod = 3 * 24 * time.Hour
// RenewWindow is how long before expiry a Free instance may be renewed.
//
// Renewal stays available after expiry too, right up until the reaper takes the
// instance, so the same button rescues a lapsed instance instead of needing a
// second mechanism.
const RenewWindow = 7 * 24 * time.Hour
// RenameCooldown is how long a customer must wait between renames of one
// instance.
//
// A rename moves the instance's DNS host and invalidates every saved link to it,
// so this exists to make that a considered act rather than a slider. Staff are
// not subject to it: a support conversation about a name is already a human
// deciding.
const RenameCooldown = 24 * time.Hour
type Account struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
AccountID string `bson:"account_id" json:"account_id"`
Name string `bson:"name" json:"name"`
BillingEmail string `bson:"billing_email" json:"billing_email"`
PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"`
Status string `bson:"status" json:"status"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// Instance is admin's record of one deployment.
//
// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it
// is the UUID the customer pasted — their database is theirs, and we cannot see
// it, so this row is the only thing that exists on our side.
type Instance struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier,omitempty" json:"tier,omitempty"`
Status string `bson:"status" json:"status"`
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
RelinkCount int `bson:"relink_count" json:"relink_count"`
// RenamedAt is when this instance last changed name, and backs the customer
// rename cooldown. It is a pointer because absent means "never renamed"; a
// zero time.Time would read as year 1 — an inert cooldown, but only by
// accident. Staff renames deliberately leave it alone.
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
// NoticesSent holds the lifecycle notice keys already emailed for the
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
// clears it, so the next term starts the sequence again. It is what stops a
// restart re-sending a notice.
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
// Placeholder is true while a paid CLOUD instance row exists only so a
// checkout has something to attach custom_data to, before the confirmed
// payment provisions it. Cleared once provisioned. Self-hosted has no
// placeholder: its checkout names the install's real UUID.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
// PreviousInstanceIDs is every id this row has carried before its current one.
// A self-hosted row's identity is rewritten on each relink to a rebuilt
// server, and Paddle keeps its own copy of custom_data written at checkout.
// That copy
// is patched on each rewrite, but the patch is best-effort and any event
// already in flight still names an old id, so this is what lets a webhook
// resolve to the right instance instead of erroring as unknown.
PreviousInstanceIDs []string `bson:"previous_instance_ids,omitempty" json:"-"`
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
// remembered so the confirmed-payment webhook can provision the instance with
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
PendingOwnerUserID string `bson:"pending_owner_user_id,omitempty" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// License is append-only. A renewal writes a new row and sets SupersededBy on
// the old one. Nothing here is ever edited or deleted: when a support question
// arrives about why an instance stopped working on a given date, the answer has
// to still be in the table.
type License struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
LicenseID string `bson:"license_id" json:"license_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Tier string `bson:"tier" json:"tier"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features Features `bson:"features" json:"features"`
IssuedAt time.Time `bson:"issued_at" json:"issued_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
Blob string `bson:"blob" json:"-"`
SupersededBy string `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"`
IssuedBy string `bson:"issued_by" json:"issued_by"`
Reason string `bson:"reason" json:"reason"`
}
type Subscription struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
SubscriptionID string `bson:"subscription_id" json:"subscription_id"`
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Status string `bson:"status" json:"status"`
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
// Items is the full line-item list. Spec 7 made a subscription several
// prices — a base, a per-server unit at quantity N, an item per paid
// feature — so a single price ID can no longer describe it.
Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
}
// SubItem is one line of a subscription: a price and its quantity, the shape
// catalogue.ResolveItems reads back into a plan and configuration.
type SubItem struct {
PriceID string `bson:"price_id" json:"price_id"`
Quantity int `bson:"quantity" json:"quantity"`
}
// PaddleEvent is the idempotency record for one webhook delivery. The unique
// index on EventID is what makes a retry a no-op rather than a second licence.
type PaddleEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
EventID string `bson:"event_id" json:"event_id"`
EventType string `bson:"event_type" json:"event_type"`
ReceivedAt time.Time `bson:"received_at" json:"received_at"`
ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
}
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
// from shared/license.
//
// It lives in the database so tier contents change without a deploy. Every
// issued licence snapshots it, so editing a plan never rewrites an existing
// licence — the same rule as workflow_runs.steps_snapshot.
//
// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a
// metered plan is priced by several components and a single map on this row
// cannot express that.
type Plan struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
Name string `bson:"name" json:"name"`
// BaseLimits is the allowance before anything is bought. The field is named
// `base_` rather than `limits` because that is a different claim from the one
// the old field made, and a reader must not assume it is the total.
BaseLimits license.Limits `bson:"base_limits" json:"base_limits"`
BaseFeatures Features `bson:"base_features" json:"base_features"`
SupportLevel string `bson:"support_level" json:"support_level"`
Active bool `bson:"active" json:"active"`
}
type StaffUser struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
Name string `bson:"name" json:"name"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// CustomerUser is one person on an HQ account.
//
// AccountRole governs what they may do to the ACCOUNT — invite people, create
// instances, grant access. It says nothing about what they may do inside any
// instance; that is the role on their InstanceMember row.
type CustomerUser struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
AccountID string `bson:"account_id" json:"account_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
AccountRole string `bson:"account_role" json:"account_role"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"`
VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"`
// HQSyncFailedAt is set when a password change could not be written to
// every projected control-plane row. It is visibility only — hqsync repairs
// by comparing hashes, not by reading this field.
HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type AuditEntry struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Actor string `bson:"actor" json:"actor"`
Action string `bson:"action" json:"action"`
AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"`
Target string `bson:"target,omitempty" json:"target,omitempty"`
Detail string `bson:"detail,omitempty" json:"detail,omitempty"`
IP string `bson:"ip,omitempty" json:"ip,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
-48
View File
@@ -1,48 +0,0 @@
package models
import (
"context"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ClaimEvent records an event ID before it is processed and reports whether THIS
// call is the one that claimed it.
//
// The unique index on event_id turns a duplicate insert into a duplicate-key
// error, which is the signal that another delivery of the same event already
// owns it — so this returns (false, nil) and the caller answers 200 without
// acting. A genuine error returns (false, err).
func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
_, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
EventID: eventID,
EventType: eventType,
ReceivedAt: time.Now().UTC(),
})
if err == nil {
return true, nil
}
if mongo.IsDuplicateKeyError(err) {
return false, nil
}
return false, err
}
// MarkEventProcessed stamps success, or records the error for staff visibility.
// A failed event keeps no processed_at, so a retry re-runs it.
func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
set := bson.M{}
if procErr != nil {
set["error"] = procErr.Error()
} else {
now := time.Now().UTC()
set["processed_at"] = now
set["error"] = ""
}
_, err := db.Admin("paddle_events").UpdateOne(ctx,
bson.M{"event_id": eventID}, bson.M{"$set": set})
return err
}
-60
View File
@@ -1,60 +0,0 @@
package models
import (
"context"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// SeedPlans inserts the six (deployment, tier) rows from shared/license on first
// boot.
//
// It uses $setOnInsert only: once a plan exists, staff edits to allowances,
// features and support level are authoritative and a redeploy must not stamp
// over them.
func SeedPlans(ctx context.Context) error {
for _, deployment := range license.Deployments() {
for _, tier := range license.Tiers() {
p, ok := license.PlanFor(deployment, tier)
if !ok {
continue
}
_, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"deployment": deployment, "tier": tier},
bson.M{"$setOnInsert": bson.M{
"deployment": p.Deployment,
"tier": p.Tier,
"name": p.Name,
"base_limits": p.Limits,
"base_features": Features(p.Features).OrEmpty(),
"support_level": p.SupportLevel,
"active": true,
}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return err
}
}
}
return nil
}
// GetPlan reads one pair's authoritative definition.
//
// It normalises the tier first, so a legacy self_hosted licence being reissued
// resolves to the plan that replaced it.
func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
var p Plan
if err := db.Admin("plans").FindOne(ctx,
bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil {
return nil, err
}
return &p, nil
}
func now() time.Time { return time.Now().UTC() }
-58
View File
@@ -1,58 +0,0 @@
// Package paddle is the only place that talks to Paddle. Everything outside it
// depends on the Client interface and our own types, never on Paddle's wire
// shapes — so a change at Paddle is confined to http.go, and the billing package
// can be reasoned about without knowing Paddle exists.
//
// It is a thin REST client rather than the vendor SDK on purpose: the surface we
// need is two calls, and a hand-rolled client has no version-drift risk and no
// dependency to keep in go.sum.
package paddle
import "context"
// LineItem is one price at a quantity, the shape both a checkout and a
// subscription update are built from.
type LineItem struct {
PriceID string
Quantity int
}
// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
// the browser via paddle-js; the server only updates an existing subscription
// and mints a portal session.
type Client interface {
// UpdateSubscriptionItems replaces a subscription's items, prorated
// immediately by Paddle. This is the one outbound mutation, used when a
// customer changes their server count or features on an existing plan.
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
// UpdateSubscriptionCustomData replaces a subscription's custom_data. Used
// when a self-hosted instance is relinked to a rebuilt server: the checkout
// attached the old id, and every later webhook must name the new one.
UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error
// PortalSession returns a customer-portal URL for managing billing.
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
// Env is "sandbox" or "production", the same value catalogue price lookups
// are keyed on.
Env() string
}
var current Client
// Init constructs the client from config and stores it. Called once at boot.
func Init(apiKey, env string) (Client, error) {
c, err := newHTTPClient(apiKey, env)
if err != nil {
return nil, err
}
current = c
return c, nil
}
// Get returns the client initialised at boot. Panics if unset, which can only
// happen if a caller runs before Init — a programming error, not a runtime one.
func Get() Client {
if current == nil {
panic("paddle.Get before paddle.Init")
}
return current
}
-131
View File
@@ -1,131 +0,0 @@
package paddle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// httpClient is the only implementation of Client. It is the single place that
// knows Paddle's base URLs, auth header and request shapes — swap the whole
// vendor here without the rest of the tree noticing.
type httpClient struct {
apiKey string
env string
base string
http *http.Client
}
func newHTTPClient(apiKey, env string) (Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("paddle: empty API key")
}
base := "https://sandbox-api.paddle.com"
if env == "production" {
base = "https://api.paddle.com"
}
return &httpClient{
apiKey: apiKey,
env: env,
base: base,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (c *httpClient) Env() string { return c.env }
// do sends a JSON request and decodes the `data` envelope Paddle wraps every
// response in. A non-2xx is returned as an error carrying the body, so a
// configuration or auth failure is loud rather than silent.
func (c *httpClient) do(ctx context.Context, method, path string, body any, out any) error {
var buf io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("paddle: marshal %s %s: %w", method, path, err)
}
buf = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, buf)
if err != nil {
return fmt.Errorf("paddle: build %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("paddle: %s %s: %w", method, path, err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("paddle: %s %s returned %d: %s", method, path, res.StatusCode, string(raw))
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("paddle: decode %s %s: %w", method, path, err)
}
return nil
}
type updateSubscriptionRequest struct {
Items []reqItem `json:"items"`
ProrationBillingMode string `json:"proration_billing_mode"`
}
type reqItem struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
reqItems := make([]reqItem, 0, len(items))
for _, it := range items {
reqItems = append(reqItems, reqItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, updateSubscriptionRequest{
Items: reqItems,
ProrationBillingMode: "prorated_immediately",
}, nil)
}
// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the
// whole object, so callers pass every key they want to keep.
func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct {
CustomData map[string]string `json:"custom_data"`
}{CustomData: data}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
}
var out struct {
Data struct {
URLs struct {
General struct {
Overview string `json:"overview"`
} `json:"general"`
} `json:"urls"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodPost,
"/customers/"+customerID+"/portal-sessions", struct{}{}, &out); err != nil {
return "", err
}
return out.Data.URLs.General.Overview, nil
}
-42
View File
@@ -1,42 +0,0 @@
package paddle
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifySignature checks a raw webhook body against the Paddle-Signature header.
//
// Paddle signs an HMAC-SHA256 over "ts:body", carried as "ts=<unix>;h1=<hex>".
// It uses a constant-time compare and never logs the secret. A false return is
// always a 401 with nothing processed — an unverified body could be anyone
// claiming a subscription was paid for.
func VerifySignature(secret, header string, body []byte) bool {
if secret == "" || header == "" {
return false
}
var ts, h1 string
for _, part := range strings.Split(header, ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch k {
case "ts":
ts = v
case "h1":
h1 = v
}
}
if ts == "" || h1 == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte(":"))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(h1))
}
-4
View File
@@ -1,4 +0,0 @@
node_modules
.next
.env
*.lic
-5
View File
@@ -1,5 +0,0 @@
node_modules
.next
next-env.d.ts
.env
*.lic
-55
View File
@@ -1,55 +0,0 @@
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
# Browser checkout. The client token and environment are baked in, never
# fetched, so a production build cannot load a sandbox token by accident.
ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=
ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
# Marketing site origin. Signup lives there (/start), not here; empty renders no
# link at all rather than one that 404s.
ARG NEXT_PUBLIC_SITE_URL=
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
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"]
-127
View File
@@ -1,127 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { TermSpark } from "@/components/TermBar";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { formatDate, licenceState } from "@/lib/format";
export default function BillingPage() {
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
if (subs.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (subs.isLoading) return <p className="text-ink-3">Loading</p>;
const rows = subs.data ?? [];
// Each subscription names the instance it pays for, because tier and term
// are per-licence rather than per-account. Resolving the name here is the
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
/*
* A subscription reports when the period ends but not when it began, so the
* start is derived from the term. Only the two terms we actually sell are
* handled — anything else returns null and the row falls back to the date
* alone, because a bar drawn from a guessed span is worse than no bar.
*/
const periodStart = (end: string, term: string): string | null => {
const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
if (!months) return null;
const d = new Date(end);
if (Number.isNaN(d.getTime())) return null;
d.setMonth(d.getMonth() - months);
return d.toISOString();
};
return (
<div className="grid gap-6">
<PageHeader
title="Billing"
subtitle="One subscription per instance each carries its own tier and term."
record={account.data ? [{ key: "Billing", value: account.data.account.billing_email }] : undefined}
/>
<PageFrame
aside={
<>
<RailCard title="Account">
<RailFacts
rows={[
{
label: "Billing contact",
value: account.data?.account.billing_email ?? "—",
},
{ label: "Status", value: account.data?.account.status ?? "—" },
{ label: "Subscriptions", value: rows.length },
]}
/>
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">Change a card, download an invoice or cancel from the billing portal. It covers every subscription on this account.</p>
<ManageBillingButton />
<p className="text-[0.82rem] text-ink-2">Anything else, email support.</p>
<a href="mailto:support@hostxtra.co.uk" className="text-[0.82rem] font-semibold text-accent underline">
support@hostxtra.co.uk
</a>
</RailCard>
</>
}
>
<Panel title="Subscriptions" meta={rows.length ? `${rows.length}` : undefined} bodyless>
{rows.length === 0 ? (
<EmptyState
title="No subscriptions yet."
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
/>
) : (
<Table stack>
<THead>
<TR className="hover:bg-transparent">
<TH>Instance</TH>
<TH>Plan</TH>
<TH>Billing</TH>
<TH>Status</TH>
<TH>Renews</TH>
</TR>
</THead>
<TBody>
{rows.map((s) => {
const start = periodStart(s.current_period_end, s.term);
const name = nameFor(s.instance_id);
return (
<TR key={s.subscription_id}>
<TD label="Instance">
{name ?? <span className="text-ink-3">Not linked yet</span>}
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
</TD>
<TD label="Plan">{s.tier.replace("_", " ")}</TD>
<TD label="Billing" className="text-ink-2">
{s.term}
</TD>
<TD label="Status" className="text-ink-2">
{s.status}
</TD>
<TD label="Renews">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
</div>
</TD>
</TR>
);
})}
</TBody>
</Table>
)}
</Panel>
</PageFrame>
</div>
);
}
@@ -1,278 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { RenamePanel } from "@/components/RenamePanel";
import { StatePill } from "@/components/StatePill";
import { TermBar } from "@/components/TermBar";
import { EmptyState, Note, Panel } from "@/components/Panel";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
import { useSession } from "@/lib/session";
/** One key/value row. The key is the same keyed idiom as everywhere else. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{label}</dt>
<dd className="m-0 text-[0.88rem] tabular-nums">{value}</dd>
</div>
);
}
/*
* Every feature the product sells, granted or not.
*
* Listing only what is included answers "what do I have" but not "what am I
* missing", which is the question someone on this screen is actually weighing
* before they click Change plan. The absent ones are struck through rather than
* omitted, so the comparison is on the page instead of in another tab.
*/
function Features({ granted }: { granted: string[] }) {
const all = Object.keys(FEATURE_LABEL);
// Anything the licence carries that this build does not know about is still
// shown — the map degrades to the raw key, which is ugly but never wrong.
const extras = granted.filter((f) => !all.includes(f));
return (
<div className="grid gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Features</span>
<div className="flex flex-wrap gap-1.5">
{[...all, ...extras].map((f) => {
const on = granted.includes(f);
return (
<span key={f} title={featureDesc(f) || undefined} className={on ? "rounded-sm border border-rule px-2 py-0.5 text-[0.78rem] text-ink-2" : "rounded-sm border border-rule-soft px-2 py-0.5 text-[0.78rem] text-ink-3 line-through decoration-ink-3/60"}>
{featureLabel(f)}
</span>
);
})}
</div>
</div>
);
}
export default function InstancePage() {
const id = String(useParams().id);
const router = useRouter();
const qc = useQueryClient();
const [relinkError, setRelinkError] = useState<string | undefined>();
// useSession is the app's one way to ask who the caller is — it shares the
// ["me"] query, so this adds no request.
const { session } = useSession();
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: License) => {
qc.invalidateQueries({ queryKey: ["account"] });
router.replace(`/instances/${lic.instance_id}`);
},
onError: (err) => setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
});
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const instance = account.data?.instances.find((i) => i.instance_id === id);
if (account.isLoading) return <p className="text-ink-3">Loading</p>;
if (!instance) {
// Says "not on your account" rather than "does not exist": the backend
// answers 404 for another account's instance, and confirming existence
// here would undo that.
return <p className="text-ink-2">That instance is not on your account.</p>;
}
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
const maxRelinks = account.data?.max_relinks ?? 3;
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title={instance.name || "Unnamed instance"}
subtitle={`${cloud ? "Cloud" : "Self-hosted"} instance${instance.tier ? ` on ${instance.tier.replace("_", " ")}` : ""} · created ${formatDate(instance.created_at)}`}
/*
* The two things this screen is for, in the header rather than
* hunted for further down. Download is self-hosted only: a cloud
* licence is injected into the control plane directly and there
* is nothing for the customer to do with the file.
*/
actions={
<>
{lic && !cloud && (
<LinkButton variant="line" external href={api.licenseBlobUrl(instance.instance_id)}>
Download licence
</LinkButton>
)}
{lic && <LinkButton href="/purchase">Renew licence</LinkButton>}
</>
}
record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]}
status={<StatePill state={state} />}
/>
<PageFrame
aside={
host ? (
<RailCard title="Console">
<p className="text-[0.82rem] text-ink-2">Servers, workflows and monitors live in the instance itself.</p>
<a href={`https://${host}`} className="inline-flex items-center justify-center gap-2 rounded border border-rule px-3 py-2 text-[0.84rem] font-semibold text-ink no-underline hover:border-accent hover:text-accent">
Open {instance.name || "instance"} &rarr;
</a>
<p className="font-mono text-[0.72rem] text-ink-3">{host}</p>
</RailCard>
) : undefined
}
>
{/*
* The term leads. This screen is about one licence, and the rail
* carried its issue and expiry dates as two lines of text —
* which is the arithmetic this bar does for the reader.
*/}
{lic && (
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
{state === "warn" && <Note tone="warn">Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
{state === "expired" && <Note tone="expired">A lapsed licence does not stop the control plane: agents carry on reporting and your servers keep their keys. It stops accepting changes, so nothing new can be deployed until this is renewed.</Note>}
</Panel>
)}
{/*
* What the licence grants, on the screen about that licence.
* These were four rows in a 320px rail card, which is where
* facts go when nobody has decided they matter.
*/}
{lic && (
<Panel
title="Included"
/* A panel-header action is a quiet link, not a second
full-size button competing with the header's Renew. */
actions={
<Link href="/purchase" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
Change plan &rarr;
</Link>
}
>
<div className="grid gap-x-8 gap-y-2.5 sm:grid-cols-2">
<dl className="grid content-start gap-2.5">
<Row label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Row label="Monitors" value={limitLabel(lic.limits.max_monitors)} />
<Row label="Secret groups" value={limitLabel(lic.limits.max_secret_groups)} />
</dl>
<dl className="grid content-start gap-2.5">
<Row label="Channels" value={limitLabel(lic.limits.max_channels)} />
<Row label="Audit history" value={`${limitLabel(lic.limits.audit_retention_days)} days`} />
<Row label="Issued for" value={lic.reason.replace("_", " ")} />
</dl>
</div>
<Features granted={lic.features} />
</Panel>
)}
{/*
* On a self-hosted instance the licence is the errand: someone
* opens this page to fetch the blob and paste it. It sits
* directly under the term, above the panels that only explain
* things.
*/}
{lic && !cloud && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
{cloud && <MembersPanel instanceId={instance.instance_id} />}
{/*
* Address rather than "Rename": the panel is about where this
* instance lives, and the rename is how you change it. Cloud
* only — a self-hosted install has no tenant subdomain for us to
* move.
*/}
{cloud && mayRename && (
<Panel title="Address" meta={host ?? undefined}>
<p className="text-[0.86rem] text-ink-2">
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
one, so saved links and bookmarks to it stop working.
</p>
{/*
* Keyed on the instance: this element stays mounted
* across a navigation between two instance pages, so
* without a key the success note and the typed name
* from one instance surface on the next.
*/}
<RenamePanel
key={instance.instance_id}
movesHost
currentName={instance.name}
currentSlug={instance.slug ?? ""}
onRename={async (name) => {
const res = await api.renameInstance(instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["account"] });
return res;
}}
/>
</Panel>
)}
{/*
* "Moves" rather than "Relinks": the count is rationed, so the
* headline is how many are left, and the panel explains what
* spends one. Cloud instances cannot move — we own the host —
* so the panel is absent rather than present and refusing.
*/}
{!cloud && (
<Panel title="Moves" meta={`${Math.max(0, maxRelinks - instance.relink_count)} of ${maxRelinks} left`}>
<p className="text-[0.86rem] text-ink-2">
A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID
that is a move, and it covers the rest of your current term.
</p>
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
</Panel>
)}
{/*
* A panel holding one sentence has not decided what it is for.
* For a self-hosted install the useful content is not "we don't
* do this" but where the thing they came looking for actually
* lives — and why the people on their HQ account are not it.
*/}
{!cloud && (
<Panel title="Who can sign in" meta="Managed in your install">
<p className="text-[0.86rem] text-ink-2">
You run this deployment, so its users live inside it rather than here. Add and remove them in the instance&rsquo;s own settings.
</p>
<p className="text-[0.82rem] text-ink-3">
People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting
one never grants the other.
</p>
</Panel>
)}
{!lic && (
<Panel bodyless>
<EmptyState title="No licence issued yet." body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
</Panel>
)}
</PageFrame>
</div>
);
}
-35
View File
@@ -1,35 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
/*
* Three destinations, not five. Settings moved into the account menu it is
* your password, not a place and Instances went with it, because Overview
* already lists them and a second door to the same room is just a second thing
* to keep in sync. Linking an install is an action, so it is a button on
* Overview rather than a permanent nav entry.
*/
const LINKS: NavLink[] = [
{ href: "/", label: "Overview" },
{ href: "/users", label: "People" },
{ href: "/billing", label: "Billing" },
];
function AccountName() {
// Shares the ["account"] key with Overview, so this costs no extra request.
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
if (!data) return null;
return <span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">{data.account.name}</span>;
}
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="customer">
<AppBar links={LINKS} context={<AccountName />} />
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
-241
View File
@@ -1,241 +0,0 @@
"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 { InstanceRecord } from "@/components/InstanceRecord";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { Panel } from "@/components/Panel";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { StatePill } from "@/components/StatePill";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
export default function OverviewPage() {
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const licences = useQueries({
queries: (data?.instances ?? [])
.filter((i) => i.current_license)
.map((i) => ({
queryKey: ["license", i.instance_id],
queryFn: () => api.license(i.instance_id),
})),
});
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (isLoading || !data) return <p className="text-ink-3">Loading your account</p>;
const byInstance = new Map<string, License>();
licences.forEach((q) => {
if (q.data) byInstance.set(q.data.instance_id, q.data);
});
const live = data.instances.filter((i) => i.status !== "deleted");
/*
* Work the customer has to do, gathered across every instance. This is the
* only account-level view of it — each record only knows about itself.
*
* Each item carries the way out of it. It used to be a list of sentences in
* the rail, which told someone their licence was expiring and then made
* them go and find the instance that owned it; the fix for every one of
* these is one click, so the click belongs on the row.
*/
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
const name = i.name || "An instance";
if (state === "none")
return [
{
id: i.instance_id,
text: `${name} has no licence yet`,
note: "Pick a plan and we will issue a licence for this install.",
href: "/purchase",
action: "Get a licence",
tag: "",
},
];
if (state === "expired")
return [
{
id: i.instance_id,
text: `${name} has expired`,
note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: "now",
},
];
if (state === "warn") {
const d = daysRemaining(lic!.expires_at);
return [
{
id: i.instance_id,
text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: `${d}d`,
},
];
}
return [];
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const subtitle =
live.length === 0 ? "Nothing here yet." : `${live.length} ${live.length === 1 ? "instance" : "instances"}${attention.length ? ` · ${attention.length} needing attention` : " · all licensed"}`;
return (
<div className="grid gap-6">
<PageHeader
title="Overview"
subtitle={subtitle}
actions={live.length > 0 ? <LinkButton href="/purchase">Buy a plan</LinkButton> : undefined}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Billing", value: data.account.billing_email },
]}
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
/>
{live.length === 0 ? (
/*
* An empty screen is an invitation to act, and the two ways in
* are genuinely different products — we host it, or you do. One
* button and a paragraph explaining the other option made the
* self-hosted path read as an afterthought, which it is not.
*/
<div className="grid gap-4 rounded border border-rule bg-panel p-6">
<div className="grid gap-2">
<h2 className="text-xl">No instances yet</h2>
<p className="max-w-[52ch] text-ink-2">An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Cloud</h3>
<p className="text-[0.82rem] text-ink-2">We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.</p>
<div className="pt-1">
<LinkButton href="/purchase">Create a cloud instance</LinkButton>
</div>
</div>
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Self-hosted</h3>
<p className="text-[0.82rem] text-ink-2">You host it. Get the licence here, then paste your install&rsquo;s ID to bind it.</p>
<div className="pt-1">
<LinkButton variant="line" href="/purchase">
License my own install
</LinkButton>
</div>
</div>
</div>
<p className="text-[0.78rem] text-ink-3">The Free tier covers 5 servers and needs no card.</p>
</div>
) : (
<PageFrame
aside={
<>
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
<li key={p.user_id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span className="truncate">{p.email}</span>
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">{p.account_role}</span>
</li>
))}
</ul>
{pending > 0 && (
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
</p>
)}
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
Manage people
</Link>
</RailCard>
{/*
* Account-level facts only. Tier, limits and renewal date
* belong to a licence, and a licence belongs to one
* instance an account holding a Free cloud instance and
* a Professional self-hosted one has no single plan.
*/}
<RailCard title="Account">
<RailFacts
rows={[
{ label: "Billing contact", value: data.account.billing_email },
{ label: "Status", value: data.account.status },
{
label: "Customer since",
value: formatDate(data.account.created_at),
},
]}
/>
<Link href="/billing" className="text-[0.82rem] font-semibold text-accent underline">
Billing history
</Link>
</RailCard>
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">Get a licence for your own install free or paid from the purchase page. It keeps its own users.</p>
<Link href="/purchase" className="text-[0.82rem] font-semibold text-accent underline">
Get a licence
</Link>
</RailCard>
</>
}
>
{/*
* First in the main column, not in the rail. This is the
* reason the page is open; the rail is for things that are
* merely true. It disappears entirely when there is nothing
* in it rather than saying "all clear", which is a line
* nobody needs to read twice a week.
*/}
{attention.length > 0 && (
<Panel title="Needs you" meta={`${attention.length} ${attention.length === 1 ? "item" : "items"}`} bodyless>
<ul className="grid">
{attention.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
<div className="grid min-w-0 gap-0.5">
<span className="flex items-center gap-2 text-[0.9rem] font-semibold">
{a.text}
{a.tag && <span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-warn">{a.tag}</span>}
</span>
<span className="text-[0.8rem] text-ink-3">{a.note}</span>
</div>
<LinkButton href={a.href}>{a.action}</LinkButton>
</li>
))}
</ul>
</Panel>
)}
{live.map((i, n) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
return (
<InstanceRecord
key={i.instance_id}
instance={i}
license={lic}
// Open when it is the only one, or when it is the
// first thing that needs a decision. A saved toggle
// beats this from then on.
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
/>
);
})}
</PageFrame>
)}
</div>
);
}
@@ -1,769 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
import { featureDesc, featureLabel } from "@/lib/features";
/* Tiers in the order a customer reads them, cheapest first. */
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Feature wording lives in lib/features.ts, shared with the staff
* configurator. It was duplicated here and there, and the two copies had
* already drifted. */
interface Choice {
tier: Tier;
term: Term;
servers: number;
features: string[];
}
/* What a plan offers a given feature: included in the base, a paid add-on, or
* absent. Drives both the tier cards and the configurator toggles. */
type FeatureState = "included" | "addon" | "absent";
function featureStateFor(plan: Plan | undefined, rows: CatalogueRow[], env: string, term: Term, key: string): FeatureState {
if (plan?.base_features.includes(key)) return "included";
const row = rows.find((r) => r.kind === "feature" && r.feature_key === key);
const priced = Boolean(row?.price_ids?.[env]?.[term]);
return priced ? "addon" : "absent";
}
export function PurchaseForm() {
const router = useRouter();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const optionsQ = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
const [dep, setDep] = useState<Deployment>("cloud");
const [choice, setChoice] = useState<Choice>({
tier: "professional",
term: "annual",
servers: 3,
features: [],
});
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
// Follow-up phase after a checkout has been started.
const [pending, setPending] = useState<null | {
instanceId: string;
deployment: Deployment;
}>(null);
const [uuid, setUuid] = useState("");
const options = optionsQ.data;
const accountId = account.data?.account.account_id ?? "";
// Distinct feature keys offered on this deployment, in a stable order.
const featureKeys = useMemo(() => {
if (!options) return [] as string[];
const keys = new Set<string>();
for (const r of options.catalogue) {
if (r.deployment === dep && r.kind === "feature" && r.feature_key) {
keys.add(r.feature_key);
}
}
return [...keys];
}, [options, dep]);
const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]);
const plan = activePlans.find((p) => p.tier === choice.tier);
const baseServers = plan?.base_limits.max_servers ?? 0;
const unlimited = baseServers === -1;
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
// Real line items for the current configuration the same builder the
// checkout uses, so the summary can never disagree with the overlay.
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
// Real, localised prices from Paddle for those items.
const [receiptPrice, setReceiptPrice] = useState<PricePreview | null>(null);
useEffect(() => {
let live = true;
previewPrices(items).then((p) => {
if (live) setReceiptPrice(p);
});
return () => {
live = false;
};
}, [items]);
// A headline "base" price per tier, all previewed in one call.
const [basePrices, setBasePrices] = useState<Record<string, string>>({});
useEffect(() => {
if (!options) return;
const baseItems: { priceId: string; quantity: number; tier: Tier }[] = [];
for (const p of activePlans) {
const row = options.catalogue.find((r) => r.deployment === dep && r.tier === p.tier && r.kind === "base");
const id = row?.price_ids?.[options.env]?.[choice.term];
if (id) baseItems.push({ priceId: id, quantity: 1, tier: p.tier });
}
let live = true;
previewPrices(baseItems.map(({ priceId, quantity }) => ({ priceId, quantity }))).then((p) => {
if (!live) return;
const next: Record<string, string> = {};
if (p) {
for (const bi of baseItems) {
const line = p.lines[bi.priceId];
if (line) next[bi.tier] = line.total;
}
}
setBasePrices(next);
});
return () => {
live = false;
};
}, [options, dep, choice.term, activePlans]);
// --- actions -----------------------------------------------------------
const createFree = useMutation({
mutationFn: () => api.createInstance(name.trim()),
onSuccess: () => router.push("/"),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the instance."),
});
// Self-hosted Free binds to the install's own UUID: register the instance,
// then issue its Free licence in one action.
const createSelfHostedFree = useMutation({
mutationFn: async () => {
const inst = await api.link(uuid.trim(), name.trim());
await api.claimFree(inst.instance_id);
return inst.instance_id;
},
onSuccess: (id) => router.push(`/instances/${id}`),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
});
// Self-hosted checkout names the install's REAL UUID, so the instance is
// linked (or an already-owned one reused) before Paddle opens. The webhook
// then issues straight onto it — there is no placeholder to claim afterwards.
const startCheckout = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed);
return r.instance_id;
},
onSuccess: async (instanceId) => {
setPending({ instanceId, deployment: dep });
const paddle = await initPaddle();
paddle?.Checkout.open({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
customData: { account_id: accountId, instance_id: instanceId },
});
},
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
});
if (optionsQ.isLoading || account.isLoading) {
return <p className="text-ink-3">Loading plans</p>;
}
if (!options) {
return <p className="text-ink-2">Plans are unavailable right now. Try again shortly.</p>;
}
const selfHostedFree = dep === "self_hosted" && choice.tier === "free";
const cloudFree = dep === "cloud" && choice.tier === "free";
const paid = choice.tier !== "free";
return (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
{/* ---- main column ---- */}
<div className="grid min-w-0 gap-6">
<Block n={1} label="Deployment">
<Seg
value={dep}
onChange={(v) => {
const next = v as Deployment;
setDep(next);
// Self-hosted sells annual only; clamp the term.
setChoice((c) => ({
...c,
term: next === "self_hosted" ? "annual" : c.term,
}));
}}
options={[
{
value: "cloud",
icon: cloudIcon,
title: "Cloud",
sub: "We host and manage it · monthly or annual",
},
{
value: "self_hosted",
icon: serverIcon,
title: "Self-hosted",
sub: "Runs on your own servers · annual only",
},
]}
/>
</Block>
{dep === "cloud" && (
<Block n={2} label="Billing">
<Seg
value={choice.term}
onChange={(v) => setChoice((c) => ({ ...c, term: v as Term }))}
options={[
{
value: "monthly",
icon: calendarIcon,
title: "Monthly",
sub: "Pay as you go · cancel anytime",
},
{
value: "annual",
icon: annualIcon,
title: "Annual",
sub: "2 months free vs monthly",
},
]}
/>
</Block>
)}
<Block n={dep === "cloud" ? 3 : 2} label="Plan">
<div className="grid gap-3 sm:grid-cols-3">
{activePlans.map((p) => (
<TierCard
key={p.tier}
plan={p}
selected={p.tier === choice.tier}
headline={p.tier === "free" ? "£0" : basePrices[p.tier]}
cycleLabel={cycleShort(dep, choice.term)}
featureKeys={featureKeys}
catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)}
env={options.env}
term={choice.term}
onSelect={() =>
setChoice((c) => ({
...c,
tier: p.tier,
// Moving tier moves the floor; clamp up.
servers: Math.max(c.servers, p.base_limits.max_servers === -1 ? c.servers : p.base_limits.max_servers),
// Drop add-ons the new tier does not sell.
features: c.features.filter((k) => {
const st = featureStateFor(
p,
options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier),
options.env,
c.term,
k,
);
return st === "addon";
}),
}))
}
/>
))}
</div>
</Block>
{paid && (
<Block n={dep === "cloud" ? 4 : 3} label="Configure">
<div className="rounded border border-rule bg-panel p-4">
{/* servers */}
<Row title="Managed servers" desc={unlimited ? "Unlimited servers included in this plan" : `${baseServers} included`}>
{unlimited ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Unlimited</span>
) : (
<Stepper value={choice.servers} min={baseServers} max={500} onChange={(servers) => setChoice((c) => ({ ...c, servers }))} />
)}
</Row>
{/* features */}
{featureKeys.map((key) => {
const st = featureStateFor(plan, rows, options.env, choice.term, key);
return (
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
{st === "included" ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
) : st === "absent" ? (
<span className="font-mono text-[0.76rem] text-ink-3">Not in this plan</span>
) : (
<Toggle
checked={choice.features.includes(key)}
onChange={(on) =>
setChoice((c) => ({
...c,
features: on ? [...c.features, key] : c.features.filter((f) => f !== key),
}))
}
/>
)}
</Row>
);
})}
</div>
</Block>
)}
{dep === "self_hosted" && (
<Block n={paid ? 4 : 3} label="Your install">
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
<p className="text-[0.86rem] text-ink-2">
{paid
? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it."
: "Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence — nothing to pay."}
</p>
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
<input
value={uuid}
onChange={(e) => setUuid(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
/>
<span className="text-[0.72rem] text-ink-3">Find this on your install&rsquo;s Settings Licence page, or the setup screen just after first sign-in.</span>
</label>
</div>
</Block>
)}
</div>
{/* ---- receipt rail ---- */}
<aside className="lg:sticky lg:top-5">
<div className="overflow-hidden rounded-[14px] border border-rule bg-panel shadow-[var(--shadow)]">
<div className="flex items-center justify-between border-b border-rule-soft px-4 py-3.5">
<h3 className="text-[0.95rem] font-semibold">Order summary</h3>
<span className="rounded border border-rule px-1.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-[0.07em] text-ink-3">{dep === "cloud" ? "Cloud" : "Self-hosted"}</span>
</div>
<Receipt options={options} dep={dep} choice={choice} plan={plan} items={items} price={receiptPrice} />
{/* name + CTA */}
<div className="grid gap-3 border-t border-rule px-4 py-4">
{!pending && (
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
className="rounded border border-rule bg-panel px-2.5 py-2 text-[0.9rem] text-ink placeholder:text-ink-3"
/>
</label>
)}
{error && <p className="text-[0.82rem] text-expired">{error}</p>}
{/* Phase A: choose an action for the configuration. */}
{!pending &&
(selfHostedFree ? (
<Cta
label={createSelfHostedFree.isPending ? "Creating…" : "Create licence"}
variant="line"
disabled={!UUID_RE.test(uuid.trim()) || createSelfHostedFree.isPending}
onClick={() => {
setError(null);
createSelfHostedFree.mutate();
}}
/>
) : cloudFree ? (
<Cta
label={createFree.isPending ? "Creating…" : "Create free instance"}
variant="line"
disabled={!name.trim() || createFree.isPending}
onClick={() => {
setError(null);
createFree.mutate();
}}
/>
) : (
<Cta
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
onClick={() => {
setError(null);
startCheckout.mutate();
}}
/>
))}
{/* Phase B: after the checkout has been opened. */}
{pending && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">
{pending.deployment === "cloud"
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
</p>
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
Go to your instance
</Link>
</div>
)}
</div>
<div className="flex items-start gap-2 border-t border-rule-soft px-4 py-3 text-[0.72rem] text-ink-3">
<LockIcon />
<span>{paid ? "Secure checkout by Paddle, our reseller of record. VAT is added at checkout where applicable." : "No payment details required for the Free plan."}</span>
</div>
</div>
</aside>
</div>
);
}
// ---------------------------------------------------------------------------
// Presentational pieces
// ---------------------------------------------------------------------------
function cycleShort(dep: Deployment, term: Term) {
return dep === "cloud" ? (term === "annual" ? "/yr" : "/mo") : "/yr";
}
function Block({ n, label, children }: { n: number; label: string; children: React.ReactNode }) {
return (
<section className="grid gap-2.5">
<h2 className="flex items-center gap-2 text-[0.72rem] font-bold uppercase tracking-[0.1em] text-ink-3">
<span className="font-mono text-accent">{n}</span>
{label}
</h2>
{children}
</section>
);
}
interface SegOption {
value: string;
icon: React.ReactNode;
title: string;
sub: string;
}
function Seg({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: SegOption[] }) {
return (
<div className="flex gap-1 rounded-[9px] border border-rule bg-panel-2 p-1">
{options.map((o) => {
const on = o.value === value;
return (
<button
key={o.value}
type="button"
aria-pressed={on}
onClick={() => onChange(o.value)}
className={`flex flex-1 items-center gap-3 rounded-[7px] px-4 py-3 text-left transition-colors ${on ? "bg-panel text-ink shadow-[var(--shadow)]" : "text-ink-2"}`}
>
<span
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-lg border ${
on ? "border-accent/40 bg-accent-wash text-accent" : "border-rule bg-panel text-ink-3"
}`}
>
{o.icon}
</span>
<span className="flex flex-col leading-tight">
<span className="text-[0.92rem] font-bold">{o.title}</span>
<span className={`text-[0.72rem] font-medium ${on ? "text-accent" : "text-ink-3"}`}>{o.sub}</span>
</span>
<span className={`relative ml-auto h-[18px] w-[18px] flex-none rounded-full border-2 ${on ? "border-accent bg-accent" : "border-rule"}`}>
{on && <span className="absolute inset-[3px] rounded-full bg-accent-ink" />}
</span>
</button>
);
})}
</div>
);
}
function TierCard({
plan,
selected,
headline,
cycleLabel,
featureKeys,
catalogue,
env,
term,
onSelect,
}: {
plan: Plan;
selected: boolean;
headline?: string;
cycleLabel: string;
featureKeys: string[];
catalogue: CatalogueRow[];
env: string;
term: Term;
onSelect: () => void;
}) {
const base = plan.base_limits.max_servers;
const servers = base === -1 ? "Unlimited servers" : `${base} server${base === 1 ? "" : "s"} included`;
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={`relative flex flex-col gap-3 rounded-xl border bg-panel p-4 text-left transition-[border-color,box-shadow] ${
selected ? "border-accent shadow-[0_0_0_1px_var(--accent)]" : "border-rule hover:border-accent/50"
}`}
>
{plan.tier === "professional" && (
<span className="absolute -top-2 right-3 rounded-full bg-accent px-2 py-0.5 text-[0.6rem] font-bold uppercase tracking-[0.08em] text-accent-ink">Most popular</span>
)}
<span className="flex items-center justify-between gap-2">
<span className="text-[1.05rem] font-extrabold tracking-[-0.02em]">{plan.name}</span>
<span className={`relative h-4 w-4 flex-none rounded-full border-2 ${selected ? "border-accent bg-accent" : "border-rule"}`}>
{selected && <span className="absolute inset-[3px] rounded-full bg-accent-ink" />}
</span>
</span>
<span className="flex items-baseline gap-1">
<span className="text-[1.5rem] font-extrabold tracking-[-0.03em] tabular-nums">{headline ?? "—"}</span>
<span className="text-[0.72rem] text-ink-3">{plan.tier === "free" ? "forever" : cycleLabel}</span>
</span>
<ul className="grid gap-1.5 text-[0.8rem] text-ink-2">
<FeatureLine on>{servers}</FeatureLine>
{featureKeys.map((key) => {
const st = featureStateFor(plan, catalogue, env, term, key);
return (
<FeatureLine key={key} on={st !== "absent"}>
{featureLabel(key)}
{st === "included" ? " included" : st === "addon" ? " add-on" : " not available"}
</FeatureLine>
);
})}
<FeatureLine on>{supportLabel(plan.support_level)} support</FeatureLine>
</ul>
</button>
);
}
function supportLabel(level: string) {
switch (level) {
case "community":
return "Community";
case "email_24_5":
return "Email, 24/5";
case "email_call_24_7":
return "Email + call, 24/7";
default:
return level;
}
}
function FeatureLine({ on, children }: { on: boolean; children: React.ReactNode }) {
return (
<li className={`flex items-start gap-2 ${on ? "" : "text-ink-3"}`}>
<span className={`mt-0.5 flex-none ${on ? "text-valid" : "text-ink-3"}`} aria-hidden>
{on ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
<path d="M5 12h14" />
</svg>
)}
</span>
<span>{children}</span>
</li>
);
}
function Row({ title, desc, dim, children }: { title: string; desc: string; dim?: boolean; children: React.ReactNode }) {
return (
<div className={`flex items-center justify-between gap-4 border-b border-rule-soft py-3.5 first:pt-0 last:border-0 last:pb-0 ${dim ? "opacity-55" : ""}`}>
<div className="min-w-0">
<h4 className="text-[0.9rem] font-semibold">{title}</h4>
{desc && <p className="text-[0.78rem] text-ink-3">{desc}</p>}
</div>
<div className="flex-none">{children}</div>
</div>
);
}
function Stepper({ value, min, max, onChange }: { value: number; min: number; max: number; onChange: (v: number) => void }) {
const clamp = (v: number) => Math.min(max, Math.max(min, v));
return (
<div className="inline-flex items-center overflow-hidden rounded-lg border border-rule">
<button
type="button"
aria-label="Fewer servers"
disabled={value <= min}
onClick={() => onChange(clamp(value - 1))}
className="h-9 w-9 bg-panel-2 text-lg leading-none text-ink hover:bg-accent-wash hover:text-accent disabled:opacity-35"
>
</button>
<input
value={value}
inputMode="numeric"
aria-label="Server count"
onChange={(e) => onChange(clamp(parseInt(e.target.value) || min))}
className="h-9 w-14 border-x border-rule bg-panel text-center text-[0.9rem] font-bold tabular-nums text-ink"
/>
<button
type="button"
aria-label="More servers"
disabled={value >= max}
onClick={() => onChange(clamp(value + 1))}
className="h-9 w-9 bg-panel-2 text-lg leading-none text-ink hover:bg-accent-wash hover:text-accent disabled:opacity-35"
>
+
</button>
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-[42px] flex-none rounded-full transition-colors ${checked ? "bg-accent" : "bg-rule"}`}
>
<span className={`absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white shadow transition-[left] ${checked ? "left-[21px]" : "left-[3px]"}`} />
</button>
);
}
function Receipt({
options,
dep,
choice,
plan,
items,
price,
}: {
options: CheckoutOptions;
dep: Deployment;
choice: Choice;
plan: Plan | undefined;
items: { priceId: string; quantity: number }[];
price: PricePreview | null;
}) {
if (choice.tier === "free") {
return (
<div className="px-4">
<div className="flex items-center justify-between gap-3 py-3 text-[0.85rem]">
<span className="text-ink-2">
{plan?.name ?? "Free"} plan
<small className="block text-[0.72rem] text-ink-3">{plan?.base_limits.max_servers ?? 1} server · community support</small>
</span>
<span className="font-mono font-semibold tabular-nums text-valid">£0</span>
</div>
</div>
);
}
// Label each real line item from the catalogue, and price it from Paddle.
const base = plan?.base_limits.max_servers ?? 0;
const extra = base === -1 ? 0 : Math.max(0, choice.servers - base);
const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier);
const idFor = (predicate: (r: CatalogueRow) => boolean) => {
const row = rows.find(predicate);
return row?.price_ids?.[options.env]?.[choice.term] ?? "";
};
const amount = (priceId: string) => price?.lines[priceId]?.total ?? null;
const lines: { label: string; sub?: string; value: string | null }[] = [];
const baseId = idFor((r) => r.kind === "base");
lines.push({
label: `${plan?.name ?? ""} base`,
sub: base === -1 ? "unlimited servers" : `${base} servers included`,
value: amount(baseId),
});
if (extra > 0) {
lines.push({
label: "Extra servers",
sub: `${extra} × per server`,
value: amount(idFor((r) => r.kind === "limit" && r.limit_key === "max_servers")),
});
}
for (const key of choice.features) {
const id = idFor((r) => r.kind === "feature" && r.feature_key === key);
if (id) lines.push({ label: featureLabel(key), sub: "add-on", value: amount(id) });
}
const priced = price !== null;
return (
<div className="px-4">
<div className="grid">
{lines.map((l, i) => (
<div key={i} className="flex justify-between gap-3 border-b border-dashed border-rule-soft py-2.5 text-[0.85rem] last:border-0">
<span className="text-ink-2">
{l.label}
{l.sub && <small className="block text-[0.72rem] text-ink-3">{l.sub}</small>}
</span>
<span className="font-mono font-semibold tabular-nums">{l.value ?? "—"}</span>
</div>
))}
</div>
<div className="mt-2 flex items-baseline justify-between border-t border-rule pt-3">
<span className="text-[0.85rem]">Total</span>
<span className="text-[1.4rem] font-extrabold tabular-nums">{priced && price?.total ? price.total : "—"}</span>
</div>
<p className="pb-3 pt-0.5 text-[0.72rem] text-ink-3">
{priced
? dep === "cloud"
? choice.term === "annual"
? "per year, billed annually"
: "per month, billed monthly"
: "per year, billed annually"
: items.length > 0
? "Final price shown at checkout."
: ""}
</p>
</div>
);
}
function Cta({ label, onClick, disabled, variant = "solid" }: { label: string; onClick: () => void; disabled?: boolean; variant?: "solid" | "line" }) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`rounded-[9px] px-3 py-3 text-[0.9rem] font-bold transition-[filter] hover:brightness-[1.06] disabled:opacity-40 disabled:hover:brightness-100 ${
variant === "solid" ? "bg-accent text-accent-ink" : "border border-accent bg-panel text-accent"
}`}
>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// Icons
// ---------------------------------------------------------------------------
const cloudIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.5 19a4.5 4.5 0 0 0 .5-9 6 6 0 0 0-11.6-1.5A4 4 0 0 0 6 19z" />
</svg>
);
const serverIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="6" rx="1" />
<rect x="2" y="9" width="20" height="6" rx="1" />
<path d="M6 6h.01M6 12h.01" />
</svg>
);
const calendarIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18M8 2v4M16 2v4" />
</svg>
);
const annualIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
function LockIcon() {
return (
<svg className="mt-px flex-none" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
@@ -1,18 +0,0 @@
import type { Metadata } from "next";
import { PurchaseForm } from "./PurchaseForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "Buy a plan" };
export default function PurchasePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Choose your plan"
subtitle="Configure the instance, see exactly what you'll be charged, then pay. Nothing is billed until you confirm at checkout."
/>
<PurchaseForm />
</div>
);
}
@@ -1,74 +0,0 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
export default function SettingsPage() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<string | null>(null);
const change = useMutation({
mutationFn: () => api.changePassword(current, next),
onSuccess: (res) => {
setCurrent("");
setNext("");
setDone(
res.propagation_pending
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
: "Password changed everywhere.",
);
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Settings"
subtitle="Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them."
/>
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
setDone(null);
change.mutate();
}}
>
<Field
label="Current password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
{done && <p className="text-[0.9rem] text-valid">{done}</p>}
<Button type="submit" disabled={change.isPending || next.length < 12}>
{change.isPending ? "Changing…" : "Change password"}
</Button>
</form>
</div>
);
}
@@ -1,237 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button, controlClass } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { formatDate } from "@/lib/format";
const ROLES: AccountRole[] = ["owner", "admin", "member"];
const WHAT_ROLES_DO: [AccountRole, string][] = [
["owner", "Everything, including billing."],
["admin", "Invite people, create instances, grant access. No billing."],
["member", "Sign in to the instances they are given."],
];
export function InvitePanel() {
const qc = useQueryClient();
const { session } = useSession();
const [email, setEmail] = useState("");
const [role, setRole] = useState<AccountRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const invite = useMutation({
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
onSuccess: () => {
setEmail("");
setRole("member");
refresh();
},
onError: fail,
});
const setRoleFor = useMutation({
mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
onSuccess: refresh,
onError: fail,
});
const remove = useMutation({
mutationFn: (id: string) => api.removeAccountUser(id),
onSuccess: () => {
setConfirming(null);
refresh();
},
onError: (e) => {
setConfirming(null);
fail(e);
},
});
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<PageFrame
aside={
<>
{canManage && (
<RailCard title="Invite someone">
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className={controlClass()}>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
</RailCard>
)}
<RailCard title="What the roles do">
<dl className="grid gap-2">
{WHAT_ROLES_DO.map(([r, what]) => (
<div key={r} className="grid gap-0.5">
<dt className="font-mono text-[0.68rem] uppercase tracking-[0.08em] text-ink">
{r}
</dt>
<dd className="m-0 text-[0.8rem] text-ink-2">{what}</dd>
</div>
))}
</dl>
<p className="border-t border-rule-soft pt-2 text-[0.8rem] text-ink-2">
An account role is not access to an instance. Give someone that on the
instance itself.
</p>
</RailCard>
</>
}
>
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
<th className="px-4 py-2.5 font-normal">Email</th>
<th className="px-4 py-2.5 font-normal">Account role</th>
<th className="px-4 py-2.5 font-normal">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="px-4 py-3">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="px-4 py-3 text-ink-2">
{u.verified_at
? `Active since ${formatDate(u.verified_at)}`
: "Invitation pending"}
</td>
<td className="px-4 py-3 text-right">
{canManage &&
!isSelf &&
/*
* Inline rather than window.confirm(): removing
* someone here revokes them from every instance
* on the account, which is more than the word
* "Remove" beside one row implies, and the
* browser dialog cannot show the consequence
* where the eye already is.
*/
(confirming === u.user_id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-[0.82rem] text-ink-2">
Removes access to every instance.
</span>
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
disabled={remove.isPending}
onClick={() => remove.mutate(u.user_id)}
>
{remove.isPending ? "Removing…" : "Remove"}
</button>
<button
type="button"
className="text-[0.82rem] text-ink-2 underline"
onClick={() => setConfirming(null)}
>
Keep
</button>
</span>
) : (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => setConfirming(u.user_id)}
>
Remove<span className="sr-only"> {u.email}</span>
</button>
))}
</td>
</tr>
);
})}
{users.data?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-ink-3">
Nobody yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</PageFrame>
);
}
-16
View File
@@ -1,16 +0,0 @@
"use client";
import { InvitePanel } from "./InvitePanel";
import { PageHeader } from "@/components/PageHeader";
export default function UsersPage() {
return (
<div className="grid gap-6">
<PageHeader
title="People"
subtitle="Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners."
/>
<InvitePanel />
</div>
);
}
@@ -1,80 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { EmptyState, Panel } from "@/components/Panel";
import { controlClass } from "@/components/Button";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export function AccountSearch() {
const [q, setQ] = useState("");
const { data, isFetching } = useQuery({
queryKey: ["staff-accounts", q],
queryFn: () => api.staff.accounts(q || undefined),
});
const rows = data ?? [];
return (
<div className="grid gap-4">
<Panel>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Search</span>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, email, ctm_… or an instance UUID"
className={controlClass()}
/>
</label>
</Panel>
<Panel bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Account</TH>
<TH>Billing email</TH>
<TH>Status</TH>
<TH>Created</TH>
<TH />
</TR>
</THead>
<TBody>
{rows.map((a) => (
<TR key={a.account_id}>
<TD>
<Link href={`/staff/accounts/${a.account_id}`} className="font-semibold text-accent no-underline hover:underline">
{a.name}
</Link>
<Sub>
<span className="font-mono">{a.account_id}</span>
</Sub>
</TD>
<TD className="font-mono text-[0.82rem] text-ink-2">{a.billing_email}</TD>
<TD className="text-ink-2">{a.status}</TD>
<TD className="font-mono tabular-nums text-ink-2">{formatDate(a.created_at)}</TD>
<TD numeric>
<Link href={`/staff/accounts/${a.account_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
Open
</Link>
</TD>
</TR>
))}
</TBody>
</Table>
{!isFetching && rows.length === 0 && (
<EmptyState
title={q ? "No account matches that." : "No accounts yet."}
body={q ? "Try the instance UUID from the customer's email — it resolves to the account that owns it." : undefined}
/>
)}
</Panel>
</div>
);
}
@@ -1,156 +0,0 @@
"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";
import { PageHeader } from "@/components/PageHeader";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export default function AccountDetailPage() {
const id = String(useParams().id);
const { data, isLoading } = useQuery({
queryKey: ["staff-account", id],
queryFn: () => api.staff.account(id),
});
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
return (
<div className="grid gap-5">
<PageHeader
back={{ href: "/staff/accounts", label: "Accounts" }}
title={data.account.name}
subtitle={data.account.billing_email}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Status", value: data.account.status },
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
]}
/>
{/*
* Four lists of "thing · thing · thing" became four tables. Each row
* held three or four separate facts run into one string with
* middots, which cannot be scanned down a column and a staff
* screen is read by scanning down a column.
*/}
<Panel title="Instances" meta={String(data.instances.length)} bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Instance</TH>
<TH>Deployment</TH>
<TH>Tier</TH>
<TH>Status</TH>
<TH />
</TR>
</THead>
<TBody>
{data.instances.map((i) => (
<TR key={i.instance_id}>
<TD>
<Link href={`/staff/instances/${i.instance_id}`} className="font-semibold text-accent no-underline hover:underline">
{i.name || "Unnamed instance"}
</Link>
<Sub>
<span className="font-mono">{i.instance_id.slice(0, 8)}</span>
</Sub>
</TD>
<TD className="text-ink-2">{i.deployment === "cloud" ? "Cloud" : "Self-hosted"}</TD>
<TD className="text-ink-2">{i.tier?.replace("_", " ") ?? "—"}</TD>
<TD className="text-ink-2">{i.status}</TD>
<TD numeric>
<Link href={`/staff/instances/${i.instance_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
Open
</Link>
</TD>
</TR>
))}
</TBody>
</Table>
{data.instances.length === 0 && <EmptyState title="No instances on this account." body="They have signed up but not created or linked anything yet." />}
</Panel>
<Panel title="Subscriptions" meta={String(data.subscriptions.length)} bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Tier</TH>
<TH>Billing</TH>
<TH>Status</TH>
<TH>Renews</TH>
</TR>
</THead>
<TBody>
{data.subscriptions.map((s) => (
<TR key={s.subscription_id}>
<TD>{s.tier.replace("_", " ")}</TD>
<TD className="text-ink-2">{s.term}</TD>
<TD className="text-ink-2">{s.status}</TD>
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(s.current_period_end)}</TD>
</TR>
))}
</TBody>
</Table>
{data.subscriptions.length === 0 && <EmptyState title="No subscriptions." body="Everything on this account is Free, or nothing has been bought yet." />}
</Panel>
<Panel title="People" meta={String(data.users.length)} bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Email</TH>
<TH>Role</TH>
<TH>Verified</TH>
</TR>
</THead>
<TBody>
{data.users.map((u) => (
<TR key={u.user_id}>
<TD className="font-mono text-[0.82rem]">{u.email}</TD>
<TD className="text-ink-2">{u.account_role}</TD>
<TD className="text-ink-2">
{u.verified_at ? (
<span className="font-mono tabular-nums">{formatDate(u.verified_at)}</span>
) : (
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-warn">Not verified</span>
)}
</TD>
</TR>
))}
</TBody>
</Table>
{data.users.length === 0 && (
<EmptyState title="No HQ people on this account." body="This is a cloud account, so its people sign in with their control-plane details instead." />
)}
</Panel>
<Panel title="Audit" meta="Newest first" bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Date</TH>
<TH>Actor</TH>
<TH>Action</TH>
<TH>Target</TH>
</TR>
</THead>
<TBody>
{data.audit.map((e, n) => (
<TR key={n}>
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(e.created_at)}</TD>
<TD className="text-ink-2">{e.actor}</TD>
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
<TD className="text-ink-2">{e.target ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
{data.audit.length === 0 && <EmptyState title="Nothing recorded against this account yet." />}
</Panel>
</div>
);
}
@@ -1,14 +0,0 @@
import { AccountSearch } from "./AccountSearch";
import { PageHeader } from "@/components/PageHeader";
export default function AccountsPage() {
return (
<div className="grid gap-6">
<PageHeader
title="Accounts"
subtitle="Search by name, email, Paddle ID or instance UUID."
/>
<AccountSearch />
</div>
);
}
@@ -1,80 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export default function AuditPage() {
const [filter, setFilter] = useState("");
const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
const rows = (data ?? []).filter((e) => (filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true));
return (
<div className="grid gap-6">
<PageHeader
title="Audit"
subtitle="Every mutating action across every account, newest first."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<Panel>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Filter</span>
<input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Action, actor or target"
className={controlClass()}
/>
</label>
</Panel>
{/*
* A table, not a list of mono sentences joined by middots. Every row
* held five separate facts run together into one string, so nothing
* could be scanned down a column which is the only way anyone
* reads an audit log looking for "who did this".
*/}
<Panel bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Time</TH>
<TH>Actor</TH>
<TH>Action</TH>
<TH>Target</TH>
<TH>Detail</TH>
</TR>
</THead>
<TBody>
{rows.map((e, n) => (
<TR key={n}>
<TD className="whitespace-nowrap font-mono text-[0.78rem] tabular-nums text-ink-2">
{formatStamp(e.created_at)}
<Sub>{formatDate(e.created_at)}</Sub>
</TD>
<TD className="text-ink-2">{e.actor}</TD>
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
<TD className="text-ink-2">{e.target ?? "—"}</TD>
<TD className="text-[0.82rem] text-ink-3">{e.detail ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
{rows.length === 0 && (
<EmptyState
title={filter ? "Nothing matches that." : "No actions recorded yet."}
body={filter ? "Clear the filter to see the whole log." : "Every licence issued, relinked or reaped is written here as it happens."}
/>
)}
</Panel>
</div>
);
}
@@ -1,169 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/PageHeader";
import { PageFrame } from "@/components/PageFrame";
import { Panel } from "@/components/Panel";
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
* rather than rendered and rejected. The backend refuses one either way; this is
* so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return "Base fee";
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
return `Feature: ${r.feature_key}`;
}
function rowKey(r: CatalogueRow): string {
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
export default function CataloguePage() {
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
const save = useMutation({
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
});
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
return (
<div className="grid gap-6">
<PageHeader
title="Catalogue"
back={{ href: "/staff", label: "Operations" }}
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
/>
<PageFrame
aside={
<aside className="space-y-3 text-[0.82rem] text-ink-2">
<p>
A component with no price ID is free. A feature with no price is a
toggle a customer may take at no charge; giving it a price here is
all it takes to start charging for it.
</p>
<p>
Free is priced by nothing and has no rows. That absence is what
keeps it outside Paddle.
</p>
<p>
Changing a price affects the next checkout only. It cannot touch an
issued licence.
</p>
</aside>
}
>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="grid gap-4">
{groups.map((g) => {
const [deployment, tier] = g.split("/");
const terms = termsFor(deployment);
return (
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
<Table className="min-w-[42rem]">
<THead>
<TR className="hover:bg-transparent">
<TH>Component</TH>
{ENVS.map((env) =>
terms.map((t) => (
<TH key={`${env}-${t}`}>
{env} / {t}
</TH>
)),
)}
<TH />
</TR>
</THead>
<TBody>
{rows
.filter(
(r) =>
r.deployment === deployment &&
r.tier === tier,
)
.map((r) => {
const k = rowKey(r);
const ids = drafts[k] ?? r.price_ids ?? {};
const dirty =
JSON.stringify(ids) !==
JSON.stringify(r.price_ids ?? {});
return (
<TR key={k}>
<TD className="text-ink">{componentLabel(r)}</TD>
{ENVS.map((env) =>
terms.map((t) => (
<TD key={`${env}-${t}`}>
<input
value={
ids[env]?.[t] ?? ""
}
placeholder="pri_…"
onChange={(e) =>
setDrafts({
...drafts,
[k]: {
...ids,
[env]: {
...(ids[
env
] ?? {}),
[t]: e
.target
.value,
},
},
})
}
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
/>
</TD>
)),
)}
<TD numeric>
<button
type="button"
disabled={
!dirty || save.isPending
}
onClick={() =>
save.mutate({
...r,
price_ids: ids,
})
}
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
>
Save
</button>
</TD>
</TR>
);
})}
</TBody>
</Table>
</Panel>
);
})}
</div>
)}
</PageFrame>
</div>
);
}
@@ -1,63 +0,0 @@
"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 }: { instanceId: string }) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>("professional");
const [term, setTerm] = useState("annual");
const [newId, setNewId] = useState("");
const [error, setError] = useState<string | undefined>();
const invalidate = () => qc.invalidateQueries({ queryKey: ["staff-instance", instanceId] });
const issue = useMutation({
mutationFn: () => api.staff.issue(instanceId, { tier, term, reason: "manual" }),
onSuccess: invalidate,
onError: (e) => setError(e instanceof ApiError ? e.message : "Issue failed."),
});
const relink = useMutation({
mutationFn: () => api.staff.relink(instanceId, newId.trim()),
onSuccess: invalidate,
onError: (e) => setError(e instanceof ApiError ? e.message : "Relink failed."),
});
return (
<section className="grid gap-4 border-t border-rule-soft pt-5">
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
<select value={tier} onChange={(e) => setTier(e.target.value as Tier)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
<select value={term} onChange={(e) => setTerm(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
<option value="annual">Annual</option>
<option value="monthly">Monthly</option>
</select>
</label>
<Button type="button" onClick={() => issue.mutate()} disabled={issue.isPending}>
{issue.isPending ? "Issuing…" : "Issue licence"}
</Button>
</div>
<div className="flex flex-wrap items-end gap-3">
<Field label="Relink to instance ID" value={newId} onChange={(e) => setNewId(e.target.value)} hint="Staff relinks are not capped the customer cap exists to put you in the loop." />
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
Relink
</Button>
</div>
{error && <p className="text-[0.82rem] text-expired">{error}</p>}
</section>
);
}
@@ -1,199 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import clsx from "clsx";
import { api, type Deployment, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import { TermBar } from "@/components/TermBar";
import { Panel } from "@/components/Panel";
import { licenceState } from "@/lib/format";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
import { RenamePanel } from "@/components/RenamePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
current: { label: "Control plane holds the current licence", tone: "text-valid" },
stale: {
label: "Control plane holds an older blob the reconciler will repair it",
tone: "text-warn",
},
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
none_issued: { label: "Nothing issued yet, so nothing to inject", tone: "text-ink-3" },
};
export default function StaffInstancePage() {
const id = String(useParams().id);
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["staff-instance", id],
queryFn: () => api.staff.instance(id),
refetchInterval: 30_000,
});
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
const current = data.licenses.find((l) => !l.superseded_by);
// A cloud placeholder has no control-plane row yet, so there is no host to
// move and nothing to rename — the panel's wording and its control are both
// read from this one answer rather than from the deployment alone, which is
// how they came to contradict each other.
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
return (
<div className="grid gap-8">
<div className="grid gap-3">
<PageHeader
back={{
href: `/staff/accounts/${data.account.account_id}`,
label: data.account.name || "Account",
}}
title={data.instance.name || data.instance.instance_id}
subtitle={
<>
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
</span>
</>
}
record={[{ key: "Instance", value: data.instance.instance_id, copy: true }, ...(data.instance.slug ? [{ key: "Slug", value: data.instance.slug }] : [])]}
/>
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
</div>
{/*
* The live licence is the one nothing has superseded, which is the
* record's own statement of the fact not its position in the
* array, which is the server's ordering and not a guarantee.
*/}
{current && (
<Panel title="Current licence" meta={current.license_id}>
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
</Panel>
)}
<Panel title="Licence history" meta="Append-only">
<Ledger licenses={data.licenses} />
<IssuePanel instanceId={data.instance.instance_id} />
</Panel>
{/*
* Staff rename has no cooldown and does not start the customer's:
* fixing a name on someone's behalf must not spend their next 24
* hours.
*/}
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
{cloudPlaceholder ? (
// The API refuses this with a 409, so offering the control
// would only be a form that cannot succeed.
<p className="text-[0.85rem] text-ink-3">
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
</p>
) : (
/*
* Keyed on the instance so a success note cannot follow staff
* from one instance page to the next the element stays
* mounted across that navigation.
*/
<RenamePanel
key={data.instance.instance_id}
movesHost={movesHost}
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
)}
</Panel>
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
</div>
);
}
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
const qc = useQueryClient();
const { data: plans = [] } = useQuery({
queryKey: ["staff", "plans"],
queryFn: api.staff.plans,
});
const { data: catalogue = [] } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const { data } = useQuery({
queryKey: ["staff", "entitlement", instanceId],
queryFn: () => api.staff.entitlement(instanceId),
retry: false,
});
const ent = data?.entitlement;
const [draft, setDraft] = useState<PlanChoice | null>(null);
const choice: PlanChoice =
draft ??
(ent
? {
tier: ent.tier,
term: ent.term,
servers: ent.desired.servers,
features: ent.desired.features ?? [],
}
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
const save = useMutation({
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
},
});
return (
<section className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3">
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
<p className="text-[0.78rem] text-ink-3">
What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
</p>
</header>
{ent && data?.pending && (
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
Pending change currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
{ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
</p>
)}
<PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
Save as configured
</button>
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(true)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
Save and grant
</button>
</div>
<p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
{save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
</section>
);
}
-30
View File
@@ -1,30 +0,0 @@
"use client";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
const LINKS: NavLink[] = [
{ href: "/staff", label: "Operations" },
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/catalogue", label: "Catalogue" },
{ href: "/staff/audit", label: "Audit" },
];
export default function StaffLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="staff">
<AppBar
links={LINKS}
staff
context={
<span className="rounded-sm border border-accent px-1.5 py-0.5 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-accent">
Staff
</span>
}
/>
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
@@ -1,122 +0,0 @@
"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, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { TermSpark } from "@/components/TermBar";
const SELECT = controlClass("w-auto");
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));
const filtered = Boolean(tier || reason);
return (
<div className="grid gap-6">
<PageHeader
title="Licences"
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<Panel>
<div className="flex flex-wrap gap-3">
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Tier</span>
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className={SELECT} aria-label="Filter by tier">
<option value="">All tiers</option>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
<option value="self_hosted">Self-Hosted (legacy)</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Reason</span>
<select value={reason} onChange={(e) => setReason(e.target.value)} className={SELECT} aria-label="Filter by reason">
<option value="">All reasons</option>
<option value="new">New</option>
<option value="renewal">Renewal</option>
<option value="tier_change">Tier change</option>
<option value="relink">Relink</option>
<option value="manual">Manual</option>
</select>
</label>
</div>
</Panel>
<Panel bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Issued</TH>
<TH>Instance</TH>
<TH>Tier</TH>
<TH>Reason</TH>
<TH>Term</TH>
<TH>Expires</TH>
<TH>State</TH>
</TR>
</THead>
<TBody>
{rows.map((l) => {
const dead = Boolean(l.superseded_by);
return (
// A superseded row is overprinted rather than hidden:
// it is the only record of why an instance stopped
// working on a given date.
<TR key={l.license_id} className={dead ? "text-ink-3" : undefined}>
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.issued_at)}</TD>
<TD>
<Link href={`/staff/instances/${l.instance_id}`} className="font-mono text-[0.82rem] text-accent no-underline hover:underline">
{l.instance_id.slice(0, 8)}
</Link>
<Sub>
<span className="font-mono">{l.license_id.slice(0, 8)}</span>
</Sub>
</TD>
<TD>{l.tier.replace("_", " ")}</TD>
<TD className="text-ink-2">{l.reason.replace("_", " ")}</TD>
{/* A superseded row's term is not a countdown to
anything it ended when its successor was
issued, so drawing a bar would invite a
comparison that means nothing. */}
<TD>
{dead ? (
<span className="font-mono text-[0.72rem] text-ink-3"></span>
) : (
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
)}
</TD>
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.expires_at)}</TD>
<TD>
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3">{dead ? "superseded" : "current"}</span>
</TD>
</TR>
);
})}
</TBody>
</Table>
{rows.length === 0 && (
<EmptyState
title={filtered ? "No licences match those filters." : "No licences issued yet."}
body={filtered ? "Clear a filter to widen the search." : "Every issue, renewal and relink writes a row here."}
/>
)}
</Panel>
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Queue } from "@/components/Queue";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { EmptyState, Panel } from "@/components/Panel";
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { StatePill } from "@/components/StatePill";
import { LinkButton } from "@/components/Button";
import { daysRemaining, formatStamp } 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" }),
});
const audit = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
const allInstances = useQuery({
queryKey: ["instances", "all"],
queryFn: () => api.staff.instances(),
});
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const stale = (unlinked.data ?? []).filter((i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48);
const failed = injection.data?.count ?? 0;
const instances = allInstances.data ?? [];
return (
<div className="grid gap-6">
<PageHeader
title="Operations"
subtitle={failed > 0 ? "Injection failures come first those instances are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing."}
actions={
<LinkButton variant="line" href="/staff/accounts">
Find an account
</LinkButton>
}
record={[{ key: "Checked", value: formatStamp(new Date().toISOString()) }]}
status={failed > 0 ? <StatePill state="expired" /> : <StatePill state="valid" />}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Queue
title="Failed injections"
tone="expired"
count={failed}
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
}))}
/>
<Queue
title="Expiring ≤ 14 days"
tone="warn"
count={expiring.data?.length ?? 0}
items={(expiring.data ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
meta: i.tier ?? "",
}))}
/>
<Queue
title="Past due"
tone="expired"
count={pastDue.data?.length ?? 0}
items={(pastDue.data ?? []).slice(0, 4).map((s) => ({
label: s.instance_id || s.account_id,
href: `/staff/accounts/${s.account_id}`,
meta: `${daysRemaining(s.current_period_end)}d`,
}))}
/>
<Queue
title="Unlinked > 48h"
tone="accent"
count={stale.length}
items={stale.slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/accounts/${i.account_id}`,
meta: `${Math.floor((Date.now() - new Date(i.created_at).getTime()) / 86_400_000)}d`,
}))}
/>
</div>
<PageFrame
aside={
<RailCard title="Fleet">
<RailFacts
rows={[
{ label: "Instances", value: instances.length },
{
label: "Cloud",
value: instances.filter((i) => i.deployment === "cloud").length,
},
{
label: "Self-hosted",
value: instances.filter((i) => i.deployment === "self_hosted").length,
},
{
label: "Awaiting link",
value: (unlinked.data ?? []).length,
},
]}
/>
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
All licences
</Link>
</RailCard>
}
>
<Panel
title="Recent activity"
actions={
<Link href="/staff/audit" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
Full audit &rarr;
</Link>
}
bodyless
>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Time</TH>
<TH>Actor</TH>
<TH>Action</TH>
<TH>Target</TH>
</TR>
</THead>
<TBody>
{(audit.data ?? []).slice(0, 12).map((e, n) => (
<TR key={n}>
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</TD>
<TD className="text-ink-2">{e.actor}</TD>
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
<TD className="text-ink-2">{e.target ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
{audit.data?.length === 0 && <EmptyState title="Nothing yet today." body="Every licence issued, relinked or reaped appears here as it happens." />}
</Panel>
</PageFrame>
</div>
);
}
-153
View File
@@ -1,153 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { PageHeader } from "@/components/PageHeader";
import { Panel } from "@/components/Panel";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
/*
* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all.
*/
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
return (
<div className="grid gap-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: {
...draft.base_limits,
[f.key]: Number(e.target.value),
},
})
}
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
/>
<span className="mt-0.5 block text-[0.72rem] text-ink-3">1 is unlimited</span>
</label>
))}
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
<select
value={draft.support_level}
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
Offered to customers
</label>
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
<button
type="button"
disabled={!dirty || saving}
onClick={() => onSave(draft)}
className="justify-self-start rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink disabled:opacity-40"
>
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
);
}
export default function PlansPage() {
const qc = useQueryClient();
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
const licenses = useQuery({
queryKey: ["staff-licenses"],
queryFn: () => api.staff.licenses(),
});
const [draft, setDraft] = useState<Plan | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const save = useMutation({
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setDraft(null);
setSaving(null);
},
onError: () => setSaving(null),
});
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
return (
<div className="grid gap-6">
<PageHeader
title="Plans"
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
<ConfirmPlanChange
plan={original}
next={draft}
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
save.mutate(draft);
}}
onCancel={() => setDraft(null)}
/>
)}
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="grid gap-3">
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<Panel
key={`${p.deployment}/${p.tier}`}
title={p.name}
meta={`${p.deployment}/${p.tier}`}
actions={!p.active ? <span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-warn">Not offered</span> : undefined}
>
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
</Panel>
))}
</section>
))}
</div>
);
}
-81
View File
@@ -1,81 +0,0 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
function AcceptForm() {
const token = useSearchParams().get("token") ?? "";
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token)
return (
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the invitation exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (done)
return (
<AuthMessage
title="You're in"
body="Sign in with your email address and the password you just set."
action={{ href: "/login", label: "Sign in" }}
/>
);
return (
<AuthShell
title="Choose a password"
lede="You have been invited to a Vantage HQ account."
footnote="Nobody who invited you can see this password, and it is never sent to them."
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
>
<p className="text-[0.86rem] text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to.</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12} className="w-full justify-center">
{accept.isPending ? "Setting…" : "Set password and continue"}
</Button>
</form>
</AuthShell>
);
}
export default function AcceptInvitePage() {
return (
<Suspense fallback={<AuthShell title="Choose a password" lede="One moment." />}>
<AcceptForm />
</Suspense>
);
}
-178
View File
@@ -1,178 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ==========================================================================
Vantage admin console design tokens.
Lines 8-97 below are site/app/globals.css's token blocks, copied verbatim:
the marketing site and this console are one visual system. Change them in
both apps in the same commit nothing enforces the match automatically.
Light is the default because web/ is locked to dark, and telling the two
apart at a glance is what stops a Reissue landing in the wrong tab. In dark
mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
distinction leans on the ground rather than the hue.
========================================================================== */
:root {
color-scheme: light dark;
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
--rail: 1200px;
}
/* Dark tokens are defined once and applied through three selectors: the OS
preference, and both explicit values of data-theme so the in-page toggle
wins in either direction. */
@media (prefers-color-scheme: dark) {
:root {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
}
:root[data-theme="dark"] {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
:root[data-theme="light"] {
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
}
/* Not in site/: the hatched sandbox badge and hover washes need a tinted fill,
and deriving it at each use would drift. */
:root {
--accent-wash: rgba(11, 42, 88, 0.07);
}
@media (prefers-color-scheme: dark) {
:root {
--accent-wash: rgba(91, 155, 232, 0.1);
}
}
:root[data-theme="dark"] {
--accent-wash: rgba(91, 155, 232, 0.1);
}
:root[data-theme="light"] {
--accent-wash: rgba(11, 42, 88, 0.07);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
font-size: var(--s-0);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* site/'s heading treatment, which is what replaces a display face. */
h1,
h2,
h3 {
margin: 0;
font-weight: 800;
line-height: 1.03;
letter-spacing: -0.03em;
text-wrap: balance;
}
p {
margin: 0;
}
code {
font-family: var(--mono);
font-size: 0.92em;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
-28
View File
@@ -1,28 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
export const metadata: Metadata = {
title: "Vantage HQ",
description: "Licences, instances and billing for Vantage.",
};
/*
* The masthead deliberately does NOT live here. It belongs to the authenticated
* layouts, so /login, /verify and /accept-invite stop rendering a bar
* whose navigation and account menu they cannot use.
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Runs before first paint, so a dark-preferring viewer never sees white. */}
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
-94
View File
@@ -1,94 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [staff, setStaff] = useState(false);
const [error, setError] = useState<string | null>(null);
const [offline, setOffline] = useState(false);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const s = staff
? await api.staffLogin(email, password)
: await api.login(email, password);
router.replace(s.kind === "staff" ? "/staff" : "/");
} catch (err) {
if (err instanceof NotConnected) setOffline(true);
else if (err instanceof ApiError) setError(err.message);
else setError("Sign in failed. Try again.");
} finally {
setBusy(false);
}
}
if (offline)
return (
<AuthShell title="Sign in">
<NotConnectedPanel url={API_BASE} />
</AuthShell>
);
return (
<AuthShell
title="Sign in"
lede="Licences, instances and billing for your account."
/*
* HQ and the Vantage console are separate sign-ins on separate
* hosts, and the two get confused someone lands here with their
* console password and reads the generic failure as a broken
* account. Saying which door this is costs one line.
*/
footnote="This is the portal for your licence and billing. Your servers are managed inside your Vantage instance, which signs in separately."
>
<form onSubmit={submit} className="grid gap-4">
<Field label="Email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} className="accent-[var(--accent)]" />
I work at Vantage
</label>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
{SITE_URL && (
<>
<div className="h-px bg-rule-soft" />
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</AuthShell>
);
}
-15
View File
@@ -1,15 +0,0 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto max-w-rail px-5 py-12">
<h1 className="text-3xl">Nothing here</h1>
<p className="mt-2 text-ink-2">
That page does not exist, or it belongs to an account you are not signed in to.
</p>
<Link href="/" className="mt-4 inline-block text-accent underline">
Back to your account
</Link>
</main>
);
}
-85
View File
@@ -1,85 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import { api } from "@/lib/api";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
function Verify() {
const router = useRouter();
const token = useSearchParams().get("token") ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["verify", token],
queryFn: () => api.verify(token),
enabled: token !== "",
retry: false,
});
// An invitation and a verification link are the same shape, and someone will
// paste one into the other. The backend leaves an invite token unspent and
// says so; send them where they can actually finish.
const needsPassword = data?.needs_password === true;
useEffect(() => {
if (needsPassword) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
}
}, [needsPassword, token, router]);
if (needsPassword) return <AuthShell title="One moment…" lede="Taking you to set a password." />;
if (!token)
return (
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the email exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (isLoading) return <AuthShell title="Verifying…" lede="One moment." />;
if (error || !data?.verified)
return (
<AuthMessage
title="That link is invalid or has expired"
body="Links last 24 hours and can only be used once. Signing in will send you a fresh one."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
return (
<AuthShell
title="Email verified"
lede="Your account is ready."
footnote={
SITE_URL ? (
<>
New to Vantage? The{" "}
<a href={`${SITE_URL}/docs`} className="text-accent underline">
getting started guide
</a>{" "}
walks through your first instance.
</>
) : undefined
}
>
<p className="text-[0.9rem] text-ink-2">Sign in to create your first instance. The Free tier covers 5 servers and needs no card.</p>
<a
href="/login"
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
Sign in
</a>
</AuthShell>
);
}
export default function VerifyPage() {
return (
<Suspense fallback={<AuthShell title="Verifying…" lede="One moment." />}>
<Verify />
</Suspense>
);
}
-149
View File
@@ -1,149 +0,0 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useSession } from "@/lib/session";
import { useTheme, type ThemePref } from "@/lib/theme";
const APPEARANCE: { value: ThemePref; label: string }[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
/*
* Everything here is about YOU rather than about the account: your settings,
* your password, how you want the app to look, and leaving. None of it is a
* destination worth a slot in the primary nav, which is why Settings moved off
* the bar and into this menu.
*/
export function AccountMenu({ staff = false }: { staff?: boolean }) {
const { session } = useSession();
const [open, setOpen] = useState(false);
const [pref, setPref] = useTheme();
const wrap = useRef<HTMLDivElement>(null);
const router = useRouter();
const qc = useQueryClient();
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const signOut = useMutation({
mutationFn: api.logout,
// Clear the cache before leaving: a cached account response outliving
// the session would show the next person who signs in on this browser
// the previous account's name for a beat.
onSettled: () => {
qc.clear();
router.replace("/login");
},
});
const email = session?.email ?? "";
const initials =
email
.split("@")[0]
.split(/[.\-_]/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("") || "?";
return (
<div className="relative" ref={wrap}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
className={`flex items-center gap-2 rounded-sm border px-2 py-1 text-[0.8rem] ${
open ? "border-accent text-ink" : "border-rule text-ink-2"
} bg-panel hover:border-ink-3`}
>
<span className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{initials}
</span>
<span className="hidden max-w-[16ch] truncate sm:inline">{email}</span>
<span aria-hidden className="text-[0.6rem] text-ink-3">
</span>
</button>
{open && (
<div
role="menu"
// --shadow rather than a literal: globals.css defines it per
// theme, and a hardcoded rgba would be a colour value living
// in a component, which this app's tokens rule forbids.
className="absolute right-0 top-[calc(100%+8px)] z-50 grid w-64 overflow-hidden rounded border border-rule bg-panel shadow-[var(--shadow)]"
>
<div className="grid gap-0.5 border-b border-rule-soft px-3 py-2.5">
<strong className="truncate text-[0.86rem]">{email}</strong>
<span className="font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">
{staff ? "Vantage staff" : (session?.account_role ?? "member")}
</span>
</div>
{!staff && (
<Link
href="/settings"
role="menuitem"
onClick={() => setOpen(false)}
className="px-3 py-2 text-[0.86rem] text-ink hover:bg-accent-wash"
>
Settings
</Link>
)}
<div className="grid gap-1.5 border-y border-rule-soft px-3 py-2.5">
<span className="font-mono text-[0.66rem] uppercase tracking-[0.12em] text-ink-3">
Appearance
</span>
<div className="flex overflow-hidden rounded-sm border border-rule">
{APPEARANCE.map((a) => (
<button
key={a.value}
type="button"
onClick={() => setPref(a.value)}
aria-pressed={pref === a.value}
className={`flex-1 px-0 py-1 font-mono text-[0.62rem] uppercase tracking-[0.08em] ${
pref === a.value
? "bg-accent text-accent-ink"
: "bg-panel text-ink-3 hover:text-ink-2"
}`}
>
{a.label}
</button>
))}
</div>
</div>
<button
type="button"
role="menuitem"
onClick={() => signOut.mutate()}
disabled={signOut.isPending}
className="px-3 py-2 text-left text-[0.86rem] text-expired hover:bg-accent-wash"
>
{signOut.isPending ? "Signing out…" : "Sign out"}
</button>
</div>
)}
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AccountMenu } from "@/components/AccountMenu";
import { EnvBadge } from "@/components/EnvBadge";
export type NavLink = { href: string; label: string };
/*
* One masthead in three zones: who you are acting as, where you can go, and
* which environment you are in.
*
* It replaces a brand bar and a separate nav strip. The nav's active state is
* derived from the pathname rather than hardcoded the previous customer nav
* marked Overview as current on every page, including the ones that weren't it.
*
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
* to dark so this app defaults to light for the same reason CLAUDE.md gives:
* telling two consoles apart before you click Reissue. Staff and customer need
* that distinction from each other too, and one shade plus one chip buys it
* without a second palette.
*/
export function AppBar({ links, context, staff = false }: { links: NavLink[]; context?: React.ReactNode; staff?: boolean }) {
const pathname = usePathname();
const isCurrent = (href: string) =>
// The section root matches only exactly; deeper routes match by prefix,
// so /staff/accounts/:id still lights Accounts while /staff/accounts
// does not light Operations.
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
return (
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
<div className="mx-auto grid max-w-rail grid-cols-[auto_1fr_auto] items-center gap-4 px-5 md:gap-7">
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
{context && (
<>
<span aria-hidden className="hidden h-[22px] w-px bg-rule sm:block" />
<span className="hidden min-w-0 sm:block">{context}</span>
</>
)}
</div>
<nav
aria-label={staff ? "Staff" : "Account"}
className="col-span-3 col-start-1 row-start-2 flex items-stretch gap-1 overflow-x-auto border-t border-rule-soft md:col-span-1 md:col-start-2 md:row-start-1 md:border-t-0"
>
{links.map((l) => {
const on = isCurrent(l.href);
return (
<Link
key={l.href}
href={l.href}
aria-current={on ? "page" : undefined}
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
on ? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']" : "text-ink-3 hover:text-ink-2"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="col-start-3 row-start-1 flex items-center justify-end gap-2.5 py-2.5">
<span className="hidden sm:block">
<EnvBadge />
</span>
<AccountMenu staff={staff} />
</div>
</div>
</header>
);
}
-67
View File
@@ -1,67 +0,0 @@
import Link from "next/link";
/*
* The frame for every screen you can reach without a session: sign in, email
* verification, and accepting an invitation.
*
* These three had drifted into three different layouts. Sign in was a centred
* 26rem card with the lockup above it; verify and accept-invite were bare
* left-aligned text on the full 1200px rail, with no masthead, no panel and no
* brand anywhere on the page. Those two are the first screens a new customer
* ever sees arriving from an email, on a domain they have not visited before
* and they were the two that did not say whose product this is.
*
* There is no AppBar here on purpose: it carries navigation and an account
* menu, and none of it works without a session.
*/
export function AuthShell({
title,
lede,
children,
footnote,
}: {
title: string;
lede?: React.ReactNode;
children?: React.ReactNode;
/** Sits outside the panel: orientation, not part of the task. */
footnote?: React.ReactNode;
}) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
<h1 className="text-[1.16rem]">{title}</h1>
{lede && <p className="text-[0.86rem] text-ink-2">{lede}</p>}
</div>
{children && <div className="grid gap-4 rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">{children}</div>}
{footnote && <div className="mt-5 text-center text-[0.8rem] text-ink-3">{footnote}</div>}
</main>
);
}
/*
* A terminal state verified, expired, already used, invalid. Always says what
* happened and what to do next: a dead end that only reports the failure leaves
* someone holding an email they cannot act on.
*/
export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
return (
<AuthShell title={title}>
<p className="text-[0.9rem] text-ink-2">{body}</p>
{action && (
<Link
href={action.href}
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
{action.label}
</Link>
)}
</AuthShell>
);
}
-68
View File
@@ -1,68 +0,0 @@
import clsx from "clsx";
import Link from "next/link";
type Variant = "solid" | "line";
/*
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
/*
* The height every form control resolves to, buttons included.
*
* Padding alone cannot align them: a select is mono at 0.84rem and a button is
* sans at 0.94rem, so identical padding still leaves them ~7px apart and a
* filter row looks assembled from two different kits. It is the height the
* button's own padding already computed to, so buttons do not move everything
* else comes up to meet them.
*/
export const CONTROL_HEIGHT = "h-11";
/*
* An input or select that sits on a form row with a button. Mono, because in
* this product the values typed into these are addresses, UUIDs and price IDs.
*/
export function controlClass(className?: string) {
return clsx(
CONTROL_HEIGHT,
"w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink",
"focus:border-accent focus:outline-none",
className,
);
}
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
return clsx(
"inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold",
CONTROL_HEIGHT,
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
);
}
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant };
export function Button({ variant = "solid", className, ...rest }: Props) {
return <button {...rest} className={buttonClass(variant, rest.disabled, className)} />;
}
/*
* A link that looks like a button. It exists so a navigation action never has to
* be an <a> wrapped around a <button> invalid markup, and it gives screen
* readers two nested controls where the page means one.
*/
export function LinkButton({ href, variant = "solid", external, className, children }: { href: string; variant?: Variant; external?: boolean; className?: string; children: React.ReactNode }) {
const cls = buttonClass(variant, false, className);
return external ? (
<a href={href} className={cls}>
{children}
</a>
) : (
<Link href={href} className={cls}>
{children}
</Link>
);
}
-40
View File
@@ -1,40 +0,0 @@
"use client";
import { useState } from "react";
import { initPaddle } from "@/lib/paddle";
/* Opens the Paddle overlay with the resolved line items and custom_data. The
* items come from the configurator via catalogue pricing; custom_data is what
* lets the webhook route without a lookup table. */
export function CheckoutButton({
items,
customData,
disabled,
label = "Continue to payment",
}: {
items: { priceId: string; quantity: number }[];
customData: { account_id: string; instance_id: string };
disabled?: boolean;
label?: string;
}) {
const [busy, setBusy] = useState(false);
async function open() {
setBusy(true);
const paddle = await initPaddle();
setBusy(false);
paddle?.Checkout.open({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
customData,
});
}
return (
<button
type="button"
disabled={disabled || busy || items.length === 0}
onClick={open}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
{busy ? "Opening…" : label}
</button>
);
}
@@ -1,59 +0,0 @@
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 }[] = [];
const fields = ["max_servers", "max_monitors", "max_secret_groups", "max_channels", "audit_retention_days"] as const;
for (const f of fields) {
if (plan.base_limits[f] !== next.base_limits[f])
rows.push({
field: f,
was: limitLabel(plan.base_limits[f]),
now: limitLabel(next.base_limits[f]),
});
}
if (plan.support_level !== next.support_level)
rows.push({
field: "support_level",
was: plan.support_level || "none",
now: next.support_level || "none",
});
if (plan.base_features.join(",") !== next.base_features.join(","))
rows.push({
field: "features",
was: plan.base_features.join(", ") || "none",
now: next.base_features.join(", ") || "none",
});
return (
<div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
<h2 className="text-xl">Change what {plan.name} grants?</h2>
<ul className="grid gap-1 font-mono text-[0.82rem]">
{rows.map((r) => (
<li key={r.field} className="flex flex-wrap gap-2">
<span className="text-ink-3">{r.field}</span>
<span className="text-ink-3 line-through">{r.was}</span>
<span className="font-semibold text-ink"> {r.now}</span>
</li>
))}
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
</ul>
<p className="text-[0.82rem] text-ink-3">This applies to licences issued from now on. The {issuedCount} licences already issued keep what they were signed with until each is reissued.</p>
<div className="flex flex-wrap gap-3">
<Button type="button" onClick={onConfirm}>
Change plan
</Button>
<Button type="button" variant="line" onClick={onCancel}>
Keep as is
</Button>
</div>
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
/*
* Sandbox is hatched as well as coloured, so it survives a colourblind reader
* and a glance. It sits in the same place on every screen: issuing against the
* wrong environment should feel wrong before you click.
*/
const ENV = process.env.NEXT_PUBLIC_ADMIN_ENV === "sandbox" ? "sandbox" : "production";
export function EnvBadge() {
const sandbox = ENV === "sandbox";
return (
<span
className={
sandbox
? "inline-flex items-center gap-2 rounded-sm border border-warn px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink [background-image:repeating-linear-gradient(-45deg,var(--accent-wash)_0_6px,transparent_6px_12px)]"
: "inline-flex items-center gap-2 rounded-sm bg-accent px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-accent-ink"
}
>
<i className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" />
{sandbox ? "Sandbox" : "Production"}
</span>
);
}
-27
View File
@@ -1,27 +0,0 @@
import { controlClass } from "./Button";
export function Field({
label,
hint,
error,
className,
...input
}: React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
hint?: React.ReactNode;
error?: string;
}) {
return (
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
{/*
* className is pulled out of the spread rather than left in it: it
* used to be spread onto the input and then overwritten by the
* hardcoded one below, so a caller passing className got nothing and
* no warning.
*/}
<input {...input} className={controlClass(className)} aria-invalid={error ? true : undefined} />
{error ? <span className="text-[0.82rem] text-expired">{error}</span> : hint ? <span className="text-[0.82rem] text-ink-3">{hint}</span> : null}
</label>
);
}
-194
View File
@@ -1,194 +0,0 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { TermBar } from "./TermBar";
import { Button, LinkButton } from "./Button";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
const KEY = (id: string) => `vantage-hq-record-open:${id}`;
/*
* One instance, open or closed.
*
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
* licence includes, who can sign in, and the actions. Deliberately ONE component
* rather than a card and a detail panel: two components meant a single-instance
* account got a third of a row of summary with its substance a click away, and
* a six-instance account got a grid of summaries with no way to look closer.
*
* It defaults open when it is the only instance or when it needs attention,
* because the thing that needs you is the thing that should be open. A manual
* toggle is remembered per instance and beats the default from then on.
*/
export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen = false }: { instance: Instance; license?: License; reapAfterDays?: number; defaultOpen?: boolean }) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
const saved = localStorage.getItem(KEY(instance.instance_id));
if (saved !== null) setOpen(saved === "1");
}, [instance.instance_id]);
const toggle = () => {
setOpen((v) => {
localStorage.setItem(KEY(instance.instance_id), v ? "0" : "1");
return !v;
});
};
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
// Only fetched once the record is open, and only for cloud: a self-hosted
// install manages its own users and the endpoint refuses it.
const members = useQuery({
queryKey: ["members", instance.instance_id],
queryFn: () => api.members(instance.instance_id),
enabled: open && cloud,
});
const panelId = `record-${instance.instance_id}`;
return (
<article className={clsx("relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5", "before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", STRIPE[state])}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[1.22rem]">{instance.name || "Unnamed instance"}</h2>
<p className="mt-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
{` · created ${formatDate(instance.created_at)}`}
</p>
{cloud && instance.slug && (
<a href={`https://${instance.slug}.vantage.hostxtra.co.uk`} className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline">
{instance.slug}.vantage.hostxtra.co.uk &rarr;
</a>
)}
</div>
<div className="flex shrink-0 items-center gap-2.5">
<StatePill state={state} />
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? "Hide details" : "Show details"}
className="grid h-[26px] w-[26px] place-items-center rounded-sm border border-rule bg-panel text-[0.6rem] text-ink-3 hover:border-accent hover:text-accent"
>
<span aria-hidden className={clsx("block transition-transform", open && "rotate-180")}>
</span>
</button>
</div>
</div>
{/* The term is drawn for an expired licence too. The old bar hid
itself once it lapsed, which removed the measurement at exactly
the moment it started mattering. */}
{license && <TermBar issuedAt={license.issued_at} expiresAt={license.expires_at} state={state} className="max-w-md" />}
{state === "expired" && (
<div className="grid gap-1">
<p className="text-[0.82rem] text-ink-2">Servers and monitors are still running, and your agents keep their keys. Changes are disabled until you renew.</p>
{deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0 ? "Scheduled for deletion." : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
</div>
)}
{state === "none" && <p className="text-[0.82rem] text-ink-2">You have paid for this but it is not attached to an install yet, so no licence has been issued. Linking takes a minute.</p>}
<div id={panelId} className={clsx("gap-3.5", open ? "grid" : "hidden")}>
{license && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Included in {instance.tier?.replace("_", " ") ?? "this licence"}</p>
<div className="flex flex-wrap gap-x-7 gap-y-2.5">
<Stat n={limitLabel(license.limits.max_servers)} label="Servers" />
<Stat n={limitLabel(license.limits.max_secret_groups)} label="Secret groups" />
<Stat n={limitLabel(license.limits.max_channels)} label="Channels" />
<Stat n={license.features.length ? license.features.join(" · ") : "None"} label="Features" quiet={license.features.length === 0} />
</div>
</div>
)}
{cloud && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Who can sign in</p>
<div className="flex flex-wrap items-center gap-2">
{(members.data ?? []).map((m) => (
<span key={m.member_id} className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2">
<span className="grid h-[18px] w-[18px] place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
{m.email}
</span>
))}
{members.isLoading && <span className="text-[0.82rem] text-ink-3">Loading</span>}
{members.data?.length === 0 && <span className="text-[0.82rem] text-ink-3">Nobody yet.</span>}
<Link href={`/instances/${instance.instance_id}`} className="text-[0.82rem] font-semibold text-accent underline">
Manage access
</Link>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
// Every unlicensed instance is answered from the purchase
// page — self-hosted Free and paid both start there, and
// both name the install's own UUID.
<LinkButton href="/purchase">Get a licence</LinkButton>
) : cloud && instance.slug ? (
<>
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
Open Cloud Instance
</LinkButton>
<LinkButton variant="line" href={`/instances/${instance.instance_id}`}>
View Instance Settings
</LinkButton>
</>
) : (
<LinkButton href={`/instances/${instance.instance_id}`}>View Instance Settings</LinkButton>
)}
{canRenew && (
<Button type="button" variant="line" onClick={() => renew.mutate()} disabled={renew.isPending}>
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
</div>
</div>
</article>
);
}
function Stat({ n, label, quiet }: { n: string; label: string; quiet?: boolean }) {
return (
<div className="grid gap-px">
<b className={clsx("tabular-nums tracking-[-0.02em]", quiet ? "text-[0.95rem] font-semibold text-ink-3" : "text-[1.18rem] font-extrabold")}>{n}</b>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
</div>
);
}
-59
View File
@@ -1,59 +0,0 @@
import clsx from "clsx";
import type { License } from "@/lib/api";
import { formatDate, formatStamp, limitLabel } from "@/lib/format";
const REASON: Record<License["reason"], string> = {
new: "New",
renewal: "Renewal",
tier_change: "Tier change",
relink: "Relink",
manual: "Manual",
};
/*
* Licences are append-only: a renewal supersedes its predecessor rather than
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
* and are overprinted the way a cancelled instrument is hiding them would
* destroy the only record of why an instance stopped working on a given date.
*/
export function Ledger({ licenses }: { licenses: License[] }) {
if (licenses.length === 0) {
return <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
}
return (
<ul className="grid">
{licenses.map((l) => {
const dead = Boolean(l.superseded_by);
return (
<li key={l.license_id} className={clsx("grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]", dead && "text-ink-3")}>
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
<b className={clsx("block text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>{formatDate(l.issued_at)}</b>
{formatStamp(l.issued_at)}
</div>
<div className="grid justify-items-start gap-1.5">
{dead && (
<span className="-rotate-2 rounded-sm border-2 border-archival px-1.5 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.18em] text-archival opacity-75">
Superseded
</span>
)}
<p className="flex flex-wrap items-center gap-2 font-semibold">
{l.tier.replace("_", " ")}
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">{REASON[l.reason]}</span>
</p>
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} · {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
{l.superseded_by && (
<>
{" "}
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
</>
)}
</p>
</div>
</li>
);
})}
</ul>
);
}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useState } from "react";
import { Panel } from "./Panel";
/*
* 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.
* That is also why it is never collapsed behind a toggle: someone whose
* clipboard and download are both blocked has to be able to select it by hand.
*
* It is evidence rather than content, so it is set in a well with a keyed strip
* saying what it is and how much of it there is, and given a fixed height. It
* used to run to 250px of base64 and was the largest thing on the page, which
* is a strange amount of room to give a string nobody reads.
*
* The download lives in the page header beside Renew, not here it was in both
* places, which is one button too many for one file.
*/
export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(blob);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard is refused without a secure context or a gesture the
// browser trusts. The blob is on screen and selectable either way,
// so this needs no error state.
}
}
const steps = [
<>
Open <Code>Settings Licence</Code> on your install.
</>,
<>Paste the licence into the box and save.</>,
<>
The page reports <Code>Valid</Code> straight away no restart.
</>,
];
return (
<Panel title="Your licence" meta="Paste into your install">
<div className="grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Licence key</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">{blob.length.toLocaleString()} characters</span>
</div>
<div className="relative">
{/* Dashed, because this is data to be carried somewhere else
rather than a surface to read. */}
<pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 pr-24 font-mono text-[0.7rem] leading-relaxed text-ink-2">
{blob}
</pre>
<button
type="button"
onClick={copy}
className="absolute right-2 top-2 rounded border border-rule bg-panel px-2.5 py-1 font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-2 hover:border-accent hover:text-accent"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
{/* Numbered because this is an actual sequence each step is only
possible once the one before it is done. */}
<ol className="grid gap-2">
{steps.map((body, i) => (
<li key={i} className="grid grid-cols-[1.5rem_1fr] items-start gap-3 text-[0.84rem] text-ink-2">
<span className="grid h-[1.4rem] place-items-center rounded-sm border border-rule font-mono text-[0.68rem] text-accent">{i + 1}</span>
<span className="leading-[1.4rem]">{body}</span>
</li>
))}
</ol>
</Panel>
);
}
function Code({ children }: { children: React.ReactNode }) {
return <code className="rounded-sm bg-accent-wash px-1 font-mono text-[0.8rem] text-ink">{children}</code>;
}
@@ -1,35 +0,0 @@
"use client";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
/* Opens Paddle's hosted customer portal in a new tab. The account learns its
* paddle_customer_id from its first paid subscription's webhook, so this reports
* a plain message rather than erroring when there is no billing account yet. */
export function ManageBillingButton() {
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
async function open() {
setBusy(true);
setNote(null);
try {
const { url } = await api.billingPortal();
window.open(url, "_blank", "noopener");
} catch (e) {
setNote(e instanceof ApiError ? e.message : "Could not open billing.");
} finally {
setBusy(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<Button type="button" variant="line" onClick={open} disabled={busy}>
{busy ? "Opening…" : "Manage billing"}
</Button>
{note && <span className="text-[0.78rem] text-ink-3">{note}</span>}
</span>
);
}
-249
View File
@@ -1,249 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type InstanceRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { Button, controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
/*
* What each rank actually lets someone do, in the instance rather than in the
* portal. The select used to offer three words with no statement of what they
* bought which is a permissions control that declines to explain permissions.
*/
const ROLE_GRANTS: Record<InstanceRole, string> = {
owner: "Everything, including billing and deleting the instance.",
admin: "Manage servers, workflows, secrets and settings.",
member: "Use the instance. Cannot change settings or members.",
};
const SELECT_QUIET =
"rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none";
/* Same height as the Grant access button beside it — see controlClass. */
const SELECT = controlClass("bg-panel");
/*
* The access roster for one instance.
*
* Absent entirely for self-hosted instances the backend refuses those, and a
* panel that renders controls the server will reject is a panel that lies.
*
* The row is a monogram and an address set in mono, because in this product an
* identity IS an address, and every other identifier on the screen the
* instance UUID, the licence reference is mono too. The role is a fact most
* of the time and a control occasionally, so it is drawn as text and only grows
* a border on hover or focus: the old row made the dropdown the loudest thing
* in it, which is backwards for a list people mostly read.
*
* Granting sits in its own strip on --panel-2 rather than as a fourth row of
* naked controls, so the roster reads as the record and the strip as the action.
*/
export function MembersPanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient();
const { session } = useSession();
const [selected, setSelected] = useState("");
const [role, setRole] = useState<InstanceRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const members = useQuery({
queryKey: ["members", instanceId],
queryFn: () => api.members(instanceId),
});
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const grant = useMutation({
mutationFn: () => api.grantMember(instanceId, selected, role),
onSuccess: () => {
setSelected("");
setRole("member");
refresh();
},
onError: fail,
});
const changeRole = useMutation({
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
onSuccess: refresh,
onError: fail,
});
const revoke = useMutation({
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
onSuccess: () => {
setConfirming(null);
refresh();
},
onError: (e) => {
setConfirming(null);
fail(e);
},
});
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const rows = members.data ?? [];
const granted = new Set(rows.map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
return (
<Panel title="Who can sign in" meta={rows.length ? `${rows.length} ${rows.length === 1 ? "person" : "people"}` : undefined} bodyless>
<div className="grid gap-3 px-4 pb-4 pt-3.5">
<p className="text-[0.84rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
{error && (
<p role="alert" className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
{error}
</p>
)}
</div>
{rows.length === 0 ? (
<EmptyState
title="Nobody else can sign in yet."
body={canManage ? "Add someone from your account below and a user is created for them inside this instance." : "An owner or admin can grant access."}
/>
) : (
<ul className="grid border-t border-rule-soft">
{/*
* Two columns on a phone monogram and address with the
* controls dropping to their own full-width row beneath;
* three columns from sm up, controls right-aligned. As one
* wrapping flex row the address competed with a select and
* two buttons for 320px and lost, and the confirm step put
* three more elements into the same row.
*/}
{rows.map((m) => (
<li
key={m.member_id}
className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-rule-soft px-4 py-3 last:border-b-0 sm:grid-cols-[auto_1fr_auto]"
>
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.62rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0 break-all font-mono text-[0.84rem] sm:truncate sm:break-normal">{m.email}</span>
<div className="col-span-2 flex flex-wrap items-center gap-2 sm:col-span-1 sm:flex-nowrap sm:justify-end">
{canManage ? (
<label className="shrink-0">
<span className="sr-only">Role for {m.email}</span>
<select
value={m.role}
title={ROLE_GRANTS[m.role]}
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
className={SELECT_QUIET}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
) : (
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
)}
{canManage &&
/*
* Confirming inline rather than through
* window.confirm(), and in the row itself rather
* than a dialog: it can say what revoking does,
* where the eye already is.
*/
(confirming === m.customer_user_id ? (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
<button
type="button"
className="rounded border border-expired px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-expired hover:bg-expired hover:text-panel disabled:opacity-50"
disabled={revoke.isPending}
onClick={() => revoke.mutate(m.customer_user_id)}
>
{revoke.isPending ? "Revoking…" : "Revoke"}
</button>
<button type="button" className="font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:text-ink" onClick={() => setConfirming(null)}>
Keep
</button>
</span>
) : (
/* Quiet until intent: a row that is mostly read
should not carry a permanently red control. */
<button
type="button"
className="shrink-0 rounded border border-transparent px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:border-expired hover:text-expired"
onClick={() => {
setError(null);
setConfirming(m.customer_user_id);
}}
>
Revoke<span className="sr-only"> access for {m.email}</span>
</button>
))}
</div>
</li>
))}
</ul>
)}
{canManage && (
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
{/* Stacked and full width on a phone; one row from sm up.
Three controls side by side left the person select about
90px wide, which is not enough to read an address in. */}
<form
className="grid gap-3 sm:flex sm:flex-wrap sm:items-end"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (selected) grant.mutate();
}}
>
<label className="grid min-w-0 gap-1.5 sm:flex-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Grant access to</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className={SELECT} disabled={candidates.length === 0}>
<option value="">{candidates.length === 0 ? "Everyone already has access" : "Choose a person…"}</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
{p.email}
</option>
))}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">As</span>
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className={SELECT}>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={!selected || grant.isPending} className="w-full justify-center sm:w-auto">
{grant.isPending ? "Granting…" : "Grant access"}
</Button>
</form>
{/* The chosen rank explains itself, rather than leaving three
words to be guessed at. */}
<p className="text-[0.8rem] text-ink-3">
<span className="font-mono uppercase tracking-[0.08em]">{role}</span> {ROLE_GRANTS[role]}
</p>
{pending > 0 && (
<p className="text-[0.8rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet, and cannot be granted access until they do.
</p>
)}
</div>
)}
</Panel>
);
}
-25
View File
@@ -1,25 +0,0 @@
/*
* The deployment failure this repo makes most often, made legible. It names the
* variable, the value baked in, and both reasons it fails unreachable from
* the browser, or missing from admin's ADMIN_ORIGIN.
*/
export function NotConnectedPanel({ url }: { url: string }) {
return (
<div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
{url ? (
<p className="text-ink-2">
This build points at <code className="text-ink">ADMIN_API_URL</code> = <code className="text-ink">{url}</code>, which did not respond.
</p>
) : (
<p className="text-ink-2">
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was built, so there is nowhere to send requests.
</p>
)}
<p className="text-[0.82rem] text-ink-3">
The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service&rsquo;s{" "}
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
</p>
</div>
);
}
-49
View File
@@ -1,49 +0,0 @@
/*
* Main column plus a fixed support rail.
*
* The rail is what stops a page being empty and the main column is what stops
* it being thin: an account with one instance used to render a third of a row
* of summary and nothing else. The rail carries what is true regardless of how
* many instances exist, so the page has a floor.
*
* It collapses below lg in source order, which puts the main column first on a
* phone. Nothing is hidden at any width if content only fits on a desktop it
* does not belong in the rail.
*/
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
if (!aside) return <div className="grid gap-5">{children}</div>;
return (
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-w-0 gap-4">{children}</div>
<aside className="grid gap-3.5">{aside}</aside>
</div>
);
}
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
export function RailCard({ title, count, children }: { title: string; count?: number | string; children: React.ReactNode }) {
return (
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
<header className="flex items-baseline justify-between gap-2.5">
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">{title}</h2>
{count !== undefined && <b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>}
</header>
{children}
</section>
);
}
/** Key/value rows for the rail. Values are mono so numbers line up. */
export function RailFacts({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
return (
<dl className="grid gap-1.5">
{rows.map((r) => (
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
<dt className="text-ink-3">{r.label}</dt>
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
</div>
))}
</dl>
);
}
-96
View File
@@ -1,96 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
/*
* One record-line entry. `copy` marks the value as worth lifting to the
* clipboard an instance UUID or a licence ID, the strings people paste into
* support tickets.
*/
export type RecordField = { key: string; value: string; copy?: boolean };
function CopyButton({ value }: { value: string }) {
const [done, setDone] = useState(false);
return (
<button
type="button"
// Never the thing that wraps: it is 5 characters and the value
// beside it may be 36.
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
// Clipboard is refused without a secure context or a user
// gesture the browser trusts. The value is on screen and
// selectable either way, so this needs no error state.
}
}}
className="shrink-0 rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
>
{done ? "Copied" : "Copy"}
</button>
);
}
/*
* The page frame every screen starts with, replacing nine hand-rolled header
* blocks that each picked their own gaps and their own place for actions.
*
* The record line is the one new idea: Vantage HQ is a registry, so every screen
* is a record and records have reference numbers. Giving the reference a fixed
* slot, in mono, above the fold, means "where is the ID" stops being a per-page
* question. It costs one hairline rule.
*/
export function PageHeader({
back,
title,
subtitle,
actions,
record,
status,
}: {
back?: { href: string; label: string };
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
record?: RecordField[];
status?: React.ReactNode;
}) {
return (
<header className="grid gap-3">
{back && (
<Link href={back.href} className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent">
&larr; {back.label}
</Link>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[1.9rem]">{title}</h1>
{subtitle && <p className="mt-1 text-[0.92rem] text-ink-2">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{(record?.length || status) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
{record?.map((f) => (
// min-w-0 and break-all because the commonest value here
// is a 36-character UUID with a Copy button beside it,
// which does not fit a 320px screen as one unbreakable
// token and pushed the whole page sideways.
<span key={f.key} className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
<span className="min-w-0 break-all font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
{status && <span className="ml-auto">{status}</span>}
</div>
)}
</header>
);
}
-91
View File
@@ -1,91 +0,0 @@
import clsx from "clsx";
/*
* The surface every screen is built from.
*
* Before this there were four panel treatments in the app: `rounded border
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
* with no border at all, and a table wrapper that was a panel in everything but
* name. They were all trying to be the same object.
*
* The header is title-left, meta-right. Meta is the keyed idiom mono, small,
* tracked, dimmed because it is always a count, a scope or an identifier,
* never prose.
*/
export function Panel({
title,
meta,
actions,
tone,
children,
bodyless,
className,
}: {
title?: string;
meta?: React.ReactNode;
actions?: React.ReactNode;
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
tone?: "warn" | "expired";
children: React.ReactNode;
/** Skip the padded body — for a panel whose content is a full-bleed table. */
bodyless?: boolean;
className?: string;
}) {
const head = title || meta || actions;
return (
<section
className={clsx(
"grid overflow-hidden rounded border bg-panel",
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
className,
)}
>
{head && (
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
<div className="flex items-center gap-3">
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
{actions}
</div>
</header>
)}
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
</section>
);
}
/*
* An aside that is part of the argument rather than beside it: the consequence
* of the action on screen, or the constraint the reader is about to hit. The
* left rule carries the tone, so the note reads as annotation and never as a
* second panel competing with the one it sits in.
*/
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
return (
<p
className={clsx(
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
)}
>
{children}
</p>
);
}
/*
* An empty screen is an invitation to act. Every one of these says what the
* thing is before offering to make one "No licences match those filters" on
* its own tells someone the filter worked, not what to do about it.
*/
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
<p className="text-[1rem] font-bold">{title}</p>
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
-174
View File
@@ -1,174 +0,0 @@
"use client";
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
import { featureLabel } from "@/lib/features";
export interface PlanChoice {
tier: Tier;
term: Term;
servers: number;
features: string[];
}
/* Self-hosted sells annual only. The reason is in shared/license: an offline
* licence cannot be revoked, so the term length IS the revocation window. */
function termsFor(deployment: Deployment): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
/*
* PlanConfigurator is the whole of "what is this instance allowed", driven
* entirely by the plans and catalogue it is handed.
*
* A feature appears because a catalogue row offers it, and shows a price because
* that row has one. Nothing here is hardcoded per tier, which is what lets a new
* paid add-on ship as a staff edit rather than a frontend release.
*
* It saves nothing and knows nothing about who is using it. Staff mount it to
* set an entitlement; the customer purchase flow mounts the same component and
* hands it a checkout.
*/
export default function PlanConfigurator({
deployment,
value,
plans,
catalogue,
onChange,
disabled,
}: {
deployment: Deployment;
value: PlanChoice;
plans: Plan[];
catalogue: CatalogueRow[];
onChange: (next: PlanChoice) => void;
disabled?: boolean;
}) {
const available = useMemo(
() => plans.filter((p) => p.deployment === deployment && p.active),
[plans, deployment],
);
const plan = available.find((p) => p.tier === value.tier);
const rows = useMemo(
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
[catalogue, deployment, value.tier],
);
const featureRows = rows.filter((r) => r.kind === "feature");
const base = plan?.base_limits.max_servers ?? 0;
const extra = Math.max(0, value.servers - base);
const priceOf = (r: CatalogueRow) =>
r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? "";
return (
<div className="space-y-4">
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Tier</legend>
<div className="flex flex-wrap gap-2">
{available.map((p) => (
<button
key={p.tier}
type="button"
disabled={disabled}
onClick={() =>
onChange({
...value,
tier: p.tier,
/* Moving tier moves the floor, so clamp up
* rather than leaving an invalid count the
* backend would refuse. */
servers: Math.max(value.servers, p.base_limits.max_servers),
})
}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
p.tier === value.tier
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{p.name}
</button>
))}
</div>
</fieldset>
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Term</legend>
<div className="flex flex-wrap gap-2">
{termsFor(deployment).map((t) => (
<button
key={t}
type="button"
disabled={disabled}
onClick={() => onChange({ ...value, term: t })}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
t === value.term
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{t === "monthly" ? "Monthly" : "Annual"}
</button>
))}
</div>
{deployment === "self_hosted" && (
<p className="text-[0.72rem] text-ink-3">
Self-hosted is annual only.
</p>
)}
</fieldset>
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
<input
type="number"
min={base}
value={value.servers}
disabled={disabled}
onChange={(e) =>
onChange({ ...value, servers: Number(e.target.value) })
}
className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="ml-2 text-[0.78rem] text-ink-3">
{base} included{extra > 0 ? `, ${extra} extra` : ""}
</span>
</label>
{featureRows.length > 0 && (
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Features</legend>
{featureRows.map((r) => {
const key = r.feature_key!;
const on = value.features.includes(key);
const priced = priceOf(r) !== "";
return (
<label
key={key}
className="flex items-center gap-2 text-[0.85rem] text-ink-2"
>
<input
type="checkbox"
checked={on}
disabled={disabled}
onChange={(e) =>
onChange({
...value,
features: e.target.checked
? [...value.features, key]
: value.features.filter((f) => f !== key),
})
}
/>
<span>{featureLabel(key)}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
</label>
);
})}
</fieldset>
)}
</div>
);
}
-8
View File
@@ -1,8 +0,0 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
-58
View File
@@ -1,58 +0,0 @@
import Link from "next/link";
import clsx from "clsx";
import type { ReactNode } from "react";
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;
/* `meta` is a node rather than a string so a queue about time can carry the
* term measurement itself. A tier name told the reader what the instance
* was; the queue is sorted by how soon it lapses, and that was the one
* figure the row did not show. */
items: { label: string; href: string; meta: ReactNode }[];
}) {
return (
<section
className={clsx(
"grid gap-2 rounded border border-l-4 border-rule bg-panel p-4",
TONE[tone],
)}
>
<h2 className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.1em] text-ink-3">
{title}
</h2>
<p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">
{count}
</p>
{items.length === 0 ? (
<p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
) : (
<ul className="grid gap-1">
{items.map((i) => (
<li
key={i.href}
className="flex items-center justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
>
<Link href={i.href} className="truncate text-accent underline">
{i.label}
</Link>
<span className="shrink-0 tabular-nums">{i.meta}</span>
</li>
))}
</ul>
)}
</section>
);
}
-49
View File
@@ -1,49 +0,0 @@
"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;
/*
* The relink control, and only the control.
*
* It used to carry its own heading and its own "N of M relinks left this term"
* line. It now sits inside the Moves panel, which already says both a panel
* titled Moves with "2 of 3 used" in its header, wrapping a section headed
* "Moved to a new server?" that says "1 of 3 relinks left", is the same fact
* told twice in two different directions.
*
* The exhausted case still lives here rather than in the caller: it is the
* reason the button is disabled, so it belongs beside the button.
*/
export function RelinkPanel({ 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 (
<div className="grid gap-3">
{open && !exhausted && (
<Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
Move to another install
</Button>
{exhausted ? (
<span className="text-[0.82rem] text-ink-3">You have used every move for this term contact support and we will sort it out.</span>
) : (
open && <span className="text-[0.82rem] text-ink-3">Relinking issues a replacement licence covering the rest of your current term.</span>
)}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More