Compare commits

..
89 Commits
Author SHA1 Message Date
mrhid6 17d97aaf52 feat: More logging for command stream
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m24s
Agent Release / build (push) Successful in 10m37s
Agent Release / msi (push) Successful in 36s
2026-07-31 17:20:35 +01:00
mrhid6 1fb9bd827f feat: Added ping command
Chart Release / chart (push) Successful in 18s
Agent Release / build (push) Successful in 39s
Server Deploy / deploy (push) Successful in 55s
Agent Release / msi (push) Successful in 40s
2026-07-31 17:10:59 +01:00
mrhid6 8699dc5b7e fix: Renew presence on sub/pub
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 56s
2026-07-31 16:58:50 +01:00
mrhid6 71240f183c fix: Fixes to server shutdown stream
Chart Release / chart (push) Successful in 21s
Server Deploy / deploy (push) Successful in 1m2s
Agent Release / build (push) Successful in 43s
Agent Release / msi (push) Successful in 49s
2026-07-31 16:44:47 +01:00
mrhid6 01e8b0ba44 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m25s
2026-07-31 16:31:19 +01:00
mrhid6 2aa4784518 feat: Better debugging for console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 59s
2026-07-31 16:13:51 +01:00
mrhid6 f611cae438 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 16:00:58 +01:00
mrhid6 1eb98ef962 feat: Better debugging for console
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 15:51:26 +01:00
mrhid6 6f86496f10 fix: Ffixes to console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m9s
2026-07-31 15:05:21 +01:00
mrhid6 57a9b18102 fix: Guacd connection ip
Server Deploy / deploy (push) Successful in 9s
Chart Release / chart (push) Successful in 11s
2026-07-31 14:52:20 +01:00
mrhid6 36995fa62b fix: Fixed install and update scripts
Chart Release / chart (push) Successful in 9s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 12:10:32 +01:00
mrhid6 9121fc461f fix: Fixed chart api routes for update
Server Deploy / deploy (push) Successful in 15s
Chart Release / chart (push) Successful in 10s
2026-07-31 12:03:45 +01:00
mrhid6 fc56bae5f9 chore: Bump chart version
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 8s
Agent Release / build (push) Successful in 38s
Agent Release / msi (push) Successful in 57s
2026-07-31 11:53:08 +01:00
mrhid6 ac75b3ef76 feat: chart deployment Type added
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 35s
2026-07-31 11:52:31 +01:00
mrhid6 e6fe463216 feat: Updated for api ingress routes
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 4m26s
2026-07-31 11:21:15 +01:00
mrhid6 8528f14ed7 feat: Added ingress to chart
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m24s
2026-07-31 10:49:20 +01:00
mrhid6 df1d9658f5 fix: Chart build
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 10:41:41 +01:00
mrhid6 9f9b384481 fix: Fixed chart version
Chart Release / chart (push) Failing after 13s
2026-07-31 10:37:04 +01:00
mrhid6 165114471f fix: Fixes to running on kubernetes
Chart Release / chart (push) Failing after 13s
Server Deploy / deploy (push) Successful in 6m35s
2026-07-31 10:34:10 +01:00
mrhid6 de78688093 feat: authenticate the server's Redis connection
InitRedis now takes a username and password, read from REDIS_USERNAME and
REDIS_PASSWORD, matching what admin has always done. Both empty keeps an
unauthenticated Redis working; a password with an empty username is what a
legacy requirepass instance needs, since go-redis then sends AUTH with one
argument instead of two.

This is what lets a Kubernetes install point at a managed Redis instead of
the bundled one.
2026-07-31 09:36:31 +01:00
mrhid6 bbf9f72fd3 feat: Docker and helm charts
Server Deploy / deploy (push) Successful in 5m26s
Agent Release / build (push) Successful in 10m45s
Agent Release / msi (push) Successful in 1m31s
2026-07-31 09:28:54 +01:00
mrhid6 978b665aa6 fix: stop local relay teardown from logging a spurious proxy_failed reason
Session.Close now closing its own accepted conn (from the prior fix wave)
made net.ErrClosed on the guacd-side reader indistinguishable from a real
remote failure, so a normal browser-tab close could race the handler's
defer and intermittently log console.proxy_failed on a healthy session.
Add a closing flag, set before Close's sync.Once body actually tears
anything down, that setReason respects -- a deliberate local teardown can
no longer produce or race in a failure reason, while Close's own explicit
reason argument still wins normally.
2026-07-31 09:25:27 +01:00
mrhid6 1fe608f531 fix: bound and complete console relay teardown, restore proxy_failed audit
- Arm the unclaimed-relay watchdog in NewSession rather than Serve, so an
  agent that never opens its ProxyStream is bounded to 10s and reports
  reason "agent_timeout", per the design spec's failure-mode table.
- Session.Close now also closes the accepted net.Conn (stored via setConn),
  so ConsoleProxy.Close() is an unconditional kill of the whole relay chain
  instead of only closing an already-idle listener.
- Emit console.proxy_failed and end the console session from a defer in
  consoleTunnel guarded on relay.Reason(), since guac's OnDisconnect never
  runs when the connect callback errors -- which is the path every relay
  failure this feature introduces takes. Update the two docsite
  troubleshooting rows to match what the audit event can now actually show.
2026-07-31 09:21:07 +01:00
mrhid6 1e1546cb60 docs: document the agent-relayed console proxy
Every console session now rides the agent's outbound gRPC connection
instead of a direct guacd-to-target dial, so it works for servers
behind NAT and now requires a live agent (409 agent_offline
otherwise). Documents PROXY_ADVERTISE_HOST / PROXY_LISTEN_HOST and
corrects reachability claims across the docsite and CLAUDE.md.
2026-07-31 09:10:05 +01:00
mrhid6 119d8694d1 feat: Reap admin free instance license 2026-07-30 14:42:06 +01:00
mrhid6 8d43c689f5 feat: Reap admin free instance 2026-07-30 14:31:43 +01:00
mrhid6 05f10ed3c9 feat: record relay proxy_id and port in console audit events 2026-07-29 13:10:58 +01:00
mrhid6 c0bec3737b feat: route every console session through the agent relay 2026-07-29 13:07:30 +01:00
mrhid6 59d147fe4d feat: handle OpenProxyCmd in the agent command stream 2026-07-29 13:03:02 +01:00
mrhid6 9e38a01e3d feat: add agent-side console relay 2026-07-29 12:59:24 +01:00
mrhid6 20a302f84a feat: add OpenConsoleProxy service facade 2026-07-29 12:55:36 +01:00
mrhid6 ba2e263d00 fix: collapse ProxyStream auth failures into one indistinguishable response 2026-07-29 12:52:53 +01:00
mrhid6 a000703199 feat: add ProxyStream handler with scoped single-use auth 2026-07-29 12:50:20 +01:00
mrhid6 8fcda63742 fix: avoid closing proxy relay listener before validating remote source 2026-07-29 12:47:35 +01:00
mrhid6 3363ac9dad feat: add console proxy session relay 2026-07-29 12:43:04 +01:00
mrhid6 a7e338b171 feat: add console proxy session registry 2026-07-29 12:40:22 +01:00
mrhid6 bc79daab48 feat: add ProxyStream wire types for agent-relayed console 2026-07-29 12:37:37 +01:00
mrhid6 d3d8dba3ff docs: Implementation plan for agent-relayed console proxy 2026-07-29 12:26:16 +01:00
mrhid6 6d047e25ab docs: Design for agent-relayed console proxy 2026-07-29 12:16:45 +01:00
mrhid6 ed4c39650c feat: Removed hq signup page
Server Deploy / deploy (push) Successful in 1m44s
2026-07-29 10:42:51 +01:00
mrhid6 7b8fa4a8a0 feat: Updated hq login page
Server Deploy / deploy (push) Successful in 40s
2026-07-29 10:35:07 +01:00
mrhid6 8a02c35ec9 docs: Updated docs
Server Deploy / deploy (push) Successful in 55s
2026-07-28 16:54:39 +01:00
mrhid6 487de34a50 docs: Updated docs
Server Deploy / deploy (push) Failing after 1m57s
2026-07-28 16:53:42 +01:00
mrhid6 0424547dd4 docs: Updated docs 2026-07-28 16:53:38 +01:00
mrhid6 5be9ddb2e5 fix: Fixed baked api url
Server Deploy / deploy (push) Successful in 7m8s
2026-07-28 16:18:50 +01:00
mrhid6 bc6c7cdb5a feat: Updated docker file
Server Deploy / deploy (push) Successful in 2m35s
2026-07-28 16:04:51 +01:00
mrhid6 f46fb7fc0e feat: documentation site
Docusaurus 3 docs-only site at docsite/, served statically by nginx under
/docs on the marketing host. Covers getting started (self-hosted install
through first server and first licence), the control plane, Vantage HQ,
a reference section and operations.

Wired into docker-compose.site.yml as docsite (3005:80) and into the
image build workflow, rebuilding on its own directory only. Never added
to the self-hosted compose file.
2026-07-28 15:46:33 +01:00
mrhid6 d9945882e5 docs: serve the documentation site under /docs on the marketing host 2026-07-28 15:22:20 +01:00
mrhid6 9db16522e3 docs: design for the documentation site 2026-07-28 15:20:07 +01:00
mrhid6 6070972f9a chore: updated deps
Server Deploy / deploy (push) Successful in 36s
2026-07-28 15:07:19 +01:00
mrhid6 31e0000306 feat: default steps are read only
Server Deploy / deploy (push) Successful in 1m46s
2026-07-28 13:34:08 +01:00
mrhid6 26567766a9 feat: Move default steps into container 2026-07-28 13:28:43 +01:00
mrhid6 97dc6feaca feat: Tapo platform page
Server Deploy / deploy (push) Successful in 1m30s
2026-07-28 12:57:49 +01:00
mrhid6 f10e61cde4 feat: Updated marketing site 2026-07-28 12:46:28 +01:00
mrhid6 febe48a974 feat: Updated pricing 2026-07-28 12:31:00 +01:00
mrhid6 766dcabfde fix: Fixes to the console focus
Server Deploy / deploy (push) Successful in 43s
2026-07-28 12:02:48 +01:00
mrhid6 c9dab99271 feat: Windows agent inventory
Server Deploy / deploy (push) Successful in 9s
Agent Release / build (push) Successful in 1m39s
Agent Release / msi (push) Successful in 36s
2026-07-28 11:29:08 +01:00
mrhid6 60a4ed9aab fix: Fixed agent msi update command
Server Deploy / deploy (push) Successful in 16s
Agent Release / build (push) Successful in 10m40s
Agent Release / msi (push) Successful in 38s
2026-07-28 10:53:06 +01:00
mrhid6 f56fc2e54d fix: Fixed agent build
Server Deploy / deploy (push) Successful in 4m8s
Agent Release / build (push) Successful in 10m44s
Agent Release / msi (push) Successful in 1m43s
2026-07-28 10:30:29 +01:00
mrhid6 dabe6fe3aa feat: Updated package path to match repo
Agent Release / build (push) Failing after 25s
Agent Release / msi (push) Skipped
Server Deploy / deploy (push) Successful in 2m47s
2026-07-28 10:01:40 +01:00
mrhid6 a232c74990 feat: Move mail system to shared
Server Deploy / deploy (push) Successful in 2m46s
2026-07-28 09:50:46 +01:00
mrhid6 5e326335af fix(web): stop long tokens and button rows overflowing on mobile
Server Deploy / deploy (push) Successful in 1m40s
Three root causes behind five reported overflows:

- Td below sm is a flex row, and a flex item's default min-width:auto is
  its min-content width. A fingerprint or target URL is one unbreakable
  token, so the value could not shrink and spilled out of the card. The
  value now sits in a min-w-0 break-words wrapper that is display:contents
  above sm, leaving the desktop table untouched. Fixes /keys, /monitors
  and the installed-keys table on /servers/[id] in one place.

- The ESO read URL and the secrets group endpoint are unbreakable tokens
  in prose with the default overflow-wrap:normal. Both paragraphs now
  break-words.

- Three page headers (servers/[id], keys/[id], monitors/[id]) held an
  unwrapped button row. They stack below sm and the clusters wrap. These
  used items-start, which is why the earlier responsive sweep — matching
  items-center — skipped all three; only one was reported.

Verified: tsc clean, next build clean, and the compiled stylesheet confirms
max-sm:min-w-0 / max-sm:break-words land in the below-640px query and
sm:contents in the above-640px one.
2026-07-27 23:28:53 +01:00
mrhid6 14a11886ae feat(web): make the control plane usable on mobile
Server Deploy / deploy (push) Successful in 43s
Sidebar becomes a hamburger-driven offcanvas below lg; tables card-stack
below sm via the shared Td primitive's label prop; page padding, modals,
the workflow builder and the remaining fixed layouts all collapse.

Presentation only — no API, route or data-shape changes.

Spec:  docs/superpowers/specs/2026-07-27-web-mobile-responsive-design.md
Plan:  docs/superpowers/plans/2026-07-27-web-mobile-responsive.md
2026-07-27 23:15:25 +01:00
mrhid6 4be7d24aec feat(web): collapse remaining fixed layouts on small screens 2026-07-27 23:01:30 +01:00
mrhid6 dd4ce5bb3e fix(web): scope inspector top border to below lg 2026-07-27 22:57:53 +01:00
mrhid6 5ee3f14eed feat(web): single-column workflow builder below lg 2026-07-27 22:55:07 +01:00
mrhid6 5dcc1bf1be feat(web): bottom-sheet modals and collapsing component grids 2026-07-27 22:50:08 +01:00
mrhid6 facef270b7 feat(web): label table cells for the mobile card stack 2026-07-27 22:46:39 +01:00
mrhid6 e8c75e974d feat(web): responsive page padding and stacking page headers 2026-07-27 22:42:22 +01:00
mrhid6 60af88525c feat(web): offcanvas sidebar with hamburger below lg 2026-07-27 22:33:16 +01:00
mrhid6 74f6dca2f5 feat(web): card-stack tables below sm 2026-07-27 22:28:57 +01:00
mrhid6 effd991c31 docs: mobile responsive design spec and implementation plan for web/ 2026-07-27 22:24:26 +01:00
mrhid6 20e57d19c7 feat: Updated pricing page
Server Deploy / deploy (push) Successful in 2m46s
2026-07-27 22:08:17 +01:00
mrhid6 d5c8d0d0f2 fix: Fixed site mobile width 2026-07-27 21:58:20 +01:00
mrhid6 2869e63d0a feat: Move manage billing button to billing page 2026-07-27 21:47:50 +01:00
mrhid6 792b7eb211 feat: Updated instance record and license blob box
Server Deploy / deploy (push) Successful in 1m13s
2026-07-27 16:48:37 +01:00
mrhid6 8fd5eacec6 feat: Updated self hosted free plans
Server Deploy / deploy (push) Successful in 40s
2026-07-27 16:26:20 +01:00
mrhid6 66140aaf58 fixes
Server Deploy / deploy (push) Successful in 2m24s
2026-07-27 15:59:45 +01:00
mrhid6 fdfe8e8e46 fix: Fixed oidc purchase description
Server Deploy / deploy (push) Successful in 37s
2026-07-27 15:26:34 +01:00
mrhid6 4ad68e3ac4 feat: Updated purchase page
Server Deploy / deploy (push) Successful in 4m40s
2026-07-27 14:59:43 +01:00
mrhid6 0a86167c44 docs: updated docs
Server Deploy / deploy (push) Successful in 1m56s
2026-07-27 10:57:19 +01:00
mrhid6 3537ec59dc docs+deploy: Paddle env, webhook route, collections, CI build args, and spec status 2026-07-27 10:55:41 +01:00
mrhid6andClaude Opus 5 8bbecd2035 feat(adminsite): self-hosted purchase, checkout overlay, billing portal, client line-item builder
Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator
-> Paddle overlay with custom_data -> paste install UUID to link and issue.
lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included
in exactly one place). ManageBillingButton opens the hosted portal. Paddle token
and env are baked into the build, never fetched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:53:21 +01:00
mrhid6andClaude Opus 5 c10f093cad feat(admin): awaiting-link reminders, placeholder claim-and-issue, and billing health
sweepAwaitingLink chases paid-but-unlinked self-hosted placeholders at 24h/72h.
claimPlaceholderLink rewrites a placeholder's identity to the install's real
UUID, re-points the subscription, and issues from it — linking and claiming are
one call because the payment already happened. staffBillingHealth surfaces
failed webhook handlers and unlinked paid placeholders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:46:00 +01:00
mrhid6 28b138b4c3 feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints 2026-07-27 10:43:12 +01:00
mrhid6 01bb37125d feat(admin): renewals issue the next term and collapse a scheduled reduction; payment-failed flags only 2026-07-27 10:41:30 +01:00
mrhid6andClaude Opus 5 2e8114c41e feat(admin): subscription webhooks promote the entitlement and reissue; cancel and past-due take no licence action
created/updated/activated fold into 'make the world match current state', so
out-of-order delivery is correct by construction. A confirmed subscription
promotes desired->granted and signs from granted only. Cancel and past-due touch
only status; the licence runs to expiry. IssueForInstance backstops a
self-hosted placeholder that is linked after payment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:40:06 +01:00
mrhid6 6832bfd7bb feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch 2026-07-27 10:37:53 +01:00
mrhid6andClaude Opus 5 fbd93d0ea5 feat(admin): Paddle client behind an interface, config, and the event idempotency record
Client is a thin REST client (net/http) rather than the vendor SDK: the surface
we need is two calls, and a hand-rolled client has no version-drift risk and no
dependency in go.sum. All Paddle wire shapes live only in http.go.

PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are boot-required — an unverified
webhook endpoint is one anyone can issue licences through. paddle_events carries
a unique index on event_id for webhook idempotency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:36:26 +01:00
337 changed files with 41440 additions and 4680 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "C:/Python314/Scripts/graphify.EXE hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "C:/Python314/Scripts/graphify.EXE hook-guard read"
}
]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
graphify-out/graph.json merge=graphify
+221
View File
@@ -0,0 +1,221 @@
name: Chart Release
on:
# Every push that touches the chart is validated. Publishing is separate and
# deliberate: a chart version is immutable in the registry once pushed, so
# it must come from a tag someone chose, not from whatever landed on main.
# No `paths` filter on push, deliberately. A paths filter applies to tag
# pushes too, so tagging a commit that happened not to touch the chart
# would skip the publish entirely — a release that silently does nothing.
# Validation is seconds of helm rendering; running it on every push to main
# is cheaper than that failure mode.
push:
branches:
- main
tags:
- "chart/v*"
pull_request:
paths:
- "deploy/chart/**"
workflow_dispatch:
env:
CHART_DIR: deploy/chart/vantage
HELM_VERSION: v3.16.3
jobs:
chart:
runs-on: ubuntu-docker
container: alpine:3.21
steps:
# git for actions/checkout, curl for both the Helm download and the
# registry upload, tar because the Helm tarball is not self-extracting.
- name: Setup
run: apk add --no-cache bash curl git tar nodejs npm
- name: Install Helm
run: |
set -eu
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
| tar -xz -C /tmp linux-amd64/helm
mv /tmp/linux-amd64/helm /usr/local/bin/helm
helm version --short
- name: Checkout
uses: actions/checkout@v4
- name: Lint
run: helm lint "$CHART_DIR"
# Rendering is the real test. `helm lint` accepts a chart whose
# templates fail to execute, and every guard in this chart is a
# template `fail` that only fires during rendering.
- name: Render default values
run: helm template test "$CHART_DIR" > /dev/null
- name: Render a multi-replica install
run: |
helm template test "$CHART_DIR" \
--set server.replicaCount=3 \
--set web.replicaCount=3 > /dev/null
# The reaper deletes whole instances, so "does this env appear only
# in cloud mode" is worth asserting rather than eyeballing.
- name: Check the reaper is cloud-only
run: |
set -eu
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
exit 1
fi
if ! helm template test "$CHART_DIR" \
--set server.env.deploymentType=cloud \
| grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
exit 1
fi
echo "ok: reaper configured in cloud mode only"
- name: Render against external Redis and MongoDB
run: |
helm template test "$CHART_DIR" \
--set redis.enabled=false \
--set redis.addr=redis.example.com:6379 \
--set mongo.enabled=false \
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
- name: Render with the Traefik ingress
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.host=agents.example.com \
--set ingress.tls.certResolver=letsencrypt \
--set server.env.grpcHost=agents.example.com:443 > /dev/null
# The shape the cloud deployment actually uses: a wildcard tenant
# namespace, /api and /auth routed at the edge, and no apex — that
# belongs to the marketing site, which this chart does not deploy.
- name: Render a wildcard host with edge-routed API paths
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set 'ingress.web.host=*.vantage.example.com' \
--set ingress.api.enabled=true \
--set ingress.grpc.host=agents.example.com \
--set server.env.grpcHost=agents.example.com:443 \
--set ingress.tls.secretName=vantage-tls \
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
# The guards are load-bearing, so their absence is a regression the
# same way a broken render is. Each of these must fail.
- name: Check the guards still refuse bad values
run: |
set -eu
refuses() {
desc="$1"; shift
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
echo "GUARD MISSING: $desc was accepted"
exit 1
fi
echo "ok: refused $desc"
}
refuses "mongo disabled with an in-chart URI" \
--set mongo.enabled=false
refuses "redis disabled with no external address" \
--set redis.enabled=false
refuses "multiple replicas on a ReadWriteOnce volume" \
--set server.replicaCount=2 --set server.persistence.enabled=true
refuses "ingress with no web host" \
--set ingress.enabled=true
refuses "edge-routed API with an empty path list" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=true \
--set 'ingress.api.paths=null'
refuses "gRPC ingress with no host" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set server.env.grpcHost=agents.example.com:443
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
- name: Read the chart version
id: chart
run: |
set -eu
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "chart version is $VERSION"
# Chart.yaml is the source of truth for the version; the tag only
# says "publish this one". A mismatch is a mistake worth stopping
# for — the alternative is stamping the tag over Chart.yaml, which
# leaves the repository disagreeing with what was published.
- name: Check the tag matches Chart.yaml
if: startsWith(github.ref, 'refs/tags/chart/v')
run: |
set -eu
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
CHART_VERSION="${{ steps.chart.outputs.version }}"
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
exit 1
fi
- name: Package
run: |
set -eu
mkdir -p dist
helm package "$CHART_DIR" --destination dist
ls -l dist
- name: Publish to the Gitea chart registry
if: startsWith(github.ref, 'refs/tags/chart/v')
env:
# github.server_url is this Gitea instance, so the registry
# host needs no variable of its own and cannot drift from it.
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
# The same pair server-deploy.yml uses for `docker login`.
# RELEASE_TOKEN, not REGISTRY_PASSWORD: the latter is named in
# the docs but set by no workflow, and an unset secret becomes
# an empty password, which Gitea reports as "Failed to
# authenticate user" rather than as a missing credential.
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CHART_VERSION: ${{ steps.chart.outputs.version }}
run: |
set -eu
PKG="dist/vantage-${CHART_VERSION}.tgz"
test -f "$PKG"
# Checked explicitly, because the failure it prevents is a
# 401 that looks like a permissions problem on the token that
# was never sent.
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
echo "REGISTRY_USER or RELEASE_TOKEN is not set on this repository."
echo "RELEASE_TOKEN needs the write:package scope to publish a chart."
exit 1
fi
echo "publishing to ${REGISTRY} as ${REGISTRY_USER}"
# --fail-with-body so an HTTP error is a failed step with the
# server's explanation, rather than a green run that published
# nothing. A repeated version is rejected by the registry;
# that is the intended behaviour, not something to retry past.
curl --fail-with-body -sS \
--user "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
-X POST \
--upload-file "$PKG" \
"$REGISTRY"
echo "published vantage ${CHART_VERSION}"
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
+19 -4
View File
@@ -77,15 +77,17 @@ jobs:
# 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/|go\.work)'
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
flag sitesvc '^(sitesvc/|shared/|go\.work)'
flag admin '^(admin/|shared/|go\.work)'
# The three Next images use their own directory as the build
# context, so nothing outside it can affect them.
# The three Next images and the docs site use their own
# directory as the build context, so nothing outside it can
# affect them.
flag web '^web/'
flag site '^site/'
flag adminsite '^adminsite/'
flag docsite '^docsite/'
- name: Log in to registry
run: |
@@ -106,7 +108,6 @@ jobs:
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
-t "$IMAGE" \
-f web/Dockerfile web/
@@ -147,6 +148,20 @@ jobs:
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"
+5 -1
View File
@@ -2,6 +2,8 @@ node_modules
dist
build
.env
.env.bck
.env.live
docs/*
!docs/superpowers/
.superpowers
@@ -10,4 +12,6 @@ installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
*.tsbuildinfo
graphify-out
docker-compose.live.yml
+3 -3
View File
@@ -15,11 +15,11 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"golang.org/x/crypto/bcrypt"
)
+19 -11
View File
@@ -10,17 +10,20 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/lifecycle"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
)
func main() {
@@ -34,7 +37,11 @@ func main() {
licensing.SetSigningKey(cfg.SigningKey)
api.SetAppLoginURL(cfg.AppLoginURL)
mail.Init(mail.Config{
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,
@@ -88,6 +95,7 @@ func main() {
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
billing.StartPlaceholderReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/admin
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26
@@ -6,7 +6,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
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
@@ -52,4 +52,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
+301
View File
@@ -0,0 +1,301 @@
package api
import (
"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/billing"
"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"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// 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(),
})
}
// createSelfHostedPlaceholder makes an instance row that exists only so a
// checkout has something to put in custom_data. It carries no licence and is
// flagged Placeholder until the customer pastes their install's real UUID. The
// generated id is temporary; linking replaces the identity.
func createSelfHostedPlaceholder(c *gin.Context) {
s := auth.Current(c)
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || 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: body.Name,
Deployment: license.DeploymentSelfHosted,
Status: models.StatusAwaitingLink,
Placeholder: true,
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.placeholder_created", AccountID: s.AccountID,
Target: inst.InstanceID, 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()})
}
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
// real install UUID, then issues.
//
// :id is the placeholder (generated at checkout, carried in the subscription's
// custom_data); the body carries the UUID the install actually reports. The
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
// placeholder row's identity is rewritten to it and the subscription re-pointed,
// then billing issues from the recorded subscription. Linking and claiming stay
// one call here because, unlike Free, the payment already happened.
func claimPlaceholderLink(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if !inst.Placeholder {
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
return
}
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()
// The real UUID must be free across every account — the unique index on
// instance_id is the tenant-isolation property, so refuse rather than collide.
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
bson.M{"instance_id": body.InstanceID}); n > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
return
}
placeholderID := inst.InstanceID
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{
"instance_id": body.InstanceID,
"status": models.StatusActive,
"placeholder": false,
}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-point the subscription from the placeholder id to the real UUID so
// billing.IssueForInstance (and every later webhook) finds it.
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
// The link stuck; issuance did not. The reconciler and a retry recover it,
// and the customer is not blocked from linking. Surface it, do not roll back.
c.JSON(http.StatusAccepted, gin.H{
"instance_id": body.InstanceID,
"warning": "linked, but licence issuance is pending: " + err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
AccountID: inst.AccountID, Target: body.InstanceID,
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
}
// 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})
}
+107 -80
View File
@@ -9,19 +9,19 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -200,6 +200,96 @@ func listSubscriptions(c *gin.Context) {
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
@@ -241,75 +331,12 @@ func createInstance(c *gin.Context) {
return
}
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
return
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
rec, err := provisionCloudInstance(c, name)
if err != nil {
if 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"})
return
}
if errors.Is(err, provision.ErrNameRejected) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
cloudProvisionError(c, err)
return
}
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("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
return
}
// 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("createInstance: 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("createInstance: 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()})
inst := rec
// Past this point nothing fails the request.
lic, err := licensing.Issue(ctx, licensing.IssueInput{
@@ -327,7 +354,7 @@ func createInstance(c *gin.Context) {
inject.Deliver(ctx, lic)
if mail.Enabled() {
if err := mail.SendInstanceReady(s.Email, inst.Name,
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)
}
@@ -404,7 +431,7 @@ func renewInstance(c *gin.Context) {
Target: inst.InstanceID, IP: c.ClientIP()})
if mail.Enabled() {
if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
}
}
@@ -523,6 +550,6 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
+5 -5
View File
@@ -6,12 +6,12 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+8 -8
View File
@@ -6,16 +6,16 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
+64
View File
@@ -0,0 +1,64 @@
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})
}
}
+6 -6
View File
@@ -7,13 +7,13 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+23 -3
View File
@@ -10,10 +10,10 @@ 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"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
)
func Routes(cfg config.Config) http.Handler {
@@ -39,6 +39,10 @@ func Routes(cfg config.Config) http.Handler {
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())
{
@@ -72,6 +76,21 @@ func Routes(cfg config.Config) http.Handler {
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimFree)
cust.GET("/instances/:id/entitlement", getEntitlement)
cust.GET("/checkout/options", checkoutOptions)
cust.POST("/instances/self-hosted",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createSelfHostedPlaceholder)
// 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.POST("/instances/:id/claim-link",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimPlaceholderLink)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
@@ -113,6 +132,7 @@ func Routes(cfg config.Config) http.Handler {
staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
staff.GET("/health/billing", staffBillingHealth)
}
return r
+30 -7
View File
@@ -6,15 +6,15 @@ import (
"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/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"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -380,6 +380,29 @@ func staffListLicenses(c *gin.Context) {
c.JSON(http.StatusOK, lics)
}
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
// placeholders, so a customer who paid and got nothing is visible rather than
// stuck in a support queue.
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 {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"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
+12 -7
View File
@@ -10,12 +10,13 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -26,7 +27,11 @@ const BcryptCost = 12
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
//
// 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.
@@ -58,7 +63,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password, account
return err
}
if err := mail.SendVerification(u.Email, token); err != nil {
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
@@ -109,7 +114,7 @@ func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accou
return err
}
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
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.
+2 -2
View File
@@ -4,9 +4,9 @@ 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"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+3 -3
View File
@@ -4,10 +4,10 @@ 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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+196
View File
@@ -0,0 +1,196 @@
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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,51 @@
// 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
}
+289
View File
@@ -0,0 +1,289 @@
package billing
import (
"context"
"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/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)
}
sub := models.Subscription{
AccountID: d.CustomData.AccountID,
InstanceID: d.CustomData.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}})
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
// Placeholders are the payment-first path: the instance does not exist until
// this confirmed-payment event. A cloud placeholder is provisioned here and
// then issued (first term). A self-hosted placeholder has no UUID to bind to
// until the customer pastes their install's — its subscription is recorded and
// the link endpoint issues later.
reason := models.ReasonEntitlementChange
if inst.Placeholder {
if inst.Deployment != license.DeploymentCloud {
return nil
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
return err
}
inst = *provisioned
reason = models.ReasonNew
}
return promoteAndIssue(ctx, &inst, match, reason)
}
// 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
}
// IssueForInstance issues from an instance's recorded subscription. Called when
// a self-hosted customer finally links a placeholder they have already paid for.
func IssueForInstance(ctx context.Context, instanceID string) error {
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
return err
}
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 {
return err
}
return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
}
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 {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
return "your instance"
}
return inst.Name
}
+124
View File
@@ -0,0 +1,124 @@
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
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"errors"
"fmt"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
var (
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"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.
+23 -4
View File
@@ -15,9 +15,10 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/db"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"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"
)
@@ -32,11 +33,29 @@ import (
// 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) {
inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
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 {
+12
View File
@@ -29,6 +29,10 @@ type Config struct {
Addr string
ReapAfter time.Duration
PaddleEnv string // "sandbox" or "production"
PaddleAPIKey string
PaddleWebhookSecret string
SMTPHost string
SMTPPort string
SMTPFrom string
@@ -75,6 +79,10 @@ func Load() (Config, error) {
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
@@ -85,6 +93,10 @@ func Load() (Config, error) {
"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)
+2 -1
View File
@@ -14,7 +14,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"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"
@@ -85,6 +85,7 @@ func EnsureIndexes(ctx context.Context) error {
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"paddle_events", "event_id"},
{"staff_users", "email"},
{"customer_users", "email"},
}
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"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"
)
+4 -4
View File
@@ -12,10 +12,10 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"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"
)
+5 -5
View File
@@ -8,12 +8,12 @@ import (
"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"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -6,11 +6,11 @@ import (
"fmt"
"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/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+88 -8
View File
@@ -11,10 +11,10 @@ import (
"slices"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"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"
)
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
}
}
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
@@ -133,13 +143,13 @@ func sendNotice(ctx context.Context, inst models.Instance, lic models.License, k
switch key {
case noticeExpiring:
return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
return mail.Default.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
case noticeExpired:
return mail.SendExpired(to, inst.Name, portalURL, deleteOn)
return mail.Default.SendExpired(to, inst.Name, portalURL, deleteOn)
case noticeDelete7:
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
case noticeDelete1:
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
}
return nil
}
@@ -172,4 +182,74 @@ func runOnce(ctx context.Context) {
if err := Run(runCtx); err != nil {
log.Printf("lifecycle: %v", err)
}
sweepAwaitingLink(runCtx)
}
// Awaiting-link reminder keys.
const (
noticeLink24 = "link_24"
noticeLink72 = "link_72"
)
// sweepAwaitingLink chases self-hosted instances that were paid for but never
// linked: the subscription exists, the instance is still a placeholder. It
// emails a reminder at 24h and again at 72h. The staff dashboard already flags
// 48h; this is the active chasing on top of that. It never issues or deletes.
func sweepAwaitingLink(ctx context.Context) {
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
"deployment": license.DeploymentSelfHosted,
"placeholder": true,
"status": models.StatusAwaitingLink,
})
if err != nil {
return
}
var instances []models.Instance
if err := cur.All(ctx, &instances); err != nil {
return
}
now := time.Now().UTC()
for _, inst := range instances {
// Only chase placeholders a customer has actually paid for.
n, err := db.Admin("subscriptions").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive})
if err != nil || n == 0 {
continue
}
if !mail.Enabled() {
continue
}
to := accountEmail(ctx, inst.AccountID)
if to == "" {
continue
}
age := now.Sub(inst.CreatedAt)
var due string
if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) {
due = noticeLink72
} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) {
due = noticeLink24
}
if due == "" {
continue
}
if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil {
log.Printf("lifecycle: link reminder %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 link notice %s for %s: %v", due, inst.InstanceID, 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
}
+14 -221
View File
@@ -1,226 +1,19 @@
// Package mail delivers verification links and licence files.
// 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 (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
)
import "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
// timeout bounds the whole SMTP conversation. Without it a mail server that
// accepts the connection and then stalls holds an HTTP request open until the
// client gives up — and signup's rollback runs on that request's context.
const timeout = 15 * time.Second
// Default is admin's sender. Set once by main; read everywhere else.
var Default mail.Sender
type Config struct {
Host, Port, From, Username, Password string
PublicURL string
}
func Init(s mail.Sender) { Default = s }
var cfg Config
func Init(c Config) { cfg = c }
func Enabled() bool { return cfg.Host != "" && cfg.From != "" }
// send delivers one message.
//
// Port 465 is implicit TLS: the server expects a TLS handshake immediately, so
// the connection is wrapped BEFORE any SMTP is spoken. Every other port gets
// plaintext then STARTTLS if offered. `net/smtp.SendMail` only does the latter,
// which is why it fails against a 465 mail server — this exact bug silently
// stopped every admin email from being delivered.
//
// This mirrors sitesvc/internal/mail, which solved the same problem first. The
// two are duplicated rather than shared; if you change the transport here,
// change it there too, or consolidate both into shared/.
func send(to, subject, body string) error {
if !Enabled() {
return fmt.Errorf("SMTP is not configured")
}
if strings.TrimSpace(to) == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(cfg.Host, cfg.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
if cfg.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: cfg.Host})
}
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if cfg.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if cfg.Username != "" {
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(cfg.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", to, err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(to, subject, body)); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return client.Quit()
}
// message builds the RFC 5322 envelope.
//
// Date and Message-ID are not decoration: a message without them is scored as
// spam by most filters, which is its own way of "the email never arrived".
// Header values are stripped of CR and LF so a crafted instance name cannot
// inject extra headers.
func message(to, subject, body string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(cfg.From) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(cfg.From) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
func messageID(from string) string {
domain := "vantage.local"
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
domain = strings.Trim(from[at+1:], "<> ")
}
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
}
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(buf[:]), domain)
}
func sanitizeHeader(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
func SendVerification(to, token string) error {
link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
return send(to, "Verify your Vantage account",
"Confirm your email address to finish setting up your Vantage account:\n\n"+
link+"\n\nThis link expires in 24 hours.\n")
}
// SendInvite asks someone to join an existing account and set their own
// password. It names the account, because an unexpected invitation from a
// service you have never used is otherwise indistinguishable from spam.
func SendInvite(to, accountName, token string) error {
link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
"Set your password and finish joining:\n\n%s\n\n"+
"This link expires in 24 hours. If you were not expecting this, ignore it — "+
"nothing happens until you open the link.\n", accountName, link))
}
// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {
return send(to, "Your Vantage licence key",
fmt.Sprintf("Your licence for %s is below.\n\n"+
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
instanceName, blob))
}
// SendInstanceReady tells a customer their cloud instance exists, where it is,
// and when its licence runs out.
//
// The expiry is stated here rather than only in a later reminder: a Free licence
// that quietly expires in a month is a surprise, and the first email is the one
// people keep.
func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
body := fmt.Sprintf("%s is ready.\n\n", instanceName)
if loginURL != "" {
body += "Sign in here:\n\n" + loginURL + "\n\n"
}
body += fmt.Sprintf(
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
"Sign in with the same email address and password you use for your Vantage account. "+
"Changing your Vantage HQ password changes it here too.\n",
expires.Format("2 January 2006"))
return send(to, instanceName+" is ready", body)
}
// SendRenewed confirms a renewal and states the new date.
func SendRenewed(to, instanceName string, expires time.Time) error {
return send(to, instanceName+" renewed",
fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n",
instanceName, expires.Format("2 January 2006")))
}
// SendExpiring is the renew-now nudge, seven days out.
func SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
return send(to, instanceName+" expires on "+expires.Format("2 January"),
fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+
"Renew it in one click:\n\n%s\n\n"+
"If you do nothing, the instance keeps running but stops accepting changes.\n",
instanceName, expires.Format("2 January 2006"), portalURL))
}
// SendExpired states plainly what has stopped and what happens next.
//
// It names the deletion date rather than a vague warning: the whole point of the
// sequence is that nobody loses an instance without having been told a date.
func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
return send(to, instanceName+" is now read-only",
fmt.Sprintf("%s's Free licence has expired.\n\n"+
"Your servers and monitors keep running and your agents keep their keys, "+
"but changes are disabled.\n\n"+
"Renew it here:\n\n%s\n\n"+
"If it is not renewed, the instance and everything in it will be deleted on %s.\n",
instanceName, portalURL, deleteOn.Format("2 January 2006")))
}
// SendDeletionWarning is the final countdown, sent at seven days and one day.
func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
when := fmt.Sprintf("in %d days", daysLeft)
if daysLeft <= 1 {
when = "tomorrow"
}
return send(to, instanceName+" will be deleted "+when,
fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+
"This cannot be undone. Renew it here to keep it:\n\n%s\n",
instanceName, when, deleteOn.Format("2 January 2006"), portalURL))
}
// 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() }
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"log"
"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"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package models
import (
"context"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"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"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"errors"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"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"
+48 -4
View File
@@ -9,7 +9,7 @@ import (
"encoding/json"
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -77,6 +77,21 @@ const (
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
@@ -127,8 +142,16 @@ type Instance struct {
// 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"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
// Placeholder is true while a self-hosted instance row exists only so a
// checkout has something to attach custom_data to, before the customer has
// pasted their install's real UUID. Cleared when the instance is linked.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
// 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
@@ -158,11 +181,32 @@ type Subscription struct {
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"`
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_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
+48
View File
@@ -0,0 +1,48 @@
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
}
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"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"
)
+54
View File
@@ -0,0 +1,54 @@
// 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
// 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
}
+120
View File
@@ -0,0 +1,120 @@
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)
}
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
@@ -0,0 +1,42 @@
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))
}
+12
View File
@@ -19,6 +19,18 @@ 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
+12 -33
View File
@@ -5,6 +5,7 @@ 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 { formatDate } from "@/lib/format";
export default function BillingPage() {
@@ -19,19 +20,14 @@ export default function BillingPage() {
// 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;
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
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
}
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
@@ -50,15 +46,10 @@ export default function BillingPage() {
/>
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">
To change a card, download an invoice or cancel, email support and
we will send you a billing link. Self-service billing arrives with
card payments.
</p>
<a
href="mailto:support@hostxtra.co.uk"
className="text-[0.82rem] font-semibold text-accent underline"
>
<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>
@@ -66,10 +57,7 @@ export default function BillingPage() {
}
>
{rows.length === 0 ? (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are
both bought from the pricing page.
</p>
<p className="rounded border border-rule bg-panel p-5 text-ink-2">You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.</p>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
@@ -84,21 +72,12 @@ export default function BillingPage() {
</thead>
<tbody>
{rows.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">
{nameFor(s.instance_id) ?? (
<span className="text-ink-3">Not linked yet</span>
)}
</td>
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(s.current_period_end)}
</td>
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
</tr>
))}
</tbody>
@@ -7,9 +7,17 @@ import { Field } from "@/components/Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) {
export function LinkForm({
onLinked,
claimId,
}: {
onLinked: (instanceId: string) => void;
// The PAID placeholder awaiting its real install UUID: claim it in place. The
// name was chosen at checkout, so it is not asked for again. Self-hosted Free
// is created on the purchase page instead, not here.
claimId: string;
}) {
const [id, setId] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
@@ -28,7 +36,7 @@ export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void
setBusy(true);
setError(undefined);
try {
const inst = await api.link(value, name.trim());
const inst = await api.claimLink(claimId, value);
onLinked(inst.instance_id);
} catch (err) {
setError(
@@ -58,12 +66,6 @@ export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void
</>
}
/>
<Field
label="Name it (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
hint="So you can tell it apart from your other installs."
/>
<Button type="submit" disabled={busy} className="justify-self-start">
{busy ? "Linking…" : "Link and issue licence"}
</Button>
@@ -1,6 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { LinkForm } from "./LinkForm";
import { PageHeader } from "@/components/PageHeader";
@@ -8,6 +9,16 @@ import { PageHeader } from "@/components/PageHeader";
export default function LinkPage() {
const router = useRouter();
const qc = useQueryClient();
// This page only claims a PAID placeholder's real install UUID. Self-hosted
// Free is created on the purchase page, so with no placeholder to claim there
// is nothing to do here send them there.
const claimId = useSearchParams().get("claim") ?? undefined;
useEffect(() => {
if (!claimId) router.replace("/purchase");
}, [claimId, router]);
if (!claimId) return null;
return (
<div className="grid max-w-2xl gap-6">
@@ -17,6 +28,7 @@ export default function LinkPage() {
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
/>
<LinkForm
claimId={claimId}
onLinked={(instanceId) => {
qc.invalidateQueries({ queryKey: ["account"] });
// Straight to the download, not back to a list: the licence is
@@ -1,65 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function CreateForm() {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const qc = useQueryClient();
const create = useMutation({
mutationFn: () => api.createInstance(name.trim()),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["account"] });
router.push("/");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
const slug = slugify(name);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (name.trim()) create.mutate();
}}
>
<Field
label="Instance name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
required
error={error ?? undefined}
hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`}
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing your Vantage
HQ password changes it here too.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
</Button>
</form>
);
}
@@ -1,18 +0,0 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Create a free instance"
subtitle="An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Free covers three servers, and the licence runs for a month at a time — we email you before it needs renewing."
/>
<CreateForm />
</div>
);
}
+3 -7
View File
@@ -6,8 +6,8 @@ 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
* 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.
@@ -22,11 +22,7 @@ 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>
);
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 }) {
+20 -72
View File
@@ -35,14 +35,12 @@ export default function OverviewPage() {
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.
// only account-level view of it each record only knows about itself.
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
if (state === "none")
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired")
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "warn")
return [
{
@@ -55,51 +53,31 @@ export default function OverviewPage() {
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const hasFree = live.some((i) => i.tier === "free" && i.status !== "cancelled");
const subtitle =
live.length === 0
? "Nothing here yet."
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
}`;
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={
!hasFree && live.length > 0 ? (
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
) : undefined
}
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
}
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
/>
{live.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied
automatically. Or buy a self-hosted licence, install Vantage on your own
server, and link it here to get your licence file.
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
</p>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/instances/new">Create a free instance</LinkButton>
<LinkButton variant="line" href="/instances/link">
Link an install
</LinkButton>
<LinkButton href="/purchase">Buy a plan</LinkButton>
</div>
</div>
) : (
@@ -110,16 +88,9 @@ export default function OverviewPage() {
<RailCard title="Needs you" count={attention.length}>
<ul className="grid gap-2">
{attention.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
>
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span>{a.text}</span>
{a.note && (
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
{a.note}
</span>
)}
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
</li>
))}
</ul>
@@ -129,27 +100,18 @@ export default function OverviewPage() {
<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"
>
<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>
<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
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
</p>
)}
<Link
href="/users"
className="text-[0.82rem] font-semibold text-accent underline"
>
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
Manage people
</Link>
</RailCard>
@@ -157,7 +119,7 @@ export default function OverviewPage() {
{/*
* 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
* instance an account holding a Free cloud instance and
* a Professional self-hosted one has no single plan.
*/}
<RailCard title="Account">
@@ -171,24 +133,15 @@ export default function OverviewPage() {
},
]}
/>
<Link
href="/billing"
className="text-[0.82rem] font-semibold text-accent underline"
>
<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">
Link your own install to get its licence file. It keeps its own
users.
</p>
<Link
href="/instances/link"
className="text-[0.82rem] font-semibold text-accent underline"
>
Link an install
<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>
</>
@@ -205,12 +158,7 @@ export default function OverviewPage() {
// 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)
}
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
/>
);
})}
@@ -0,0 +1,794 @@
"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";
/* 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;
/* Human labels for feature keys. The catalogue names them by key; this is the
* one place the customer-facing wording lives. */
const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
};
const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
};
function featureLabel(key: string) {
return FEATURE_LABEL[key] ?? key;
}
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."),
});
const startCheckout = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(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."),
});
const claim = useMutation({
mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
onSuccess: () => router.push("/"),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
});
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={FEATURE_DESC[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>
)}
{selfHostedFree && (
<Block n={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">
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}
onClick={() => {
setError(null);
startCheckout.mutate();
}}
/>
))}
{/* Phase B: after the checkout has been opened. */}
{pending?.deployment === "self_hosted" && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings Licence) to receive your licence.</p>
<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"
/>
<Cta
label={claim.isPending ? "Linking…" : "Link and issue licence"}
disabled={!uuid.trim() || claim.isPending}
onClick={() => {
setError(null);
claim.mutate();
}}
/>
</div>
)}
{pending?.deployment === "cloud" && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. 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>
);
}
@@ -0,0 +1,18 @@
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>
);
}
@@ -25,9 +25,7 @@ export default function AccountDetailPage() {
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 }]
: []),
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
]}
/>
@@ -35,10 +33,7 @@ export default function AccountDetailPage() {
<ul className="grid gap-2">
{data.instances.map((i) => (
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
<Link
href={`/staff/instances/${i.instance_id}`}
className="text-accent underline"
>
<Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
{i.name || i.instance_id}
</Link>
<span className="font-mono text-[0.82rem] text-ink-3">
@@ -71,19 +66,10 @@ export default function AccountDetailPage() {
{data.users.map((u) => (
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
<span className="font-mono text-[0.82rem]">{u.email}</span>
<span className="font-mono text-[0.82rem] text-ink-3">
{u.verified_at
? `verified ${formatDate(u.verified_at)}`
: "not verified"}
</span>
<span className="font-mono text-[0.82rem] text-ink-3">{u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}</span>
</li>
))}
{data.users.length === 0 && (
<li className="text-ink-3">
None this is a cloud account, so its people sign in with their
control-plane details.
</li>
)}
{data.users.length === 0 && <li className="text-ink-3">None this is a cloud account, so its people sign in with their control-plane details.</li>}
</ul>
</Panel>
@@ -94,9 +80,7 @@ export default function AccountDetailPage() {
<span>
{e.action} · {e.actor}
</span>
<span className="tabular-nums text-ink-3">
{formatDate(e.created_at)}
</span>
<span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
</li>
))}
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
@@ -6,11 +6,7 @@ import { ApiError, api, type Tier } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export function IssuePanel({
instanceId,
}: {
instanceId: string;
}) {
export function IssuePanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>("professional");
const [term, setTerm] = useState("annual");
@@ -35,28 +31,16 @@ export function IssuePanel({
<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"
>
<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"
>
<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>
@@ -67,18 +51,8 @@ export function IssuePanel({
</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()}
>
<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>
@@ -14,7 +14,7 @@ import { IssuePanel } from "./IssuePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
current: { label: "Control plane holds the current licence", tone: "text-valid" },
stale: {
label: "Control plane holds an older blob the reconciler will repair it",
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" },
@@ -44,30 +44,19 @@ export default function StaffInstancePage() {
title={data.instance.name || data.instance.instance_id}
subtitle={
<>
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
<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`}
{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 }]
: []),
]}
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>
)}
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
</div>
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
@@ -76,10 +65,7 @@ export default function StaffInstancePage() {
<IssuePanel instanceId={data.instance.instance_id} />
</section>
<EntitlementSection
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
</div>
);
}
@@ -114,8 +100,7 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
const save = useMutation({
mutationFn: (grant: boolean) =>
api.staff.setEntitlement(instanceId, { ...choice, grant }),
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
@@ -127,38 +112,21 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
<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>.
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" })}`
: ""}
.
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}
/>
<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"
>
<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
@@ -170,15 +138,8 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
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>
)}
<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>
);
}
+8 -27
View File
@@ -35,9 +35,7 @@ export default function StaffDashboard() {
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 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 ?? [];
@@ -46,11 +44,7 @@ export default function StaffDashboard() {
<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."
}
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
@@ -68,9 +62,7 @@ export default function StaffDashboard() {
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)
: "",
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
}))}
/>
<Queue
@@ -117,8 +109,7 @@ export default function StaffDashboard() {
},
{
label: "Self-hosted",
value: instances.filter((i) => i.deployment === "self_hosted")
.length,
value: instances.filter((i) => i.deployment === "self_hosted").length,
},
{
label: "Awaiting link",
@@ -126,10 +117,7 @@ export default function StaffDashboard() {
},
]}
/>
<Link
href="/staff/licenses"
className="text-[0.82rem] font-semibold text-accent underline"
>
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
All licences
</Link>
</RailCard>
@@ -139,14 +127,9 @@ export default function StaffDashboard() {
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
<div>
<h2 className="text-[0.95rem]">Recent activity</h2>
<p className="text-[0.8rem] text-ink-2">
Every licence issued, relinked or reaped, newest first.
</p>
<p className="text-[0.8rem] text-ink-2">Every licence issued, relinked or reaped, newest first.</p>
</div>
<Link
href="/staff/audit"
className="text-[0.82rem] font-semibold text-accent underline"
>
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
Full audit
</Link>
</header>
@@ -163,9 +146,7 @@ export default function StaffDashboard() {
<tbody>
{(audit.data ?? []).slice(0, 12).map((e, n) => (
<tr key={n} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
{new Date(e.created_at).toISOString().slice(11, 16)}
</td>
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</td>
<td className="px-4 py-2.5">{e.action}</td>
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
+15 -60
View File
@@ -26,15 +26,7 @@ const LIMIT_FIELDS = [
* 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;
}) {
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);
@@ -43,9 +35,7 @@ function AllowanceForm({
<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>
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
<input
type="number"
value={draft.base_limits[f.key]}
@@ -60,20 +50,14 @@ function AllowanceForm({
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
1 is unlimited
</span>
<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>
<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 })
}
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
>
{SUPPORT_LEVELS.map((s) => (
@@ -86,25 +70,13 @@ function AllowanceForm({
</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 })}
/>
<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>
<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="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
<button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
@@ -131,26 +103,20 @@ export default function PlansPage() {
onError: () => setSaving(null),
});
const original = plans.data?.find(
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
);
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."
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
}
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
save.mutate(draft);
@@ -161,29 +127,18 @@ export default function PlansPage() {
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="space-y-3">
<h2 className="text-[0.95rem] font-medium text-ink">
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
</h2>
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<article
key={`${p.deployment}/${p.tier}`}
className="rounded-lg border border-rule bg-panel p-4"
>
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3 flex items-baseline justify-between gap-3">
<h3 className="text-[0.9rem] font-medium text-ink">
{p.name}
</h3>
<h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
<span className="font-mono text-[0.75rem] text-ink-3">
{p.deployment}/{p.tier}
</span>
</header>
<AllowanceForm
plan={p}
saving={saving === `${p.deployment}/${p.tier}`}
onSave={(next: Plan) => setDraft(next)}
/>
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
</article>
))}
</section>
+1 -1
View File
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
/*
* The masthead deliberately does NOT live here. It belongs to the authenticated
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
* 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 }) {
+65 -35
View File
@@ -2,12 +2,13 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
@@ -44,46 +45,75 @@ export default function LoginPage() {
return (
<Main>
<h1 className="text-3xl">Sign in</h1>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
{/* 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]">Sign in</h1>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Licences · instances · billing
</p>
</div>
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
<form onSubmit={submit} className="grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
I work at Vantage
</label>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={busy}>
<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>
<Link href="/signup" className="text-[0.82rem] text-accent underline">
Create an account for a self-hosted licence
</Link>
</div>
</form>
</form>
{SITE_URL && (
<>
<div className="my-5 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>
</>
)}
</div>
</Main>
);
}
function Main({ children }: { children: React.ReactNode }) {
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{children}
</main>
);
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
import { useState } from "react";
import { ApiError, NotConnected, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export default function SignupPage() {
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setState("busy");
setError(null);
try {
await api.signup(form);
setState("sent");
} catch (err) {
setState("idle");
setError(
err instanceof NotConnected
? "The licensing service is not reachable from this page."
: err instanceof ApiError
? err.message
: "Could not create the account. Try again.",
);
}
}
return (
<main className="mx-auto max-w-rail px-5 py-12">
{state === "sent" ? (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Check your email</h1>
<p className="text-ink-2">
We sent a link to {form.email}. Open it to finish setting up your account
it expires in 24 hours. Nothing is created until you do.
</p>
</div>
) : (
<>
<h1 className="text-3xl">Create an account</h1>
<p className="mt-2 max-w-xl text-ink-2">
For self-hosted licences. If you run on our cloud, sign in with the same
details you use for your Vantage instance.
</p>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Organisation"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<Field
label="Email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
<Field
label="Password"
type="password"
required
minLength={12}
hint="At least 12 characters."
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
error={error ?? undefined}
/>
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
className="absolute left-[-9999px] h-0 w-0"
/>
<Button type="submit" disabled={state === "busy"}>
{state === "busy" ? "Creating…" : "Create account"}
</Button>
</form>
</>
)}
</main>
);
}
+5 -19
View File
@@ -12,7 +12,7 @@ export type NavLink = { href: string; label: string };
* 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
* 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
@@ -21,24 +21,14 @@ export type NavLink = { href: string; label: string };
* 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;
}) {
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}/`);
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
return (
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
@@ -46,9 +36,7 @@ export function AppBar({
<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 className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
{context && (
<>
@@ -70,9 +58,7 @@ export function AppBar({
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"
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}
+3 -17
View File
@@ -12,9 +12,7 @@ export function buttonClass(variant: Variant = "solid", disabled = false, classN
return clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid"
? "border-accent bg-accent text-accent-ink"
: "border-rule bg-panel text-ink hover:border-ink-3",
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,
);
@@ -28,22 +26,10 @@ export function Button({ variant = "solid", className, ...rest }: Props) {
/*
* 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
* 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;
}) {
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}>
+40
View File
@@ -0,0 +1,40 @@
"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>
);
}
+4 -25
View File
@@ -5,30 +5,12 @@ 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
* 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;
}) {
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;
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({
@@ -63,10 +45,7 @@ export function ConfirmPlanChange({
))}
{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>
<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
+33 -102
View File
@@ -21,7 +21,7 @@ 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
* 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
@@ -31,23 +31,12 @@ const KEY = (id: string) => `vantage-hq-record-open:${id}`;
* 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;
}) {
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 termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays =
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
@@ -80,13 +69,7 @@ export function InstanceRecord({
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],
)}
>
<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>
@@ -96,10 +79,7 @@ export function InstanceRecord({
{` · 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"
>
<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>
)}
@@ -115,10 +95,7 @@ export function InstanceRecord({
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 aria-hidden className={clsx("block transition-transform", open && "rotate-180")}>
</span>
</button>
@@ -144,60 +121,36 @@ export function InstanceRecord({
{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>
<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.`}
{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>
)}
{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} hidden={!open} className="grid gap-3.5">
<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>
<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_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}
/>
<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>
<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 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>
@@ -205,13 +158,8 @@ export function InstanceRecord({
</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"
>
{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>
@@ -220,35 +168,29 @@ export function InstanceRecord({
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
<LinkButton href="/instances/link">Link an install</LinkButton>
// A paid placeholder (awaiting_link) claims its real install
// UUID in place. Anything else without a licence gets one from
// the purchase page (self-hosted Free is created there).
instance.status === "awaiting_link" ? (
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>Link an install</LinkButton>
) : (
<LinkButton href="/purchase">Get a licence</LinkButton>
)
) : cloud && instance.slug ? (
<>
<LinkButton
external
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
>
Open instance
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
Open Cloud Instance
</LinkButton>
<LinkButton
variant="line"
href={`/instances/${instance.instance_id}`}
>
Instance settings
<LinkButton variant="line" href={`/instances/${instance.instance_id}`}>
View Instance Settings
</LinkButton>
</>
) : (
<LinkButton href={`/instances/${instance.instance_id}`}>
{state === "expired" ? "Renew and download" : "Licence and download"}
</LinkButton>
<LinkButton href={`/instances/${instance.instance_id}`}>View Instance Settings</LinkButton>
)}
{canRenew && (
<Button
type="button"
variant="line"
onClick={() => renew.mutate()}
disabled={renew.isPending}
>
<Button type="button" variant="line" onClick={() => renew.mutate()} disabled={renew.isPending}>
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
@@ -261,19 +203,8 @@ export function InstanceRecord({
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>
<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>
);
}
+7 -30
View File
@@ -13,16 +13,12 @@ const REASON: Record<License["reason"], string> = {
/*
* 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
* 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 <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
}
return (
@@ -30,22 +26,9 @@ export function Ledger({ licenses }: { licenses: License[] }) {
{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",
)}
>
<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>
<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">
@@ -56,20 +39,14 @@ export function Ledger({ licenses }: { licenses: License[] }) {
)}
<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>
<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.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>
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
</>
)}
</p>
+7 -24
View File
@@ -4,19 +4,11 @@ import { useState } from "react";
import { Button } from "./Button";
/*
* A licence blob is signed public data, not a secret it is useless on any
* A licence blob is signed public data, not a secret it is useless on any
* instance other than the one it names. So it is safe to show inline, and
* showing it is what stops a blocked download from blocking a paying customer.
*/
export function LicenceDelivery({
instanceId,
blob,
downloadUrl,
}: {
instanceId: string;
blob: string;
downloadUrl: string;
}) {
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
@@ -27,13 +19,11 @@ export function LicenceDelivery({
const steps = [
<>
Open <code className="rounded-sm bg-accent-wash px-1">Settings Licence</code> on your
install.
Open <code className="rounded-sm bg-accent-wash px-1">Settings Licence</code> on your install.
</>,
<>Paste the licence into the box and save.</>,
<>
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
away no restart.
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
</>,
];
@@ -52,18 +42,11 @@ export function LicenceDelivery({
{copied ? "Copied" : "Copy to clipboard"}
</Button>
</div>
<pre className="overflow-x-auto rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
{blob}
</pre>
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">{blob}</pre>
<ol className="grid gap-2">
{steps.map((body, i) => (
<li
key={i}
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
>
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
{i + 1}
</span>
<li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">{i + 1}</span>
<span>{body}</span>
</li>
))}
@@ -0,0 +1,35 @@
"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>
);
}
+13 -39
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/Button";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
/*
* Absent entirely for self-hosted instances the backend refuses those, and a
* 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.
*/
export function MembersPanel({ instanceId }: { instanceId: string }) {
@@ -26,8 +26,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
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 fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const grant = useMutation({
mutationFn: () => api.grantMember(instanceId, selected, role),
@@ -39,8 +38,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
onError: fail,
});
const changeRole = useMutation({
mutationFn: (v: { uid: string; role: InstanceRole }) =>
api.setMemberRole(instanceId, v.uid, v.role),
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
onSuccess: refresh,
onError: fail,
});
@@ -54,29 +52,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const canManage = myRole === "owner" || myRole === "admin";
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter(
(p) => !granted.has(p.user_id) && p.verified_at,
);
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 (
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
<div className="grid gap-1">
<h2 className="text-xl">Who can sign in</h2>
<p className="text-[0.82rem] text-ink-2">
Each person here has a real user inside this instance and signs in with their
Vantage HQ password.
</p>
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
<ul className="grid gap-2">
{(members.data ?? []).map((m) => (
<li
key={m.member_id}
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
>
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
<span>{m.email}</span>
<span className="flex items-center gap-3">
{canManage ? (
@@ -104,8 +94,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`))
revoke.mutate(m.customer_user_id);
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
}}
>
Remove
@@ -114,9 +103,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</li>
)}
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
</ul>
{canManage && (
@@ -129,14 +116,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
}}
>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Add someone
</span>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
<option value="">Choose a person</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
@@ -146,14 +127,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Role here
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as InstanceRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
@@ -169,8 +144,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
{canManage && pending > 0 && (
<p className="text-[0.82rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
yet and cannot be added until they do.
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
</p>
)}
</section>
+5 -8
View File
@@ -1,6 +1,6 @@
/*
* 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
* 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 }) {
@@ -9,19 +9,16 @@ export function NotConnectedPanel({ url }: { url: string }) {
<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.
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.
<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.
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>
);
+6 -26
View File
@@ -7,16 +7,10 @@
* 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
* 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;
}) {
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
if (!aside) return <div className="grid gap-5">{children}</div>;
return (
@@ -28,24 +22,12 @@ export function PageFrame({
}
/** 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;
}) {
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>
)}
<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>
@@ -59,9 +41,7 @@ export function RailFacts({ rows }: { rows: { label: string; value: React.ReactN
{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>
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
</div>
))}
</dl>
+4 -11
View File
@@ -5,7 +5,7 @@ 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
* clipboard an instance UUID or a licence ID, the strings people paste into
* support tickets.
*/
export type RecordField = { key: string; value: string; copy?: boolean };
@@ -60,10 +60,7 @@ export function PageHeader({
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"
>
<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>
)}
@@ -80,12 +77,8 @@ export function PageHeader({
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
{record?.map((f) => (
<span key={f.key} className="flex items-center gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
{f.key}
</span>
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
{f.value}
</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
+5 -34
View File
@@ -6,18 +6,7 @@ import { Field } from "./Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function RelinkPanel({
used,
max,
onRelink,
error,
}: {
instanceId: string;
used: number;
max: number;
onRelink: (newId: string) => void;
error?: string;
}) {
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);
@@ -26,32 +15,14 @@ export function RelinkPanel({
return (
<section className="grid gap-3 border-t border-rule-soft pt-5">
<h2 className="text-xl">Moved to a new server?</h2>
<p className="text-[0.82rem] text-ink-2">
Relinking issues a replacement licence for the new install, covering the rest of
your current term.
</p>
{open && !exhausted && (
<Field
label="New instance ID"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error}
hint="From Settings → Licence on the new install."
/>
)}
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
Relink to a new install
</Button>
<span className="text-[0.82rem] text-ink-3">
{exhausted
? "You have used every relink for this term — contact support and we will sort it out."
: `${remaining} of ${max} relinks left this term`}
{exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`}
</span>
</div>
</section>
+64 -2
View File
@@ -188,6 +188,51 @@ export interface EntitlementConfig {
features: string[];
}
export interface CheckoutOptions {
plans: Plan[];
catalogue: CatalogueRow[];
env: "sandbox" | "production";
}
/*
* lineItemsFor builds the Paddle checkout items for a configuration, client-side
* from the catalogue already fetched. It mirrors the Go catalogue.LineItems and
* its billable() exactly: base is quantity 1; the per-server unit's quantity is
* servers MINUS the plan's base allowance (never charge for the base the one
* subtraction, kept here to match the server); a feature contributes an item
* only when its row has a price in this environment/term.
*/
export function lineItemsFor(
opts: CheckoutOptions,
choice: { tier: Tier; term: Term; servers: number; features: string[] },
deployment: Deployment,
): { priceId: string; quantity: number }[] {
const env = opts.env;
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
if (!plan) return [];
const rows = opts.catalogue.filter(
(r) => r.deployment === deployment && r.tier === choice.tier,
);
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
const base = plan.base_limits.max_servers;
const items: { priceId: string; quantity: number }[] = [];
for (const r of rows) {
const id = priceOf(r);
if (r.kind === "base") {
if (id) items.push({ priceId: id, quantity: 1 });
} else if (r.kind === "limit" && r.limit_key === "max_servers") {
// -1 base is unlimited: nothing metered. Otherwise charge servers over base.
const qty = base === -1 ? 0 : choice.servers - base;
if (qty > 0 && id) items.push({ priceId: id, quantity: qty });
} else if (r.kind === "feature" && r.feature_key) {
if (choice.features.includes(r.feature_key) && id) {
items.push({ priceId: id, quantity: 1 });
}
}
}
return items;
}
export interface Entitlement {
instance_id: string;
account_id: string;
@@ -251,8 +296,6 @@ export const api = {
staffLogin: (email: string, password: string) =>
post<Session>("/auth/staff/login", { email, password }),
logout: () => post<{ ok: boolean }>("/auth/logout"),
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
post<{ pending: boolean }>("/auth/signup", payload),
verify: (token: string) =>
req<{ verified: boolean; needs_password?: boolean }>(
`/auth/verify?token=${encodeURIComponent(token)}`,
@@ -263,12 +306,31 @@ export const api = {
post<Instance>("/api/instances/link", { instance_id, name }),
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
// Self-hosted Free: issue the licence on an already-linked instance.
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
relink: (id: string, instance_id: string) =>
post<License>(`/api/instances/${id}/relink`, { instance_id }),
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
entitlement: (id: string) =>
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
createSelfHosted: (name: string) =>
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
// Paid cloud: provisions the real instance the paid webhook then licenses.
createCloudCheckout: (name: string) =>
post<{ instance_id: string }>("/api/instances/cloud", { name }),
updateEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[] },
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
claimLink: (placeholderId: string, instance_id: string) =>
post<{ instance_id: string; warning?: string }>(
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
accountUsers: () => req<AccountUser[]>("/api/account/users"),
invite: (email: string, role: AccountRole) =>
post<{ invited: boolean }>("/api/account/users", { email, role }),
+76
View File
@@ -0,0 +1,76 @@
import { initializePaddle, type Paddle } from "@paddle/paddle-js";
let cached: Promise<Paddle | undefined> | null = null;
/* One Paddle instance for the app. The token and environment are baked into the
* build (NEXT_PUBLIC_*), never fetched, so a production build can never load a
* sandbox token by accident. */
export function initPaddle(): Promise<Paddle | undefined> {
if (!cached) {
cached = initializePaddle({
environment:
(process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
});
}
return cached;
}
export interface PricedLine {
priceId: string;
/* Already localised and currency-formatted by Paddle, e.g. "£39.00". The line
* total for the quantity, not the unit price. */
total: string;
unit: string;
}
export interface PricePreview {
currency: string;
/* Grand total, formatted. */
total: string;
lines: Record<string, PricedLine>;
}
/*
* previewPrices asks Paddle for the real localised prices of a set of line items,
* so the order summary shows what the customer will actually pay rather than a
* hardcoded number that would drift from the dashboard.
*
* It returns null when Paddle is unavailable or a price cannot be previewed (an
* unconfigured sandbox price, an ad blocker). The caller falls back to showing
* the line items without amounts rather than a wrong total the real figure
* still appears in the checkout overlay, which is the authority.
*/
export async function previewPrices(
items: { priceId: string; quantity: number }[],
): Promise<PricePreview | null> {
if (items.length === 0) return { currency: "", total: "", lines: {} };
const paddle = await initPaddle();
if (!paddle) return null;
try {
const res = await paddle.PricePreview({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
});
const currency = res.data.currencyCode;
const lines: Record<string, PricedLine> = {};
// Paddle gives per-line totals but no grand total, so sum the raw minor
// units and format once. The checkout overlay is the authority; this is
// the honest preview beside it.
let subtotalMinor = 0;
for (const li of res.data.details.lineItems) {
lines[li.price.id] = {
priceId: li.price.id,
total: li.formattedTotals.subtotal,
unit: li.formattedUnitTotals.subtotal,
};
subtotalMinor += Number.parseInt(li.totals.subtotal, 10) || 0;
}
const total = new Intl.NumberFormat(undefined, {
style: "currency",
currency,
}).format(subtotalMinor / 100);
return { currency, total, lines };
} catch {
return null;
}
}
+2 -4
View File
@@ -31,7 +31,7 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
// Starts at "system" on both server and first client render so hydration
// matches; the real value lands in the effect below. The inline script in
// app/layout.tsx has already painted the correct colours by then, so there
// is no flash only this control's own highlight settles a tick late.
// is no flash only this control's own highlight settles a tick late.
const [pref, setPref] = useState<ThemePref>("system");
useEffect(() => setPref(readTheme()), []);
@@ -51,6 +51,4 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
* Runs before first paint, so a dark-preferring user never sees a white flash.
* Inlined as a string because it has to execute ahead of React.
*/
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
KEY,
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(KEY)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
+7
View File
@@ -8,6 +8,7 @@
"name": "vantage-adminsite",
"version": "0.1.0",
"dependencies": {
"@paddle/paddle-js": "^1.6.4",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"next": "16.2.9",
@@ -1309,6 +1310,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@paddle/paddle-js": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.6.4.tgz",
"integrity": "sha512-ncfnS6I8mCX6krZ3Sgz2iAYivGmhdI81yt9mT6prtPj4Ipd9J3M12LCJRUFL4FB7BYeeuV04c33RSEnbZUBCaA==",
"license": "Apache-2.0"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+4 -3
View File
@@ -9,11 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"@paddle/paddle-js": "^1.6.4",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1"
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.11",
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"os/signal"
"syscall"
"github.com/mrhid6/vantage/agent/internal/config"
agentsync "github.com/mrhid6/vantage/agent/internal/sync"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync"
)
var Version = "dev"
+2 -2
View File
@@ -1,15 +1,15 @@
module github.com/mrhid6/vantage/agent
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
go 1.26
require (
golang.org/x/sys v0.20.0
google.golang.org/grpc v1.64.0
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/protobuf v1.34.1 // indirect
+2 -19
View File
@@ -11,12 +11,9 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
type streamWriter struct {
mu sync.Mutex
seq uint64
@@ -35,21 +32,10 @@ func (w *streamWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func WorkspacePath(workspaceID string) string {
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
}
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
@@ -102,7 +88,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
}
}
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
default:
default:
scriptPath = filepath.Join(dir, "step.sh")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
res.ExitCode = 1
@@ -126,7 +112,6 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
c.Stderr = sw
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
res.ExitCode = 124
res.Stderr = "[vantage] step timed out"
@@ -141,8 +126,6 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
return res
}
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
+5 -5
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
@@ -27,8 +27,6 @@ func New(serverURL string, useTLS bool) (*Client, error) {
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
dialOpts := []grpc.DialOption{
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
@@ -150,8 +148,10 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
return err
}
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) {
return c.client.ProxyStream(ctx)
}
+88
View File
@@ -131,6 +131,32 @@ type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type OpenProxyCmd struct {
ProxyId string `json:"proxy_id"`
Port uint32 `json:"port"`
}
type ProxyOpen struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
ProxyId string `json:"proxy_id"`
}
type ProxyClose struct {
Reason string `json:"reason,omitempty"`
}
type ProxyClientMsg struct {
Open *ProxyOpen `json:"open,omitempty"`
Data []byte `json:"data,omitempty"`
Close *ProxyClose `json:"close,omitempty"`
}
type ProxyServerMsg struct {
Data []byte `json:"data,omitempty"`
Close *ProxyClose `json:"close,omitempty"`
}
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
@@ -139,8 +165,15 @@ type ServerCommand struct {
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
RunStep *RunStepCmd `json:"run_step,omitempty"`
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
Ping *PingCmd `json:"ping,omitempty"`
}
// PingCmd is a server-originated liveness beat. It carries nothing and expects
// no reply: its arrival is the entire message. See the .proto for why gRPC
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
@@ -254,6 +287,51 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
return m, nil
}
type Vantage_ProxyStreamServer interface {
Send(*ProxyServerMsg) error
Recv() (*ProxyClientMsg, error)
grpc.ServerStream
}
type vantageProxyStreamServer struct {
grpc.ServerStream
}
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
return s.ServerStream.SendMsg(m)
}
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
m := new(ProxyClientMsg)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_ProxyStreamClient interface {
Send(*ProxyClientMsg) error
Recv() (*ProxyServerMsg, error)
CloseSend() error
grpc.ClientStream
}
type vantageProxyStreamClient struct {
grpc.ClientStream
}
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
m := new(ProxyServerMsg)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
@@ -263,6 +341,7 @@ type VantageClient interface {
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
}
type UnimplementedVantageServer struct{}
@@ -349,3 +428,12 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
}
return &vantageCommandStreamClient{stream}, nil
}
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
if err != nil {
return nil, err
}
return &vantageProxyStreamClient{stream}, nil
}
+9 -8
View File
@@ -1,5 +1,3 @@
package inventory
import (
@@ -10,7 +8,7 @@ import (
"syscall"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
func collect(r *pb.InventoryReport, includeStatic bool) {
@@ -42,11 +40,11 @@ func cpuSample() (idle, total uint64) {
defer f.Close()
sc := bufio.NewScanner(f)
if sc.Scan() {
fields := strings.Fields(sc.Text())
fields := strings.Fields(sc.Text())
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 {
if i == 3 {
idle = n
}
}
@@ -146,11 +144,14 @@ func partitions() []pb.PartitionReport {
if syscall.Statfs(fields[1], &st) != nil {
continue
}
total := st.Blocks * uint64(st.Bsize)
free := st.Bavail * uint64(st.Bsize)
bsize := uint64(st.Bsize)
total := st.Blocks * bsize
// Bfree, not Bavail: the difference is the root-reserved 5% on ext4,
// which is not used space. df counts it the same way.
used := (st.Blocks - st.Bfree) * bsize
out = append(out, pb.PartitionReport{
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
TotalBytes: total, UsedBytes: total - free,
TotalBytes: total, UsedBytes: used,
})
}
return out
+4 -3
View File
@@ -1,12 +1,13 @@
//go:build !linux
//go:build !linux && !windows
// Inventory collection is Linux-only. This no-op stands in everywhere else.
// Inventory collection has Linux and Windows implementations. This no-op stands
// in everywhere else.
//
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
// without it this file compiles on Linux too and collides with collect_linux.go.
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
func collect(r *pb.InventoryReport, includeStatic bool) {}
+183
View File
@@ -0,0 +1,183 @@
package inventory
import (
"fmt"
"runtime"
"time"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
// x/sys/windows exposes neither of these two, so they are bound by hand.
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
)
func collect(r *pb.InventoryReport, includeStatic bool) {
r.CPU.UsagePct = cpuUsage()
// Windows has no load average. Left at zero; the UI already treats it as
// optional because it is omitempty on the wire.
m := memoryStatus()
if m.TotalPhys > m.AvailPhys {
r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys
}
// TotalPageFile is the commit limit — physical memory plus the pagefile —
// so the pagefile alone is the difference.
swapTotal := sub(m.TotalPageFile, m.TotalPhys)
swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys))
if swapUsed > swapTotal {
swapUsed = swapTotal
}
r.SwapUsed = swapUsed
if includeStatic {
r.Memory.TotalBytes = m.TotalPhys
r.SwapTotal = swapTotal
r.CPU.Model, r.CPU.Cores = cpuStatic()
r.Kernel = kernel()
r.Partitions = partitions()
}
}
func sub(a, b uint64) uint64 {
if a > b {
return a - b
}
return 0
}
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
func memoryStatus() memoryStatusEx {
var m memoryStatusEx
m.Length = uint32(unsafe.Sizeof(m))
r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
if r == 0 {
return memoryStatusEx{}
}
return m
}
func systemTimes() (idle, total uint64, ok bool) {
var idleFT, kernelFT, userFT windows.Filetime
r, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idleFT)),
uintptr(unsafe.Pointer(&kernelFT)),
uintptr(unsafe.Pointer(&userFT)),
)
if r == 0 {
return 0, 0, false
}
ft := func(f windows.Filetime) uint64 {
return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime)
}
// Kernel time already includes idle time, so kernel+user is the whole.
return ft(idleFT), ft(kernelFT) + ft(userFT), true
}
func cpuUsage() float64 {
i1, t1, ok := systemTimes()
if !ok {
return 0
}
time.Sleep(100 * time.Millisecond)
i2, t2, ok := systemTimes()
if !ok || t2 <= t1 {
return 0
}
return (1 - float64(i2-i1)/float64(t2-t1)) * 100
}
func cpuStatic() (model string, cores int) {
cores = runtime.NumCPU()
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
if err != nil {
return
}
defer k.Close()
if s, _, err := k.GetStringValue("ProcessorNameString"); err == nil {
model = s
}
return
}
func kernel() string {
v := windows.RtlGetVersion()
return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
}
func partitions() []pb.PartitionReport {
buf := make([]uint16, 256)
n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0])
if err != nil || n == 0 {
return nil
}
var out []pb.PartitionReport
for _, root := range splitNullStrings(buf[:n]) {
rootPtr, err := windows.UTF16PtrFromString(root)
if err != nil {
continue
}
// Fixed disks only: network shares can hang, and removable drives
// would appear and vanish between snapshots.
if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED {
continue
}
var free, total, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(rootPtr, &free, &total, &totalFree); err != nil {
continue
}
fsBuf := make([]uint16, 32)
var fstype string
if err := windows.GetVolumeInformation(rootPtr, nil, 0, nil, nil, nil, &fsBuf[0], uint32(len(fsBuf))); err == nil {
fstype = windows.UTF16ToString(fsBuf)
}
out = append(out, pb.PartitionReport{
Device: root,
Mountpoint: root,
Fstype: fstype,
TotalBytes: total,
UsedBytes: total - totalFree,
})
}
return out
}
// splitNullStrings splits the NUL-separated, double-NUL-terminated block that
// GetLogicalDriveStrings writes.
func splitNullStrings(b []uint16) []string {
var out []string
start := 0
for i, c := range b {
if c != 0 {
continue
}
if i > start {
out = append(out, windows.UTF16ToString(b[start:i]))
}
start = i + 1
}
return out
}
+1 -3
View File
@@ -1,8 +1,6 @@
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
+4 -12
View File
@@ -1,6 +1,3 @@
package monitors
import (
@@ -9,13 +6,12 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/checker"
"github.com/mrhid6/vantage/agent/internal/config"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
const syncInterval = 30 * time.Second
type runner struct {
@@ -23,13 +19,10 @@ type runner struct {
cancel context.CancelFunc
}
func Run(ctx context.Context, cfg *config.Config) {
active := map[string]*runner{}
var mu sync.Mutex
results := make(chan pb.CheckResult, 64)
go reporter(ctx, cfg, results)
@@ -128,7 +121,6 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
}
}
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
+114
View File
@@ -0,0 +1,114 @@
// Package agentproxy relays a single TCP connection between a local service and
// the control plane, so a control plane that cannot route to this host's network
// can still open a console session.
//
// The dial host is hardcoded to loopback. The control plane supplies only a
// port, and nothing in this package can be made to dial anywhere else.
package agentproxy
import (
"errors"
"fmt"
"io"
"net"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
const (
loopbackHost = "127.0.0.1"
chunkSize = 32 * 1024
dialTimeout = 10 * time.Second
)
// Stream is the agent's half of a ProxyStream.
type Stream interface {
Send(*pb.ProxyClientMsg) error
Recv() (*pb.ProxyServerMsg, error)
CloseSend() error
}
// Open dials the local port, announces itself on the stream, and relays until
// either side ends. A refused dial is reported as an explicit close so the
// operator sees a reason rather than a hang.
func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error {
conn, dialErr := net.DialTimeout("tcp",
net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout)
if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{
ServerId: serverID,
AgentToken: agentToken,
ProxyId: proxyID,
}}); err != nil {
if conn != nil {
_ = conn.Close()
}
return fmt.Errorf("send open: %w", err)
}
if dialErr != nil {
_ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{
Reason: "dial_refused: " + dialErr.Error(),
}})
_ = stream.CloseSend()
return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr)
}
defer conn.Close()
return relay(conn, stream)
}
func relay(conn net.Conn, stream Stream) error {
errCh := make(chan error, 2)
// local service -> control plane
go func() {
buf := make([]byte, chunkSize)
for {
n, err := conn.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil {
errCh <- sendErr
return
}
}
if err != nil {
errCh <- err
return
}
}
}()
// control plane -> local service
go func() {
for {
msg, err := stream.Recv()
if err != nil {
errCh <- err
return
}
if msg.Close != nil {
errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason)
return
}
if len(msg.Data) > 0 {
if _, err := conn.Write(msg.Data); err != nil {
errCh <- err
return
}
}
}
}()
err := <-errCh
_ = conn.Close()
_ = stream.CloseSend()
if errors.Is(err, io.EOF) {
return nil
}
return err
}
+240 -56
View File
@@ -17,14 +17,15 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/config"
agentexec "github.com/mrhid6/vantage/agent/internal/exec"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"github.com/mrhid6/vantage/agent/internal/inventory"
"github.com/mrhid6/vantage/agent/internal/keys"
"github.com/mrhid6/vantage/agent/internal/monitors"
"github.com/mrhid6/vantage/agent/internal/updates"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
)
func Run(ctx context.Context, cfg *config.Config, version string) error {
@@ -34,7 +35,6 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
defer client.Close()
if cfg.PreRegToken != "" {
log.Println("registering with server...")
hostname, _ := os.Hostname()
@@ -64,22 +64,17 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
return fmt.Errorf("no agent token available registration required")
}
go runCommandStream(ctx, cfg)
go runUpdateCheck(ctx, cfg)
go runInventory(ctx, cfg)
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
if err := poll(client, cfg, version); err != nil {
log.Printf("poll error: %v", err)
}
@@ -123,11 +118,36 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return nil
}
// How long a command stream must survive before it counts as having worked.
// Past this, the next drop is treated as a fresh incident rather than as the
// continuation of a run of failures.
const streamHealthyAfter = time.Minute
// Stream staleness. The server beats every 20s, so 70s tolerates three missed
// beats before the stream is written off — high enough that a slow network or a
// briefly busy server does not cost a reconnect, low enough that an agent is
// not uncommandable for minutes after a control-plane restart.
const (
streamStaleAfter = 70 * time.Second
streamStaleCheck = 10 * time.Second
// How often a healthy stream reports itself. Also the interval at which an
// agent talking to a control plane too old to send heartbeats says so —
// that agent is running without a watchdog, and the journal should not be
// silent about it.
pingSummaryInterval = 5 * time.Minute
)
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
// Two minutes was the old ceiling, and it was reached far too easily. The
// command stream is what makes this agent controllable at all: while it is
// down, workflows and console sessions fail as "agent offline" even though
// SyncKeys keeps polling happily and the fleet list still shows the server
// active. A shorter ceiling costs a few reconnect attempts; the old one cost
// two minutes of an agent that looks fine and answers nothing.
const maxBackoff = 30 * time.Second
for {
select {
@@ -136,22 +156,45 @@ func runCommandStream(ctx context.Context, cfg *config.Config) {
default:
}
if err := connectAndHandleStream(ctx, cfg); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
}
} else {
started := time.Now()
err := connectAndHandleStream(ctx, cfg)
if ctx.Err() != nil {
return
}
// A stream that stayed up is evidence the control plane is reachable,
// whatever ended it. Without this the backoff only ever climbed:
// connectAndHandleStream returns an error on *every* stream end,
// including a healthy one dropped by a routine deploy, so an agent
// pinned itself at the ceiling after a handful of ordinary restarts and
// stayed there for the rest of its life.
if time.Since(started) >= streamHealthyAfter {
backoff = time.Second
}
// The uptime is in the line because it is what distinguishes a stream
// that never worked from one that ran for hours and was dropped by a
// deploy — and it is the same measure that decides whether the backoff
// resets, so a reader can see why the delay is what it is.
up := time.Since(started).Truncate(time.Second)
if err != nil {
log.Printf("command stream error after %s: %v, reconnecting in %s", up, err, backoff)
} else {
log.Printf("command stream closed after %s, reconnecting in %s", up, backoff)
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
@@ -162,7 +205,13 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
}
defer client.Close()
stream, err := client.CommandStream(ctx)
// Cancelling this context is what unblocks Recv when the stream has gone
// quiet. Without it the watchdog below would have no way to interrupt a
// read that is never going to return.
streamCtx, abandon := context.WithCancel(ctx)
defer abandon()
stream, err := client.CommandStream(streamCtx)
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
@@ -175,11 +224,8 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
return fmt.Errorf("send auth: %w", err)
}
log.Println("command stream connected")
log.Printf("command stream connected to %s", cfg.ServerURL)
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
sendMu.Lock()
@@ -187,11 +233,93 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
return stream.Send(msg)
}
// Stream liveness, tracked here rather than left to gRPC keepalive.
//
// Keepalive operates on the transport, and behind an L7 proxy the transport
// ends at the proxy: it answers pings whether or not the server behind it
// is still running. A control-plane pod that dies therefore leaves this
// agent blocked in Recv on a stream that will never deliver another message
// and never error, while the control plane dispatches commands into it and
// the operator watches nothing happen.
//
// The watchdog only arms once a ping has actually been seen. A server too
// old to send them must not be treated as dead — that would put the agent
// in a reconnect loop against a control plane that is working perfectly.
var (
lastMu sync.Mutex
lastRecv = time.Now()
pinged bool
beats int
)
markRecv := func(isPing bool) {
lastMu.Lock()
lastRecv = time.Now()
if isPing {
beats++
// Logged once per stream, because it is the moment the agent starts
// holding the control plane to account: before this the watchdog is
// disarmed and a dead stream would go unnoticed indefinitely.
if !pinged {
pinged = true
log.Printf("command stream heartbeat detected, watchdog armed (%s threshold)", streamStaleAfter)
}
}
lastMu.Unlock()
}
go func() {
t := time.NewTicker(streamStaleCheck)
defer t.Stop()
// Reported periodically rather than per beat: at one every 20s the
// journal would be nothing else. The count is what makes a partial
// failure visible — beats arriving but fewer than expected is a
// different problem from beats stopping altogether.
summary := time.NewTicker(pingSummaryInterval)
defer summary.Stop()
for {
select {
case <-streamCtx.Done():
return
case <-summary.C:
lastMu.Lock()
n, armed := beats, pinged
beats = 0
lastMu.Unlock()
if armed {
log.Printf("command stream healthy, %d heartbeats in the last %s", n, pingSummaryInterval)
} else {
log.Printf("command stream up but sending no heartbeats; "+
"control plane predates them, watchdog stays disarmed (last message %s ago)",
time.Since(lastRecv).Truncate(time.Second))
}
case <-t.C:
lastMu.Lock()
idle, armed := time.Since(lastRecv), pinged
lastMu.Unlock()
if armed && idle > streamStaleAfter {
log.Printf("command stream silent for %s (threshold %s), assuming it is dead and reconnecting",
idle.Truncate(time.Second), streamStaleAfter)
abandon()
return
}
}
}
}()
for {
cmd, err := stream.Recv()
if err != nil {
return fmt.Errorf("recv: %w", err)
}
markRecv(cmd.Ping != nil)
// Pings carry nothing and are not acknowledged; being received is their
// whole purpose.
if cmd.Ping != nil {
continue
}
if cmd.GenerateKey != nil {
go handleGenerateKey(cfg, cmd)
@@ -208,6 +336,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
if cmd.CleanupWorkspace != nil {
go handleCleanupWorkspace(cmd)
}
if cmd.OpenProxy != nil {
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
}
if cmd.RunStep != nil {
go func(rc *pb.RunStepCmd, cid string) {
emit := func(seq uint64, data []byte) {
@@ -219,7 +350,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
}
res := agentexec.RunStep(rc, emit)
res.CommandId = cid
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
@@ -279,8 +410,6 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
}
}
func runInventory(ctx context.Context, cfg *config.Config) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
@@ -298,7 +427,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
}
}
report(true)
report(true)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
tick := 0
@@ -308,7 +437,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
return
case <-ticker.C:
tick++
report(tick%30 == 0)
report(tick%30 == 0)
}
}
}
@@ -321,7 +450,6 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
}
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return
@@ -340,6 +468,29 @@ func handleCleanupWorkspace(cmd *pb.ServerCommand) {
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
}
// handleOpenProxy relays one console connection. It uses its own gRPC
// connection so console traffic never shares a stream with commands, key sync
// or workflow output.
func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err)
return
}
defer client.Close()
stream, err := client.ProxyStream(ctx)
if err != nil {
log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err)
return
}
log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port)
if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil {
log.Printf("proxy %s: %v", cmd.ProxyId, err)
}
}
func handleDeleteKey(cmd *pb.ServerCommand) {
label := cmd.DeleteKey.Label
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
@@ -363,21 +514,19 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
}
u := cmd.UpdateAgent
arch := runtime.GOARCH
arch := runtime.GOARCH
tag := "agent%2Fv" + u.Version
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
tmpBin := "/tmp/vantage-agent-update"
if err := downloadFile(binaryURL, tmpBin); err != nil {
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
return
}
checksumData, err := httpGetBytes(checksumURL)
if err != nil {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
@@ -402,11 +551,6 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
exec.Command("systemctl", "restart", "vantage-agent").Run()
}
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
tag := "agent%2Fv" + u.Version
@@ -433,18 +577,59 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
}
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
if err := up.Start(); err != nil {
// The MSI stops the vantage-agent service as part of the upgrade. Anything
// descended from this process is killed with it, so msiexec must not be a
// child: run it from a scheduled task, which is parented to the Task
// Scheduler service instead.
if err := launchDetachedUpdate(msiPath, logPath, cmd.CommandId); err != nil {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
return
}
log.Printf("scheduled msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
}
const updateTaskName = "VantageAgentUpdate"
func launchDetachedUpdate(msiPath, logPath, commandID string) error {
scriptPath := filepath.Join(os.TempDir(), "vantage-agent-update.cmd")
script := fmt.Sprintf("@echo off\r\n"+
"timeout /t 5 /nobreak >nul\r\n"+
"msiexec /i \"%s\" /qn /norestart /l*v \"%s\"\r\n"+
"schtasks /delete /tn %s /f >nul 2>&1\r\n"+
"del /f /q \"%s\" >nul 2>&1\r\n"+
"(goto) 2>nul & del /f /q \"%%~f0\"\r\n",
msiPath, logPath, updateTaskName, msiPath)
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
return fmt.Errorf("write update script: %w", err)
}
// Stale task from a previous attempt would make /create fail even with /f
// if it is still running, so tear it down first and ignore the result.
exec.Command("schtasks", "/end", "/tn", updateTaskName).Run()
exec.Command("schtasks", "/delete", "/tn", updateTaskName, "/f").Run()
create := exec.Command("schtasks", "/create",
"/tn", updateTaskName,
"/tr", `"`+scriptPath+`"`,
"/sc", "once",
// Already in the past: the task never fires on its own, only via /run.
"/st", "00:00",
"/ru", "SYSTEM",
"/rl", "HIGHEST",
"/f")
if out, err := create.CombinedOutput(); err != nil {
return fmt.Errorf("schtasks create: %v: %s", err, strings.TrimSpace(string(out)))
}
if out, err := exec.Command("schtasks", "/run", "/tn", updateTaskName).CombinedOutput(); err != nil {
return fmt.Errorf("schtasks run: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
resp, err := http.Get(url)
if err != nil {
return err
}
@@ -462,7 +647,7 @@ func downloadFile(url, dest string) error {
}
func httpGetBytes(url string) ([]byte, error) {
resp, err := http.Get(url)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
@@ -554,7 +739,6 @@ func localIP() string {
return ""
}
func GenerateAndUpload(cfg *config.Config, label string) error {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
+290 -66
View File
@@ -61,7 +61,7 @@ vantage/
│ ├── grpc/ # gRPC server + generated pb
│ ├── models/ # MongoDB documents
│ ├── monitorsched/ # server-side monitor scheduler
│ ├── notify/ # smtp, http, templating, dispatch
│ ├── notify/ # channel dispatch: http, discord, slack, telegram, smtp
│ └── services/ # business logic + migrations
├── web/ # the application UI (authenticated)
│ ├── app/(app)/ # authed routes
@@ -77,7 +77,6 @@ vantage/
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact
│ ├── mail/ # SMTP
│ └── store/ # Mongo connect helper
├── admin/ # licensing authority: the only signer
│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP
@@ -88,14 +87,20 @@ vantage/
│ ├── inject/ # licence write path into the control plane
│ ├── cloudprov/ # instance write path: creates instances + owners
│ ├── licensing/ # Issue, LinkInstance, Relink
│ ├── mail/ # verification and licence delivery
│ ├── 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
@@ -115,9 +120,9 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
### Workflows
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to MongoDB (`workflow_log_lines`, one document per line); the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Monitors
@@ -133,7 +138,98 @@ Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed tw
### Browser console
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel`
upgrades to a WebSocket and proxies to **guacd** using `github.com/wwt/guac`.
guacd never dials the managed server. The server binds a single-use ephemeral
listener, pushes `OpenProxyCmd` down the agent's command stream, and the agent
opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** —
the host is hardcoded agent-side, so the control plane can name only a port.
This is what makes the console work on Vantage Cloud, where the customer's
server is behind NAT on a private address. It also means the console now
**requires a live agent** on every deployment: `consoleConnect` answers 409
`agent_offline` rather than hanging.
**guacd's Service is headless on purpose.** The server resolves `GUACD_ADDR` to
build the allow-list of sources permitted to claim a relay listener; a ClusterIP
resolves to the Service's virtual address while guacd connects from its *pod*
IP, so every relay connection is rejected and every session dies with
`waiting for guacd: i/o timeout`. Compose is immune — there the name resolves to
the address that connects.
SSH connections authenticate with a stored private key; RDP/VNC credentials are
encrypted, single-use, and consumed when the tunnel opens. None of them reach
the agent — the session is negotiated end-to-end between guacd and the target
daemon, so the agent relays bytes it cannot read.
### Running more than one server replica
An agent's `CommandStream` terminates on exactly **one** server process. Every
piece of coordination below exists because of that single fact: with several
replicas, the process asked to do something to an agent is almost never the
process holding that agent's stream.
`server/internal/bus` is the Redis message bus that closes the gap. It adds no
infrastructure — Redis was already required for sessions — and it is **not
optional on a single-replica deployment**: dispatch takes the bus path always,
so the code running in production is the code running everywhere, rather than a
rare cross-pod branch that only fails under load.
| Concern | How it crosses replicas |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue. The envelope carries `node`, the presence holder resolved at publish time, and a pod ignores envelopes addressed elsewhere: the channel is a fan-out, and during a reconnect a half-open stream's pod is still subscribed. Unaddressed, it could ack first and queue the command onto a dead stream — the operator told it worked, the agent never seeing it. Presence renewal is owner-only (`RenewPresence`) for the same reason: a blind `SET` let the stale pod steal the key back every 10s |
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
| Console relay | **not routed to the owner pod at all.** A `ProxyStream` is its own HTTP/2 request and an L7 proxy balances requests, not connections, so it does not follow the command stream — the listener therefore cannot be bound in advance. Whichever pod receives the stream binds it and announces **its own** address on `vantage:proxyaddr:<proxy_id>`; `vantage:proxypending:<proxy_id>` (30s, consumed atomically) is what authorises the claim, and the failure reason comes back on `vantage:proxyend:<proxy_id>` |
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
with a `workflow_log_seq` counter document per run/server). Two pods write the
same log concurrently — the run's pod emits markers, the agent's pod emits
output — so ordering only means anything if both draw sequence numbers from the
same counter. `StepRun.log_offset` is that sequence number now, not a byte
offset. Writes are batched (128 lines or 250ms) and capped: 8 KB per line,
200k lines per server-run, after which one final `[vantage] log truncated`
marker is written and the rest is dropped. Without that cap a `yes` in a step
is a database incident. **Nothing writes to `/data` any more**, which is why
`server.persistence` now defaults to off and `VANTAGE_WORKFLOW_LOG_DIR` is gone.
**Shutdown order is load-bearing.** `main` traps SIGTERM, stops gRPC
(`GracefulStop`, 10s cap) and only then drains HTTP. Each `CommandStream`
handler releases its agent's presence claim on return, so a killed process
leaves `vantage:agent:<server_id>` behind for the rest of its 30s TTL — during
which other replicas dispatch to a pod that has exited and the caller sees
`agent offline` for a perfectly healthy agent. Draining HTTP first would hold
those claims for the length of the drain, which is why gRPC goes first. The
chart's `server.terminationGracePeriodSeconds` (30s) must stay above the
10s + 10s the stop sequence needs, or the kubelet SIGKILLs mid-shutdown and the
handling buys nothing.
The agent side of the same failure: `runCommandStream` resets its backoff only
after a stream that survived `streamHealthyAfter`. `connectAndHandleStream`
returns an error on *every* stream end, healthy ones included, so without that
reset the backoff only ever climbed — an agent pinned itself at the ceiling
after a handful of ordinary deploys and stayed there. The ceiling is 30s, not
minutes, because while the stream is down the agent still polls `SyncKeys` and
still reads as `active` in the fleet list while answering no commands at all.
**The leader lock is not an optimisation.** N replicas each running the monitor
scheduler means each check fires N times, each incident notification reaches the
customer N times, and each hourly rollup is written N times; N reapers race to
purge the same Free instance. `monitorsched`, `StartReaper`, `StartLogSweeper`,
`StartAuditSweeper` and the offline sweep therefore all run inside one
`RunAsLeader("housekeeping", …)` — one role, one lock. Each takes a context
cancelled the instant leadership is lost, and must return when it is.
Redis rather than a Kubernetes `Lease` so Compose takes the identical path: one
implementation to reason about, not two.
Two deployment requirements come with `replicaCount > 1`: every replica must
share **one** Redis (a per-pod Redis partitions the bus and every agent looks
offline to two thirds of the fleet), and `POD_IP` must be set — the chart does
it from the downward API — because `PROXY_ADVERTISE_HOST` names the Service, and
a Service cannot address the one pod holding a console listener.
### Inventory and OS updates
@@ -147,19 +243,25 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
`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.
`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. |
| 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` and `admin` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
`site`, `sitesvc`, `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.
```bash
# self-hosted install — no marketing site, no sitesvc
@@ -191,6 +293,37 @@ person is later granted. `GET /auth/verify` therefore peeks before it consumes:
a token belonging to a passwordless row answers `{"needs_password":true}` and is
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.
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 —
`SendVerification`, `SendExpiring`, `SendMonitorAlert`, `SendEnquiry` and the
rest, grouped by owner into `account.go`, `licence.go`, `billing.go`,
`monitor.go` and `contact.go`.
Every message is `multipart/alternative`, so each one is two templates:
`templates/<name>.html.tmpl` and `.txt.tmpl`, embedded with `go:embed`. They
define `subject`, `title`, `pill` and `body`; `layout.html.tmpl` and
`layout.txt.tmpl` provide the chrome and the helper templates (`p`, `lead`,
`button`, `well`, `note`, `rows`, `chip`) that the bodies compose. One template
set is parsed per message rather than one big set, because every message
defines those same four names. **`subject` is defined in the txt file only** —
`html/template` would escape an ampersand in an instance name and mail clients
show subjects verbatim.
`shared/mail/render_test.go` renders all of them and fails if a template exists
that no case covers, which is the only thing standing between a mistyped field
and a boot-time panic — the templates are parsed in `init()`.
### Shared provisioning
`shared/provision` (`instance.go`, `slug.go`, `user.go`) holds the slug rules, reserved names and instance/user creation logic that both `server` and `admin/internal/cloudprov` need, so there is no longer a second copy to drift: `cloudprov.CreateInstance` calls straight into it to create a control-plane instance and its owner from a customer request.
@@ -203,7 +336,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no
runtime dependency on admin**. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.
`instance_members` in admin's database is only admin's *index* of those
`instance_members` in admin's database is only admin's _index_ of those
projections; the control-plane row is the access. That is why a failed
`instance_members` insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.
@@ -235,7 +368,7 @@ password-change endpoint at all, so there is no competing writer for the hash.
- **Host/org guard**`APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
Unique indexes are a **security property**, not an optimisation. `users` is
unique on `(instance_id, email)` — one address is one user *within* an instance,
unique on `(instance_id, email)` — one address is one user _within_ an instance,
and the same address may hold a user in several instances, because an account's
people are projected into each instance they are granted. This is sufficient only
because **every lookup by email is scoped by instance**; there is deliberately no
@@ -262,7 +395,18 @@ service Vantage {
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
The server sends one every 20s on an otherwise idle command stream; the agent
treats 70s of silence as a dead stream and reconnects. Keepalive cannot do this
job behind an L7 proxy: the agent's HTTP/2 connection terminates at the proxy,
which answers pings on its own behalf, so a control-plane pod that dies leaves
the agent blocked in `Recv` on a stream that never delivers another message and
never errors — commands dispatched into it are silently lost while `SyncKeys`
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`.
@@ -273,6 +417,7 @@ Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions
Unauthenticated:
```
GET /healthz /readyz # liveness / readiness probes
GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
@@ -327,6 +472,7 @@ 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
```
Customer-session (`/api`), every instance resolved through `ownedInstance`:
@@ -338,6 +484,11 @@ POST /instances/:id/renew # Free renewal; refuses outside t
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
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 # create a paid-checkout placeholder (awaiting_link, no licence)
POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue
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
@@ -360,14 +511,20 @@ POST /instances/:id/issue · /instances/:id/relink
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
GET,PUT /catalogue
GET,PUT /instances/:id/entitlement
GET /health/injection
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 creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -379,9 +536,10 @@ Notes that are not obvious from the structs:
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `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.
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.
@@ -455,34 +613,63 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
## Environment Variables (server)
| Name | Required | Notes |
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
| `MONGO_DB` | no | default `vantage` |
| `REDIS_ADDR` | no | default `localhost:6379` |
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
| `GUACD_ADDR` | no | default `guacd:4822` |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
| Name | Required | Notes |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
| `MONGO_DB` | no | default `vantage` |
| `REDIS_USERNAME` | no | Redis 6+ ACL user. Leave empty for a legacy `requirepass` instance — go-redis then sends AUTH with one argument instead of two |
| `REDIS_PASSWORD` | no | empty for an unauthenticated Redis |
| `REDIS_ADDR` | no | default `localhost:6379` |
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
| `GUACD_ADDR` | no | default `guacd:4822` |
| `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 |
| `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 |
| `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 |
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 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 |
| `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 |
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk.
### Ingress (Helm, Traefik)
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only.
`ingress.enabled` publishes **two** hostnames, because the two audiences arrive over different protocols:
| Values | Route |
| -------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `ingress.web.host` (+ `web.extraHosts`) | browsers → `web:3000` |
| `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.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.
**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.
**`server.env.grpcHost` is not derived from `ingress.grpc.host`, and the chart refuses to render if they disagree.** Agents dial whatever `grpcHost` says, and it is baked into every install one-liner; left pointing at the in-cluster Service while agents arrive through the ingress, every install succeeds and every agent then fails to connect, with nothing in the control plane explaining why. Guessing at the port (443? 9090?) would be worse than stopping.
TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) **or** `certResolver` (Traefik ACME). Setting neither while `tls.enabled` produces a TLS router with no certificate, so Traefik serves its self-signed default — which looks valid and is trusted by nothing. NOTES.txt warns on install rather than the chart failing, since it is a real if unusual choice behind another terminator.
---
`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.
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`. `server` now reads the same three, so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it, so in Docker those credentials remain admin's alone.
---
@@ -504,15 +691,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
All three apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
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 |
| 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 |
`adminsite/app/globals.css` holds `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 three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
`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.
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
@@ -569,7 +761,7 @@ GOOS=linux GOARCH=amd64 go build \
### `server-deploy.yml` — triggered on every push to `main`
Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`.
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
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:
@@ -580,39 +772,60 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
**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` | their own directory only |
| 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 |
`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.
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `API_URL`, `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.
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.
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point — every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it — the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on. It authenticates with `REGISTRY_USER` + **`RELEASE_TOKEN`** — the pair `server-deploy.yml` actually uses for `docker login`. `REGISTRY_PASSWORD` is listed in the secrets table below but set by no workflow; passing an unset secret yields an empty password and Gitea answers `401 Failed to authenticate user`, which reads like a scope problem on a token that was never sent. The publish step therefore checks both are non-empty before it calls curl. `RELEASE_TOKEN` needs `write:package` in addition to `write:release`.
```bash
helm repo add vantage https://gitea.hostxtra.co.uk/api/packages/mrhid6/helm
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
```
### Secrets / variables
| Name | Type | Value |
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | Variable | baked into the `web` image at build time |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **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. |
| `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. |
| 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` |
| `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. |
| `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. |
---
@@ -631,4 +844,15 @@ git push origin main # server + web deploy
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
- **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.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+12
View File
@@ -0,0 +1,12 @@
{
"kind": "vantage.step/v1",
"name": "Get Host Name",
"description": "",
"interpreter": "bash",
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"HOSTNAME"
],
"declared_inputs": [],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "List Directory",
"description": "",
"interpreter": "bash",
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "./",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Start Linux Service",
"description": "",
"interpreter": "bash",
"script": "echo \"starting service $serviceName\"\nsystemctl start $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to start service\"\n exit 1\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Stop Linux Service",
"description": "",
"interpreter": "bash",
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": ""
}
],
"secret_refs": []
}
+18
View File
@@ -0,0 +1,18 @@
{
"kind": "vantage.step/v1",
"name": "WGET",
"description": "",
"interpreter": "bash",
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"FILE_PATH"
],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": ""
}
],
"secret_refs": []
}
-23
View File
@@ -1,23 +0,0 @@
[Unit]
Description=Vantage Agent
Documentation=https://github.com/your-org/vantage
After=network.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=vantage-agent
# Security hardening
NoNewPrivileges=true
ProtectSystem=false
ProtectHome=false
[Install]
WantedBy=multi-user.target

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