Compare commits

..
45 Commits
Author SHA1 Message Date
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
mrhid6andClaude Opus 5 3d3be7465f fix(admin): re-key legacy plans before seeding, tolerant of a prior partial boot
Server Deploy / deploy (push) Successful in 1m5s
Boot ran SeedPlans before Backfill, so the fresh (self_hosted, professional)
seed row was inserted before pass 3 tried to rename the legacy self_hosted TIER
row into it — colliding on deployment_tier_unique and failing boot.

The plan re-key moves to MigrateLegacyPlans, called before SeedPlans so the
rename lands first and the seed no-ops on it. It is also self-healing: on a
database a crashed boot already seeded (self_hosted, professional) into, the
legacy row can no longer be renamed onto it, so it is dropped instead — its
instances are re-tiered and re-entitled from the surviving professional row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:03:17 +01:00
mrhid6andClaude Opus 5 c4e6ad5485 feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.

Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.

Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.

Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.

Docs: CLAUDE.md, spec index and plan 5 preamble updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:37:39 +01:00
mrhid6andClaude Opus 5 3fc726da9e feat(license): two deployments times three tiers, and two new limits
Plans are keyed on (deployment, tier) rather than tier alone, which is what
ends Free being cloud-only by construction - there is a self-hosted Free
plan now, so the one-per-account rule has to be enforced per deployment
instead of falling out of the plan table.

tier self_hosted becomes a legacy value no new licence carries.
NormaliseTier maps it to self-hosted Professional, which is what it always
granted, so blobs we cannot re-sign keep working.

Limits.FillUnset exists because a licence signed before a field existed
decodes it as 0, and 0 would read as no monitors and an audit log trimmed
to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:41:43 +01:00
mrhid6andClaude Opus 5 73b6b548f0 docs: implementation plan for spec 7, metered licensing
Ten tasks, each ending in something independently verifiable. The plan
stops at the Paddle boundary on purpose: spec 7 lands before plan 5, so
there is no client, no webhook and no checkout here, and the configurator
is built as a component mounted for staff first rather than as a customer
screen with nothing behind it.

Corrects two things the spec got wrong about the control plane. Feature
gating is already built and mounted, so Free tenants have already lost the
console and no customer email is owed. The only real gap is that
HandleOIDCCallback lacks the check HandleOIDCStart already has, which is
the half that completes a sign-in.

Adds the Free self-hosted lifecycle the spec called for and the first draft
of the plan missed: linking issues nothing today, and renewInstance
hardcodes a monthly term that would hand a self-hosted install a one-month
licence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:34:34 +01:00
mrhid6andClaude Opus 5 ff0caf5a90 docs: spec 7, metered licensing and the two-by-three plan matrix
Two deployments times three tiers, servers metered per month, console and
SSO opted into individually. plans is re-keyed on (deployment, tier); every
Paddle price ID moves out of plans into a new catalogue collection; a new
entitlements collection holds desired beside granted, and a licence is only
ever signed from granted.

Spec 5's plan is revised rather than followed: it assumes one price per
subscription and a metered plan has three or more. Nothing of it has
shipped, so the revision costs a rewrite of an unstarted plan.

Free stops being cloud-only by construction, which means the plan/instance
deployment comparison in licensing.Issue no longer enforces it and
checkFreeLimit has to count per deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:16:35 +01:00
mrhid6 87dc9fc858 inital paddle billing docs 2026-07-26 22:57:57 +01:00
mrhid6 dd306e4757 feat(license): the paste form is self-hosted only
Server Deploy / deploy (push) Successful in 2m14s
A cloud instance's licence is issued in HQ and written into the control
plane by admin/internal/inject, straight to the database. The customer has
nothing to paste, so /settings/license offered them a form that could only
ever fail — and on an expired cloud instance, failed at the exact moment
they were looking for a way out.

GET /license now reports `deployment`, and the page swaps the paste form
for a short panel saying where the licence comes from, with a link to the
portal when HQ_URL is set. That is the same treatment hq-managed members
already get in the members table: read-only here, and a pointer to where
it is actually managed.

POST /license refuses with 409 cloud_managed on a cloud deployment. Hiding
a form is a courtesy; this codebase's rule is that the API is the boundary,
and the endpoint was reachable regardless of what the page rendered. It
cannot break injection, which never goes through HTTP at all.

Verified: server builds and vets clean, web builds clean.
2026-07-26 20:42:53 +01:00
mrhid6 fa75d70475 fix(web): rebuild the licence page as the document it is
Server Deploy / deploy (push) Successful in 40s
This screen was never finished. It had no page padding, so it sat flush
against the sidebar while every other screen has p-8; it used raw button,
textarea and file inputs instead of the design system; and it rendered
state as "State: valid" in a sentence.

It also dropped `reason` entirely. An instance with an invalid licence was
told "State: invalid" and nothing else — not that the signature failed, not
that it was issued against a different instance, not what to do. That was
the real defect, and no layout survives having nowhere to put the most
important thing on the page.

A licence is a document: issued, dated, signed, carrying a reference you
quote to support. The page now reads that way, which is also how adminsite/
presents the same object from the issuing side — state as a label with a
shape, the reference on a mono record line, entitlement as keyed fields.

- a record panel opens the page: the state as a statement, the reason
  underneath in this app's own words rather than the API's identifiers,
  then Expires / Source / Instance ID as keyed fields. `source` and
  `days_remaining` were being returned and never shown
- counted limits become meters, because that is already how the console
  shows headroom on a server's disks — same question, same reading. They
  turn amber at 80% and red at the cap
- an unlimited allowance gets no bar. A full-width one would read as "at
  the limit", which is the opposite of what it means
- features render as included/not with a glyph as well as a colour
- the file input is a styled label over a visually hidden input, and now
  reports which file it loaded

Two things found reviewing my own work: Card's p-6 is emitted after p-4, so
`<Card className="p-4">` silently rendered at p-6 — the allowance cards pass
padding={false} instead. And the state had a coloured dot next to a coloured
word on a colour-ruled card, which is one telling too many; the dot is gone.

`Group` moves to components/settings/ so this page and /settings share the
band label rather than growing a second copy.
2026-07-26 20:35:31 +01:00
mrhid6 653bd5a755 refactor(web): members and SSO move onto the settings page
Server Deploy / deploy (push) Successful in 1m12s
/settings/instance held two cards — Members and Single sign-on — behind a
sidebar entry called "Instance", next to one called "Settings". Nothing in
either name told you which held what, and the split left both pages thin.

They are now the Access group at the top of /settings, above Monitoring and
Integrations. Six cards on one page needs sorting into groups or it reads as
a pile, so each group carries site/'s keyed-label eyebrow on a hairline.

The cards move to web/components/settings/ rather than into the page, which
would have made it ~600 lines. That is also where the Field and inputClass
pair now lives: the two pages each had a byte-identical copy, and folding
them together is exactly when three copies would have started to drift.

Members and SSO adopt SectionCard, which the rest of the page already used.
A card that kept its own header treatment would read as a different kind of
thing rather than another setting, which is the problem being fixed.

next.config.ts keeps a permanent redirect from the old path, so bookmarks
and any support reply linking it still land somewhere useful.
2026-07-26 20:19:04 +01:00
mrhid6 01eda1dbb0 feat(web): the console joins the shared design system
Server Deploy / deploy (push) Successful in 1m6s
web/ was the last app on its own palette — a neutral #0f1117 ground with an
indigo accent, unrelated to the logo navy that site/ and adminsite/ are built
on. It now draws from the same tokens, so all three apps are one system.

It stays locked to dark, taking site/'s dark values. That is what keeps
adminsite/'s light default meaningful: an operator with both open tells them
apart by the ground before clicking anything destructive, and now that both
are the same palette, the ground is the only thing left doing that work.

The colour names stay this app's own — text-primary, border, surface rather
than ink, rule, panel — because every screen already reads that way, and
adminsite/ already establishes that each app names the shared tokens after
its own subject.

Tokens are stored as RGB channels with the hex in a trailing comment. The
console leans on Tailwind opacity modifiers far more than the other two
(bg-danger/10, border-accent/50, ring-accent/30), and <alpha-value> only
compiles against channels; the comments keep the three token blocks
diffable by eye.

Beyond colour:
- radii collapse to site/'s 4px in tailwind.config.ts rather than rewriting
  ~140 rounded-lg classes; rounded-full is untouched for dots and pills
- badges become site/'s chip — mono, uppercase, tracked, currentColor rule,
  no fill — keeping their dot so state is never colour alone
- table column heads take the keyed-label idiom, at text-secondary rather
  than tertiary, which lands under 4.5:1 at that size
- sidebar marks the active item with an accent bar, the device site/ uses
  for the chosen plan, instead of a filled pill that reads as pressable
- filled accent and danger buttons take accent-ink; the dark accent is a
  light blue and danger a coral, and white on either was unreadable
- login's packet pulses shift from green to the accent, so the sign-in
  screen is the same two blues as the marketing hero

The last hex literals and stock-palette classes are gone; the only ones left
are NetworkBackground's canvas fills, which cannot read a CSS variable and
are commented with the token each came from.

Only the web image rebuilds from this.
2026-07-26 18:33:07 +01:00
mrhid6andClaude Opus 5 11ebd324ad ci: only rebuild images whose inputs changed
Server Deploy / deploy (push) Successful in 2m38s
Every push rebuilt all six images regardless of what it touched. A git diff
against github.event.before now gates each build step.

Two things this needs to work at all: fetch-depth 0, because the default
shallow clone has a single commit and nothing to diff against, and git
installed in the dind container, which had node and npm but not git.

The path mapping follows the build contexts rather than intuition — the Go
images use a root context and COPY shared/, so shared/ fans out to all
three, while the Next images use their own directory and cannot be affected
from outside it. Anything that leaves no trustworthy base commit — manual
run, new branch, force-push whose old head is gone — lists every tracked
file instead, so the fallback is one code path rather than two.

Known gap, documented: a repo variable change pushes no commit, so nothing
rebuilds. workflow_dispatch is the escape hatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:15:26 +01:00
mrhid6andClaude Opus 5 3a6c5ebae8 fix(admin): licence and plan features serialise as [], never null
Server Deploy / deploy (push) Successful in 5m22s
Free has no features, so a nil Go slice served "features": null. The
portal's type said string[], called .length on it, and took the page down.

The guarantee lives on a named Features type with its own MarshalJSON
rather than at each of the six places a licence or plan is serialised,
because the seventh is the one that would have been forgotten. Applying it
at marshal time also fixes rows already holding null in Mongo, which a
write-side fix alone would not.

Same class, found while checking the rest: staffListInstances built its
expiring filter from a nil []string, marshalling to $in: null, which Mongo
rejects — so the quiet week when nothing is expiring is precisely when the
staff Operations page's own query would have failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:10:07 +01:00
mrhid6andClaude Opus 5 34a0373eca feat(adminsite): one masthead, a shared page frame, collapsible instance records
Server Deploy / deploy (push) Successful in 53s
The console had components but no shell: a brand bar and a nav strip stacked
into 100px carrying eight words, no sign-out, no account identity, and an
Overview link hardcoded to text-accent so it read as the current page on
every screen. Nine pages each hand-rolled their own header.

AppBar replaces both bars and derives its active state from usePathname.
Settings moves into AccountMenu — it is your password, not a destination —
taking appearance with it, which finally sets the data-theme attribute the
token blocks have supported in both directions since they were written.
That leaves three customer destinations: Overview, People, Billing.

PageFrame adds a support rail so a page has a floor, and InstanceRecord
replaces InstanceCard with one component that opens and closes: an account
with a single instance used to render a third of a row of summary with its
substance a click away. It defaults open when the instance is the only one
or needs attention.

No plan card in the rail: tier, limits and expiry belong to a licence and a
licence belongs to one instance, so an account holding a Free cloud instance
and a Professional self-hosted one has no single plan. The rail carries only
what is account-wide.

Tokens and globals.css are untouched — they stay verbatim shared with site/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:45:53 +01:00
mrhid6andClaude Opus 5 6bf288f83e docs: phase 3, grants project rather than federate
Records why instance_members is an index and not the authority, why hqsync
is not part of inject, and why an invitation carries no password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:43:52 +01:00
262 changed files with 22029 additions and 3764 deletions
+82 -2
View File
@@ -4,16 +4,88 @@ on:
push:
branches:
- main
# Manual runs rebuild everything: there is no "before" commit to diff
# against, which the change detection below treats as "build it all". That
# is also the escape hatch for a repo VARIABLE change — editing API_URL or
# ADMIN_ENV pushes no commit, so nothing would rebuild on its own.
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-docker
container: docker:dind
steps:
- name: Setup Node
run: apk add --update nodejs npm
# git is needed twice over: actions/checkout clones with it, and the
# change detection below diffs with it.
- name: Setup
run: apk add --update nodejs npm git
- name: Checkout
uses: actions/checkout@v4
with:
# The default shallow clone has one commit, which cannot be
# diffed against the previous push.
fetch-depth: 0
- name: Work out what changed
id: changed
run: |
set -eu
BEFORE="${{ github.event.before }}"
ZERO="0000000000000000000000000000000000000000"
# Build everything whenever the comparison cannot be trusted:
# a manual run, a brand-new branch, or a force-push whose old
# head is no longer in the repository. Listing every tracked
# file makes every filter below match, so there is no second
# code path to keep correct.
if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
echo "No usable base commit — building every image."
git ls-files > /tmp/changed.txt
else
git diff --name-only "$BEFORE" HEAD > /tmp/changed.txt
fi
echo "--- changed files ---"
cat /tmp/changed.txt
echo "---------------------"
# A change to the workflow itself can change a build arg, and
# a build arg is baked into the image, so it rebuilds all.
if grep -qE '^\.gitea/workflows/' /tmp/changed.txt; then
ALL=1
else
ALL=0
fi
flag() {
name="$1"
pattern="$2"
if [ "$ALL" = "1" ] || grep -qE "$pattern" /tmp/changed.txt; then
echo "$name=true" >> "$GITHUB_OUTPUT"
echo "build $name"
else
echo "$name=false" >> "$GITHUB_OUTPUT"
echo "skip $name"
fi
}
# The three Go images build from the repo root and COPY
# shared/ plus their own directory, so shared/ rebuilds all
# three. proto/ is in server's list as insurance: the
# generated pb is committed under server/, but a proto change
# that someone regenerates in the same push should not depend
# on that ordering.
flag server '^(server/|shared/|proto/|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.
flag web '^web/'
flag site '^site/'
flag adminsite '^adminsite/'
- name: Log in to registry
run: |
@@ -22,6 +94,7 @@ jobs:
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push server image
if: steps.changed.outputs.server == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
# Root context: server depends on the shared module.
@@ -29,6 +102,7 @@ jobs:
docker push "$IMAGE"
- name: Build and push web image
if: steps.changed.outputs.web == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
@@ -39,6 +113,7 @@ jobs:
docker push "$IMAGE"
- name: Build and push site image
if: steps.changed.outputs.site == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
@@ -50,6 +125,7 @@ jobs:
docker push "$IMAGE"
- name: Build and push sitesvc image
if: steps.changed.outputs.sitesvc == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
# Root context: sitesvc depends on the shared module.
@@ -57,6 +133,7 @@ jobs:
docker push "$IMAGE"
- name: Build and push admin image
if: steps.changed.outputs.admin == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
# Root context: admin depends on the shared module.
@@ -64,11 +141,14 @@ jobs:
docker push "$IMAGE"
- name: Build and push adminsite image
if: steps.changed.outputs.adminsite == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/adminsite:latest"
docker build \
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
-t "$IMAGE" \
-f adminsite/Dockerfile adminsite/
docker push "$IMAGE"
+1
View File
@@ -2,6 +2,7 @@ node_modules
dist
build
.env
.env.bck
docs/*
!docs/superpowers/
.superpowers
+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"
)
+30 -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,
@@ -64,10 +71,21 @@ func main() {
idxCancel()
log.Fatalf("indexes: %v", err)
}
// Legacy plans are re-keyed BEFORE the seed, so the seed's fresh
// (self_hosted, professional) row cannot collide with the legacy self_hosted
// row's rename on deployment_tier_unique.
if err := models.MigrateLegacyPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("migrate legacy plans: %v", err)
}
if err := models.SeedPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("plan seed: %v", err)
}
if err := models.SeedCatalogue(idxCtx); err != nil {
idxCancel()
log.Fatalf("seed catalogue: %v", err)
}
if err := models.Backfill(idxCtx); err != nil {
idxCancel()
log.Fatalf("backfill: %v", err)
@@ -77,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})
}
+207 -83
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
@@ -221,8 +311,13 @@ func createInstance(c *gin.Context) {
// Pre-check the Free rule so we never create an instance we then cannot
// licence. licensing.Issue enforces it too; this is the friendly refusal.
//
// Scoped to cloud because that is what this endpoint creates. It MUST match
// checkFreeLimit's scoping — a pre-check stricter than the issuer refuses
// something that would have worked.
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": s.AccountID,
"deployment": license.DeploymentCloud,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
})
@@ -232,79 +327,16 @@ func createInstance(c *gin.Context) {
}
if n > 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this account already has a Free instance"})
"error": "this account already has a Free cloud instance"})
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{
@@ -322,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)
}
@@ -364,10 +396,16 @@ func renewInstance(c *gin.Context) {
return
}
// Free renews on its deployment's only term: monthly for cloud, annual for
// self-hosted. Reading it from TermsFor rather than hardcoding is what stops
// a self-hosted instance being handed a one-month licence.
terms := license.TermsFor(inst.Deployment)
term := terms[len(terms)-1]
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
Term: "monthly",
Term: term,
Reason: models.ReasonRenewal,
IssuedBy: "self-serve",
})
@@ -375,7 +413,9 @@ func renewInstance(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
inject.Deliver(ctx, lic)
// Cloud is injected; self-hosted is delivered to the customer, because their
// database is theirs and we cannot write to it.
deliver(c, inst, lic)
// Clear the notice log so the next term starts the sequence again. Issue has
// already set status back to active.
@@ -391,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)
}
}
@@ -414,6 +454,90 @@ var appLoginURL string
// SetAppLoginURL is called from main.
func SetAppLoginURL(v string) { appLoginURL = v }
// claimFree issues a Free licence on a linked self-hosted instance.
//
// The link step creates the row; this gives it a licence. They are separate
// because linking is about identity — proving which install is yours — and
// claiming is about entitlement, and a customer who links an install and then
// changes their mind should not have consumed their one Free allowance.
//
// Free is outside Paddle entirely, so there is no checkout, no subscription and
// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard;
// the count here exists to refuse politely before anything is written.
func claimFree(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
// Cloud Free is claimed at creation by POST /api/instances. Allowing it here
// too would be a second way to reach the same state, with its own bugs.
if inst.Deployment != license.DeploymentSelfHosted {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cloud instances get their Free licence when they are created"})
return
}
if inst.CurrentLicense != "" {
c.JSON(http.StatusConflict, gin.H{
"error": "this instance already has a licence"})
return
}
plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"})
return
}
if !plan.Active {
c.JSON(http.StatusForbidden, gin.H{
"error": "Free self-hosted is not currently offered"})
return
}
// The entitlement is written BEFORE the licence, so Issue snapshots it rather
// than falling back to the plan base. They are the same numbers today, but
// the ordering is what makes that a coincidence rather than a dependency.
if err := models.UpsertEntitlement(ctx, models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: license.DeploymentSelfHosted,
Tier: license.TierFree,
Term: "annual",
Desired: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
Granted: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
ResolvedLimits: plan.BaseLimits,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
// Annual, and not a choice. Self-hosted sells annual only because the
// term length is the revocation window for an offline licence.
Term: "annual",
Reason: models.ReasonNew,
IssuedBy: s.Email,
})
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrFreeLimit) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
deliver(c, inst, lic)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()})
c.JSON(http.StatusCreated, lic)
}
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
@@ -426,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)
}
}
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"errors"
"fmt"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
)
// entitlementBody is what a caller may set.
//
// Only Desired is writable. Granted is what a payment confirmed, and letting a
// form set it would let the portal grant itself a licence — which is the one
// thing this whole split exists to prevent. Staff promote Granted explicitly
// through a separate flag, because staff issuing a licence to somebody who has
// not paid is a real operation with a real reason, and it should be one they
// took on purpose and left an audit row for.
type entitlementBody struct {
Tier string `json:"tier"`
Term string `json:"term"`
Servers int `json:"servers"`
Features []string `json:"features"`
// Grant promotes Desired into Granted in the same write. Staff only.
Grant bool `json:"grant"`
}
// getEntitlement serves the customer's own view of one instance's configuration.
func getEntitlement(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
if errors.Is(err, models.ErrNoEntitlement) {
c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
func staffGetEntitlement(c *gin.Context) {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
return
}
ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
if errors.Is(err, models.ErrNoEntitlement) {
c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
// staffSetEntitlement writes an instance's configuration.
//
// This is the endpoint that makes metering usable before Paddle exists: staff
// configure, then issue. It does NOT issue — recording what an instance is
// allowed and signing a licence for it stay separate, so a bad configuration is
// a row to correct rather than a licence to supersede.
func staffSetEntitlement(c *gin.Context) {
ctx := c.Request.Context()
var body entitlementBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entitlement"})
return
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
return
}
tier := body.Tier
if tier == "" {
tier = inst.Tier
}
plan, err := models.GetPlan(ctx, inst.Deployment, tier)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("no plan for %s/%s", inst.Deployment, tier)})
return
}
if !termSold(inst.Deployment, body.Term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s", inst.Deployment, body.Term)})
return
}
if body.Servers < plan.BaseLimits.MaxServers &&
plan.BaseLimits.MaxServers != -1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s includes %d servers; cannot configure fewer",
plan.Name, plan.BaseLimits.MaxServers)})
return
}
desired := models.Config{
Servers: body.Servers,
Features: models.Features(body.Features).OrEmpty(),
}
// Start from whatever is already granted, so writing a desired change never
// silently alters what the instance is currently allowed.
granted := desired
existing, err := models.GetEntitlement(ctx, inst.InstanceID)
switch {
case err == nil:
if !body.Grant {
granted = existing.Granted
}
case errors.Is(err, models.ErrNoEntitlement):
// First write. There is nothing granted to preserve, so desired becomes
// granted — an instance with an entitlement nobody has granted would
// fall back to the plan base at issue time and confuse everyone.
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Only the limits are stored. Features are NOT snapshotted onto the
// entitlement: they live in Granted.Features, and Issue resolves them again
// against the catalogue at signing time. Storing a second copy here would
// give two answers to "which features does this instance have".
limits, _, err := catalogue.Resolve(ctx, plan, granted)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ent := models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: inst.Deployment,
Tier: tier,
Term: body.Term,
Desired: desired,
Granted: granted,
ResolvedLimits: limits,
}
// A reduction is a fact about the future, so it carries a date. There is no
// billing period to read yet — plan 5 sets this from the subscription — so
// staff-set reductions are marked as pending without one.
if desired.Servers < granted.Servers {
now := time.Now().UTC()
ent.ScheduledChangeAt = &now
}
if existing != nil {
ent.GrantedAt = existing.GrantedAt
}
if body.Grant {
ent.GrantedAt = time.Now().UTC()
}
if err := models.UpsertEntitlement(ctx, ent); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "entitlement.updated",
AccountID: inst.AccountID,
Target: inst.InstanceID,
Detail: fmt.Sprintf("tier=%s term=%s desired_servers=%d granted_servers=%d granted=%t",
tier, body.Term, desired.Servers, granted.Servers, body.Grant),
})
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
+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"
)
+36 -4
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())
{
@@ -68,6 +72,25 @@ func Routes(cfg config.Config) http.Handler {
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.POST("/instances/:id/renew", renewInstance)
cust.POST("/instances/:id/claim-free",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimFree)
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)
@@ -98,9 +121,18 @@ func Routes(cfg config.Config) http.Handler {
staff.POST("/instances/:id/relink", staffRelink)
staff.GET("/licenses", staffListLicenses)
staff.GET("/plans", staffListPlans)
staff.PUT("/plans/:tier", staffUpdatePlan)
// Plans are keyed on the pair now, so the path is too. A single :tier
// segment could name three rows.
staff.PUT("/plans/:deployment/:tier", staffUpdatePlan)
staff.GET("/catalogue", staffListCatalogue)
staff.PUT("/catalogue", staffUpdateCatalogue)
staff.GET("/instances/:id/entitlement", staffGetEntitlement)
staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
staff.GET("/health/billing", staffBillingHealth)
}
return r
+146 -19
View File
@@ -1,19 +1,20 @@
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/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"
@@ -122,7 +123,11 @@ func staffListInstances(c *gin.Context) {
}
if c.Query("expiring") == "true" {
// Instances whose licence expires within 14 days, for renewal chasing.
var ids []string
//
// Empty rather than nil: a nil slice marshals to `$in: null`, which
// Mongo rejects outright, so the quiet week when nothing is expiring is
// exactly when this query would have failed.
ids := []string{}
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
"superseded_by": bson.M{"$exists": false},
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
@@ -375,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 {
@@ -389,31 +417,130 @@ func staffListPlans(c *gin.Context) {
c.JSON(http.StatusOK, plans)
}
// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
// snapshotted their plan at issue time and are unaffected — the same rule as
// workflow_runs.steps_snapshot.
// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON.
// Existing licences snapshotted their plan at issue time and are unaffected —
// the same rule as workflow_runs.steps_snapshot.
//
// It writes no Paddle identifiers: those live in the catalogue, because a
// metered plan is priced by several components.
func staffUpdatePlan(c *gin.Context) {
var body models.Plan
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
return
}
deployment, tier := c.Param("deployment"), c.Param("tier")
set := bson.M{
"name": body.Name,
"limits": body.Limits,
"features": body.Features,
"paddle_product_id": body.PaddleProductID,
"paddle_price_ids": body.PaddlePriceIDs,
"active": body.Active,
"name": body.Name,
"base_limits": body.BaseLimits,
"base_features": body.BaseFeatures.OrEmpty(),
"support_level": body.SupportLevel,
"active": body.Active,
}
if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
res, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"})
return
}
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "plan.updated",
Target: deployment + "/" + tier,
Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t",
body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors,
body.SupportLevel, body.Active),
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func staffListCatalogue(c *gin.Context) {
rows, err := models.AllCatalogue(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
// staffUpdateCatalogue sets the price IDs on one component.
//
// The component is addressed by its natural key rather than by an ObjectID, so
// the staff UI never has to hold a Mongo identifier and a seeded row can be
// updated the moment it exists. Only price IDs are writable: a row's kind, plan
// and key are seeded by SeedCatalogue, and letting a form invent a limit_key
// would let it invent a limit nothing enforces.
func staffUpdateCatalogue(c *gin.Context) {
var body struct {
Kind string `json:"kind"`
Deployment string `json:"deployment"`
Tier string `json:"tier"`
LimitKey string `json:"limit_key"`
FeatureKey string `json:"feature_key"`
PriceIDs map[string]map[string]string `json:"price_ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"})
return
}
// Refuse a price on a term the deployment does not sell. Storing one would
// mean a resolved self-hosted monthly price later, which the resolver treats
// as a configuration error — better to refuse it at the point somebody
// pastes it, while they are looking at the screen.
for env, byTerm := range body.PriceIDs {
for term, id := range byTerm {
if id == "" {
continue
}
if !termSold(body.Deployment, term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
body.Deployment, term, env)})
return
}
}
}
filter := bson.M{
"kind": body.Kind,
"deployment": body.Deployment,
"tier": body.Tier,
"limit_key": body.LimitKey,
"feature_key": body.FeatureKey,
}
res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter,
bson.M{"$set": bson.M{"price_ids": body.PriceIDs}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such component"})
return
}
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "catalogue.updated",
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
Detail: body.LimitKey + body.FeatureKey,
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func termSold(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
return true
}
}
return false
}
func staffAudit(c *gin.Context) {
filter := bson.M{}
if v := c.Query("account_id"); v != "" {
+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
}
+120
View File
@@ -0,0 +1,120 @@
// Package catalogue turns an entitlement into the two things derived from it:
// the limits and features a licence grants, and the Paddle line items a
// subscription is made of.
//
// Both folds live here so the arithmetic exists once. The temptation is to
// compute limits in the issuer and quantities in the checkout, and then the two
// disagree about whether the base allowance is included in the number — which is
// a bug that bills a customer for three servers they were given.
package catalogue
import (
"context"
"errors"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
var (
// ErrUnknownPrice means an item named a price ID no catalogue row claims.
//
// This is always a configuration error and never a customer error: someone
// bought something at a price we cannot map to a plan. It must fail loudly
// rather than guess a tier — a guessed tier is a wrong licence with no
// record of why.
ErrUnknownPrice = errors.New("no catalogue row claims that price ID")
// ErrNoBaseItem means no item matched a base row, so the subscription names
// no plan. Quantities are meaningless without one.
ErrNoBaseItem = errors.New("no item matches a base price; the subscription names no plan")
// ErrTermNotSold means a price resolved to a term its deployment does not
// sell — in practice a self-hosted monthly price.
ErrTermNotSold = errors.New("that deployment does not sell that term")
// ErrUnpriced means a component needed for this configuration has no price
// ID in this environment. Refusing is correct: a checkout that silently
// drops a paid line item gives away the thing it was meant to charge for.
ErrUnpriced = errors.New("component has no price in this environment")
)
// Resolve folds a configuration into the limits and features a licence grants.
//
// Limits start at the plan's base and each metered component adds its configured
// amount. Features are the plan's base features plus the configured ones,
// deduplicated and filtered to keys the catalogue actually offers — a stale
// feature key in a stored entitlement must not survive into a signed payload.
func Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error) {
rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
if err != nil {
return license.Limits{}, nil, err
}
limits := plan.BaseLimits
offered := map[string]bool{}
for _, r := range rows {
switch r.Kind {
case models.KindLimit:
if err := addLimit(&limits, r.LimitKey, configured(cfg, r.LimitKey), plan.BaseLimits); err != nil {
return license.Limits{}, nil, err
}
case models.KindFeature:
offered[r.FeatureKey] = true
}
}
seen := map[string]bool{}
features := []string{}
for _, f := range plan.BaseFeatures {
if !seen[f] {
seen[f] = true
features = append(features, f)
}
}
for _, f := range cfg.Features {
if seen[f] || !offered[f] {
continue
}
seen[f] = true
features = append(features, f)
}
return limits, features, nil
}
// configured reads the configured total for one metered limit key.
//
// A switch rather than reflection, so every metered dimension is greppable and
// adding one is a visible edit here as well as a catalogue row.
func configured(cfg models.Config, limitKey string) int {
switch limitKey {
case models.LimitKeyServers:
return cfg.Servers
default:
return 0
}
}
// addLimit sets a metered limit to its configured total.
//
// The configured value is a TOTAL, not an increment, so this assigns rather than
// adds. A base of Unlimited is left alone: nothing can be added to no cap, and a
// plan that meters an already-unlimited dimension is a configuration mistake
// rather than something to compute around.
func addLimit(l *license.Limits, limitKey string, total int, base license.Limits) error {
switch limitKey {
case models.LimitKeyServers:
if base.MaxServers == license.Unlimited {
return nil
}
if total > base.MaxServers {
l.MaxServers = total
}
return nil
case "":
return fmt.Errorf("catalogue limit row has no limit_key")
default:
return fmt.Errorf("%w: limit_key %q", ErrUnknownPrice, limitKey)
}
}
+206
View File
@@ -0,0 +1,206 @@
package catalogue
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// Item is one Paddle line item: a price and how many of it.
type Item struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
// LineItems builds the subscription items for a configuration.
//
// The base row is always quantity 1. A metered row's quantity is the configured
// TOTAL minus the plan's base allowance, so a Professional customer at exactly
// three servers has a single-item subscription rather than one with a zero
// quantity Paddle would reject. A feature with no price in this environment
// produces no item and is granted free.
func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) {
if !sells(plan.Deployment, term) {
return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term)
}
rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
ErrUnpriced, plan.Deployment, plan.Tier)
}
wanted := map[string]bool{}
for _, f := range cfg.Features {
wanted[f] = true
}
items := []Item{}
for _, r := range rows {
switch r.Kind {
case models.KindBase:
id := r.PriceID(env, term)
if id == "" {
return nil, fmt.Errorf("%w: base price for %s/%s in %s",
ErrUnpriced, plan.Deployment, plan.Tier, env)
}
items = append(items, Item{PriceID: id, Quantity: 1})
case models.KindLimit:
qty := billable(cfg, r.LimitKey, plan.BaseLimits)
if qty <= 0 {
continue
}
id := r.PriceID(env, term)
if id == "" {
return nil, fmt.Errorf("%w: %s price for %s/%s in %s",
ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env)
}
items = append(items, Item{PriceID: id, Quantity: qty})
case models.KindFeature:
if !wanted[r.FeatureKey] {
continue
}
id := r.PriceID(env, term)
if id == "" {
// Free to toggle. Resolve() still grants it.
continue
}
items = append(items, Item{PriceID: id, Quantity: 1})
}
}
return items, nil
}
// billable is how many UNITS to charge for a metered dimension.
//
// The configured value is the total the customer sees, which includes the base
// allowance they were given. Charging for that base is the single most likely
// bug in this file, so the subtraction lives here and nowhere else.
func billable(cfg models.Config, limitKey string, base license.Limits) int {
switch limitKey {
case models.LimitKeyServers:
if base.MaxServers == license.Unlimited {
return 0
}
return cfg.Servers - base.MaxServers
default:
return 0
}
}
// Match is what an item list says about itself.
type Match struct {
Deployment string
Tier string
Term string
Servers int
Features []string
}
// ResolveItems maps a full item list back to a plan and a configuration.
//
// This replaces a price-ID-to-tier lookup, which cannot work once a subscription
// has several prices. The base item identifies the plan and the term; everything
// else is read relative to it. An item matching nothing fails the whole list.
//
// Only the running environment's IDs are consulted, so a production process
// cannot be talked into resolving a sandbox price by a forged or misrouted
// event.
//
// It is a function of the COMPLETE list, which is what keeps out-of-order
// delivery correct by construction: Paddle sends every item on every
// subscription event, so reading all of them is reading current state rather
// than a transition.
func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) {
all, err := models.AllCatalogue(ctx)
if err != nil {
return Match{}, err
}
// Pass 1: find the base item. Until we know the plan, no other item means
// anything — a quantity of 7 is 7 of what?
var m Match
found := false
for _, it := range items {
for _, r := range all {
if r.Kind != models.KindBase {
continue
}
for _, term := range []string{"monthly", "annual"} {
if r.PriceID(env, term) != it.PriceID || it.PriceID == "" {
continue
}
if found {
return Match{}, fmt.Errorf(
"item list names two plans: %s/%s and %s/%s",
m.Deployment, m.Tier, r.Deployment, r.Tier)
}
m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term
found = true
}
}
}
if !found {
return Match{}, ErrNoBaseItem
}
if !sells(m.Deployment, m.Term) {
return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue",
ErrTermNotSold, m.Deployment, m.Term)
}
plan, err := models.GetPlan(ctx, m.Deployment, m.Tier)
if err != nil {
return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w",
m.Deployment, m.Tier, err)
}
m.Servers = plan.BaseLimits.MaxServers
m.Features = []string{}
// Pass 2: everything else, relative to that plan. An item matching no row of
// this plan is a configuration error even if it matches some other plan's
// row — mixing two plans in one subscription is not a thing we sell.
rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier)
if err != nil {
return Match{}, err
}
for _, it := range items {
matched := false
for _, r := range rows {
if r.PriceID(env, m.Term) != it.PriceID {
continue
}
matched = true
switch r.Kind {
case models.KindBase:
// Already handled.
case models.KindLimit:
if r.LimitKey == models.LimitKeyServers {
m.Servers = plan.BaseLimits.MaxServers + it.Quantity
}
case models.KindFeature:
m.Features = append(m.Features, r.FeatureKey)
}
}
if !matched {
return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)",
ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier)
}
}
return m, nil
}
// sells reports whether a deployment offers a term.
func sells(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
return true
}
}
return false
}
+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)
+31 -2
View File
@@ -11,9 +11,10 @@ package db
import (
"context"
"fmt"
"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"
@@ -84,7 +85,7 @@ func EnsureIndexes(ctx context.Context) error {
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"plans", "tier"},
{"paddle_events", "event_id"},
{"staff_users", "email"},
{"customer_users", "email"},
}
@@ -106,6 +107,34 @@ func EnsureIndexes(ctx context.Context) error {
return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err)
}
// plans was unique on tier alone until spec 7. Mongo will not replace an
// index implicitly, and the old one would refuse the second row of every
// tier, so it is dropped by name here. Dropping a missing index is not an
// error worth failing boot over — a fresh database has never had it.
if err := Admin("plans").Indexes().DropOne(ctx, "tier_unique"); err != nil {
log.Printf("index plans.tier_unique: not dropped (%v); expected on a fresh database", err)
}
for _, u := range []struct {
coll string
keys bson.D
name string
}{
{"plans", bson.D{{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}}, "deployment_tier_unique"},
{"catalogue", bson.D{
{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}, {Key: "kind", Value: 1},
{Key: "limit_key", Value: 1}, {Key: "feature_key", Value: 1},
}, "component_unique"},
{"entitlements", bson.D{{Key: "instance_id", Value: 1}}, "instance_id_unique"},
} {
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: u.keys,
Options: options.Index().SetUnique(true).SetName(u.name),
}); err != nil {
return fmt.Errorf("index %s.%s: %w", u.coll, u.name, err)
}
}
for _, idx := range []struct {
coll string
keys bson.D
+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"
)
+51 -15
View File
@@ -5,20 +5,22 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"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"
)
var (
ErrUnknownTier = errors.New("unknown tier")
ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
ErrFreeLimit = errors.New("this account already has a Free instance")
ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type")
ErrUnknownInstance = errors.New("instance not found")
)
@@ -54,7 +56,10 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
return nil, ErrUnknownInstance
}
plan, err := models.GetPlan(ctx, in.Tier)
// The plan is looked up by the INSTANCE's deployment, not by a caller's
// guess. That is what makes the deployment comparison below a consistency
// check rather than the thing that decides which plan applies.
plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier)
if err != nil {
return nil, ErrUnknownTier
}
@@ -67,11 +72,35 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
}
if plan.Tier == license.TierFree {
if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil {
if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil {
return nil, err
}
}
// What this licence grants comes from the instance's entitlement, not from
// the plan. The plan is only the base.
//
// An instance with no entitlement gets the plan's base, which covers staff
// manual issuance and anything predating the backfill. Falling back is
// deliberate: refusing here would make a missing row an outage rather than a
// default.
limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty())
ent, entErr := models.GetEntitlement(ctx, inst.InstanceID)
switch {
case entErr == nil:
// Granted, never Desired. A configuration nobody has paid for must not
// reach a signed payload.
limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted)
if err != nil {
return nil, fmt.Errorf("resolve entitlement: %w", err)
}
case errors.Is(entErr, models.ErrNoEntitlement):
log.Printf("licensing: instance %s has no entitlement; issuing plan base",
inst.InstanceID)
default:
return nil, fmt.Errorf("read entitlement: %w", entErr)
}
now := time.Now().UTC()
expires := in.ExpiresAt
if expires.IsZero() {
@@ -94,10 +123,11 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
Deployment: plan.Deployment,
IssuedAt: now,
ExpiresAt: expires,
// Snapshotted, not referenced: editing a plan tomorrow must not change
// what this licence grants.
Limits: plan.Limits,
Features: plan.Features,
// Snapshotted, not referenced: editing a plan or an entitlement tomorrow
// must not change what this licence grants.
Limits: limits,
Features: features,
SupportLevel: plan.SupportLevel,
}
blob, err := license.Sign(payload, signingKey)
@@ -111,8 +141,8 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
AccountID: inst.AccountID,
Tier: plan.Tier,
Deployment: plan.Deployment,
Limits: plan.Limits,
Features: plan.Features,
Limits: limits,
Features: models.Features(features).OrEmpty(),
IssuedAt: now,
ExpiresAt: expires,
Blob: blob,
@@ -157,13 +187,19 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
return &rec, nil
}
// checkFreeLimit enforces one Free instance per account.
// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT.
//
// It used to be one per account, which was sufficient while Free existed only on
// cloud. With a self-hosted Free plan, an account-wide count would refuse a
// self-hosted Free instance to anyone holding a cloud one, and tell them about a
// limit they have not reached.
//
// Cancelled instances do not count: a customer who cancelled their Free instance
// is allowed another one.
func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error {
func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error {
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": accountID,
"deployment": deployment,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
"instance_id": bson.M{"$ne": exceptInstanceID},
+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"
)
+78 -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"
)
@@ -133,13 +133,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 +172,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() }
+202 -3
View File
@@ -2,17 +2,105 @@ package models
import (
"context"
"errors"
"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"
)
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
// SeedPlans.
//
// The original three rows were keyed on tier alone: (cloud,free),
// (cloud,professional) and a self_hosted TIER row. Spec 7 re-keys on
// (deployment, tier), makes self_hosted a deployment rather than a tier, and
// renames limits/features to base_limits/base_features.
//
// Ordering is the whole point. SeedPlans inserts a fresh (self_hosted,
// professional) row; if the legacy self_hosted row is only renamed afterwards it
// collides with that seed on deployment_tier_unique. Running here, before the
// seed, the rename lands first and the seed then no-ops on it.
//
// It is idempotent and self-healing: on a database where a previous boot already
// seeded (self_hosted, professional) before crashing, the legacy row can no
// longer be renamed onto it, so the legacy row is dropped instead — its
// instances are re-tiered and re-entitled from the surviving professional row.
func MigrateLegacyPlans(ctx context.Context) error {
// Legacy cloud rows may predate the deployment field entirely.
if _, err := db.Admin("plans").UpdateMany(ctx,
bson.M{"deployment": bson.M{"$exists": false},
"tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}},
bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil {
return err
}
// The legacy self_hosted TIER row becomes self-hosted Professional. If that
// target already exists (a prior partial boot seeded it), drop the legacy row
// rather than colliding — the seeded row carries the same professional base.
var legacy Plan
err := db.Admin("plans").FindOne(ctx, bson.M{"tier": license.TierSelfHosted}).Decode(&legacy)
switch {
case err == nil:
targetErr := db.Admin("plans").FindOne(ctx,
bson.M{"deployment": license.DeploymentSelfHosted, "tier": license.TierProfessional}).Err()
if targetErr == nil {
if _, err := db.Admin("plans").DeleteOne(ctx, bson.M{"_id": legacy.ID}); err != nil {
return err
}
log.Printf("backfill: dropped legacy self_hosted plan row; (self_hosted, professional) already present")
} else if errors.Is(targetErr, mongo.ErrNoDocuments) {
if _, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"_id": legacy.ID},
bson.M{"$set": bson.M{
"deployment": license.DeploymentSelfHosted,
"tier": license.TierProfessional,
"name": "Professional",
}}); err != nil {
return err
}
log.Printf("backfill: re-keyed legacy self_hosted plan to (self_hosted, professional)")
} else {
return targetErr
}
case errors.Is(err, mongo.ErrNoDocuments):
// No legacy row; a fresh database or an already-migrated one.
default:
return err
}
// limits/features become base_limits/base_features on any row still carrying
// the old names.
if _, err := db.Admin("plans").UpdateMany(ctx,
bson.M{"limits": bson.M{"$exists": true}},
bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil {
return err
}
// Support level is new, so nothing has one. Fill from the seed table rather
// than guessing: a plan row a human edited keeps every other field.
for _, deployment := range license.Deployments() {
for _, tier := range license.Tiers() {
p, ok := license.PlanFor(deployment, tier)
if !ok {
continue
}
if _, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"deployment": deployment, "tier": tier,
"support_level": bson.M{"$in": bson.A{nil, ""}}},
bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil {
return err
}
}
}
return nil
}
// Backfill brings pre-phase-3 data up to the membership model.
//
// It runs on every boot and is idempotent by construction: both passes filter
@@ -97,5 +185,116 @@ func Backfill(ctx context.Context) error {
if created > 0 {
log.Printf("backfill: recorded %d pre-existing instance owners", created)
}
// Pass 3 (the plan re-key) now runs in MigrateLegacyPlans, called from main
// BEFORE SeedPlans. It has to: SeedPlans inserts a fresh
// (self_hosted, professional) row, and if the legacy self_hosted TIER row is
// only renamed afterwards it collides with that seed on deployment_tier_unique.
// Pass 4: instances carrying the self_hosted TIER move to Professional.
// Their deployment already says self_hosted, so only the tier is wrong.
res, err = db.Admin("admin_instances").UpdateMany(ctx,
bson.M{"tier": license.TierSelfHosted},
bson.M{"$set": bson.M{"tier": license.TierProfessional}})
if err != nil {
return err
}
if res.ModifiedCount > 0 {
log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount)
}
// Pass 5: give every instance an entitlement, reconstructed from its current
// licence. Filtering on the absence of a row is what makes this idempotent,
// and it means an entitlement a customer has since edited is never
// overwritten by a stale licence.
if err := backfillEntitlements(ctx); err != nil {
return err
}
return nil
}
// backfillEntitlements reconstructs an entitlement per instance from its licence.
//
// An Unlimited max_servers maps back to the plan's BASE allowance rather than to
// a huge number: an unlimited licence bought no server units, so the honest
// reconstruction of "how many did they pay for" is none. This makes a
// pre-metering Professional instance read as 3 servers, which is a REDUCTION in
// what it is allowed. That is deliberate and it is why this is a plan step and
// not a silent fix — see the task's confirmation step.
func backfillEntitlements(ctx context.Context) error {
cur, err := db.Admin("admin_instances").Find(ctx,
bson.M{"status": bson.M{"$ne": StatusDeleted}})
if err != nil {
return err
}
var instances []Instance
if err := cur.All(ctx, &instances); err != nil {
return err
}
created := 0
for _, inst := range instances {
n, err := db.Admin("entitlements").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
return err
}
if n > 0 {
continue
}
deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier)
if tier == "" {
// An instance awaiting its first licence has no tier. It gets an
// entitlement when one is issued, not before.
continue
}
plan, err := GetPlan(ctx, deployment, tier)
if err != nil {
log.Printf("backfill: instance %s names unknown plan %s/%s; skipped",
inst.InstanceID, deployment, tier)
continue
}
cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}}
if inst.CurrentLicense != "" {
var lic License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil {
if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 {
cfg.Servers = lic.Limits.MaxServers
}
cfg.Features = lic.Features.OrEmpty()
}
}
limits := plan.BaseLimits
limits.MaxServers = cfg.Servers
if err := UpsertEntitlement(ctx, Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: deployment,
Tier: tier,
Term: defaultTerm(deployment),
Desired: cfg,
Granted: cfg,
ResolvedLimits: limits,
}); err != nil {
return err
}
created++
}
if created > 0 {
log.Printf("backfill: created %d entitlements from current licences", created)
}
return nil
}
// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted
// sells annual only, so there is nothing to guess there.
func defaultTerm(deployment string) string {
if deployment == license.DeploymentSelfHosted {
return "annual"
}
return "monthly"
}
+140
View File
@@ -0,0 +1,140 @@
package models
import (
"context"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// Component kinds.
const (
// KindBase is the plan's own fee, always quantity 1.
KindBase = "base"
// KindLimit raises a named limit by one per unit of quantity.
KindLimit = "limit"
// KindFeature is an on/off feature key.
KindFeature = "feature"
)
// LimitKeyServers is the only metered limit today.
//
// A limit_key is a field name in license.Limits, which is what lets a second
// metered dimension be a catalogue row rather than a code change. There is
// deliberately no block size: with secret-group blocks dropped from the spec it
// would be 1 in every row that will ever exist.
const LimitKeyServers = "max_servers"
// CatalogueRow is one priceable component of one plan.
//
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
// PriceIDs means the component is free — a feature with no price is a toggle a
// customer may take at no charge, and giving it a price later is a staff edit
// rather than a migration or a deploy.
type CatalogueRow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Kind string `bson:"kind" json:"kind"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
// PriceIDs is environment -> term -> Paddle price ID, e.g.
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
//
// Nested by environment rather than kept in two collections, because
// promoting sandbox to production must be a configuration change and not a
// data migration. The running PADDLE_ENV picks the inner map.
PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"`
}
// PriceID returns the price for one environment and term, or "".
func (r CatalogueRow) PriceID(env, term string) string {
if r.PriceIDs == nil {
return ""
}
return r.PriceIDs[env][term]
}
// Priced reports whether this component costs anything in an environment.
func (r CatalogueRow) Priced(env string) bool {
for _, term := range []string{"monthly", "annual"} {
if r.PriceID(env, term) != "" {
return true
}
}
return false
}
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
// "fix" this by adding zero-priced Free rows.
//
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
// in by staff and a redeploy must not blank them.
func SeedCatalogue(ctx context.Context) error {
paid := []string{license.TierProfessional, license.TierEnterprise}
for _, deployment := range license.Deployments() {
for _, tier := range paid {
rows := []CatalogueRow{
{Kind: KindBase, Deployment: deployment, Tier: tier},
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
}
for _, r := range rows {
filter := bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
}
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
bson.M{"$setOnInsert": bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
"price_ids": map[string]map[string]string{},
}},
options.UpdateOne().SetUpsert(true)); err != nil {
return err
}
}
}
}
return nil
}
// CatalogueFor returns every component of one plan.
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
cur, err := db.Admin("catalogue").Find(ctx,
bson.M{"deployment": deployment, "tier": tier})
if err != nil {
return nil, err
}
rows := []CatalogueRow{}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
return rows, nil
}
// AllCatalogue returns every row, for the staff editor.
func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) {
cur, err := db.Admin("catalogue").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
rows := []CatalogueRow{}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
return rows, nil
}
+139
View File
@@ -0,0 +1,139 @@
package models
import (
"context"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrNoEntitlement means the instance has no configuration row.
//
// Callers fall back to the plan's base rather than failing: staff manual
// issuance and any instance predating the backfill legitimately have none.
var ErrNoEntitlement = errors.New("instance has no entitlement")
// Config is one side of an entitlement — a complete statement of what an
// instance is allowed.
//
// Servers is the TOTAL the customer sees, not the number of units billed. The
// billed quantity is Servers minus the plan's base allowance, and it is computed
// where the line items are built rather than stored, so the two can never
// disagree about which of them included the base.
type Config struct {
Servers int `bson:"servers" json:"servers"`
Features Features `bson:"features" json:"features"`
}
// Entitlement is what one instance's customer configured.
//
// Both the subscription and the licence are derived from it; it is derived from
// nothing. Desired is what they last asked for; Granted is what a payment
// confirmed. A licence is only ever signed from Granted, so an abandoned
// checkout leaves a Desired that reached nothing.
type Entitlement struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Desired Config `bson:"desired" json:"desired"`
Granted Config `bson:"granted" json:"granted"`
// ResolvedLimits is BaseLimits with Granted folded in. It is stored rather
// than derived on read so the fold lives in exactly one place — deriving it
// at every read would put the arithmetic in the issuer, the portal and the
// staff console.
ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"`
// ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only
// when Desired grants less than Granted, and it is what lets the portal say
// "drops to 5 on 12 August" instead of guessing.
ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"`
GrantedAt time.Time `bson:"granted_at" json:"granted_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// Pending reports whether Desired and Granted disagree.
func (e Entitlement) Pending() bool {
if e.Desired.Servers != e.Granted.Servers {
return true
}
if len(e.Desired.Features) != len(e.Granted.Features) {
return true
}
have := map[string]bool{}
for _, f := range e.Granted.Features {
have[f] = true
}
for _, f := range e.Desired.Features {
if !have[f] {
return true
}
}
return false
}
// GetEntitlement reads one instance's configuration.
func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) {
var e Entitlement
err := db.Admin("entitlements").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&e)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNoEntitlement
}
if err != nil {
return nil, err
}
return &e, nil
}
// UpsertEntitlement writes an entitlement, creating it if absent.
//
// GrantedAt is only touched when Granted actually changes, which is what makes
// it answer "since when has this instance been allowed this" rather than "when
// was this row last written".
func UpsertEntitlement(ctx context.Context, e Entitlement) error {
now := time.Now().UTC()
set := bson.M{
"account_id": e.AccountID,
"deployment": e.Deployment,
"tier": e.Tier,
"term": e.Term,
"desired": e.Desired,
"granted": e.Granted,
"resolved_limits": e.ResolvedLimits,
"updated_at": now,
}
if e.ScheduledChangeAt != nil {
set["scheduled_change_at"] = *e.ScheduledChangeAt
}
if !e.GrantedAt.IsZero() {
set["granted_at"] = e.GrantedAt
} else {
set["granted_at"] = now
}
update := bson.M{"$set": set}
if e.ScheduledChangeAt == nil {
update["$unset"] = bson.M{"scheduled_change_at": ""}
}
_, err := db.Admin("entitlements").UpdateOne(ctx,
bson.M{"instance_id": e.InstanceID},
mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}),
options.UpdateOne().SetUpsert(true))
return err
}
// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one.
func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M {
update["$setOnInsert"] = onInsert
return update
}
+102 -15
View File
@@ -6,12 +6,41 @@
package models
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"
)
// Features is a list of feature flags that marshals as `[]` rather than `null`.
//
// A nil Go slice becomes JSON null. The Free plan has no features, so every
// Free licence served `"features": null`, and the portal — whose type said
// string[] — called .length on it and took the page down with it.
//
// The guarantee lives on the type rather than at each of the six places a
// licence or plan is serialised, because the seventh is the one that would have
// been forgotten. It also fixes rows already holding null in Mongo, since it
// applies at marshal time rather than at write time.
type Features []string
func (f Features) MarshalJSON() ([]byte, error) {
if f == nil {
return []byte("[]"), nil
}
return json.Marshal([]string(f))
}
// OrEmpty is the same guarantee for values headed to Mongo rather than to JSON,
// so a null never enters the database in the first place.
func (f Features) OrEmpty() Features {
if f == nil {
return Features{}
}
return f
}
// Instance statuses.
const (
StatusAwaitingLink = "awaiting_link"
@@ -39,6 +68,28 @@ const (
ReasonTierChange = "tier_change"
ReasonRelink = "relink"
ReasonManual = "manual"
// ReasonEntitlementChange is a mid-term change to what an instance is
// allowed — servers added, a feature toggled — at the same expiry.
//
// It is deliberately NOT ReasonRenewal: a renewal resets relink_count
// because a new term has begun, and adding a server does not begin one.
ReasonEntitlementChange = "entitlement_change"
)
// Subscription statuses, mirrored from Paddle. Ours, not a vendor SDK's, so the
// billing package does not import anything Paddle.
const (
SubActive = "active"
SubCanceled = "canceled"
SubPastDue = "past_due"
SubTrialing = "trialing"
)
// Billing terms. These match catalogue price-ID keys and license.TermsFor.
const (
TermMonthly = "monthly"
TermAnnual = "annual"
)
// MaxRelinksPerTerm is the customer-facing relink cap.
@@ -91,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
@@ -107,7 +166,7 @@ type License struct {
Tier string `bson:"tier" json:"tier"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features []string `bson:"features" json:"features"`
Features Features `bson:"features" json:"features"`
IssuedAt time.Time `bson:"issued_at" json:"issued_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
Blob string `bson:"blob" json:"-"`
@@ -122,28 +181,56 @@ 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"`
}
// Plan is the authoritative tier definition, seeded from shared/license.
// SubItem is one line of a subscription: a price and its quantity, the shape
// catalogue.ResolveItems reads back into a plan and configuration.
type SubItem struct {
PriceID string `bson:"price_id" json:"price_id"`
Quantity int `bson:"quantity" json:"quantity"`
}
// PaddleEvent is the idempotency record for one webhook delivery. The unique
// index on EventID is what makes a retry a no-op rather than a second licence.
type PaddleEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
EventID string `bson:"event_id" json:"event_id"`
EventType string `bson:"event_type" json:"event_type"`
ReceivedAt time.Time `bson:"received_at" json:"received_at"`
ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
}
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
// from shared/license.
//
// It lives in the database so tier contents change without a deploy. Every
// issued licence snapshots it, so editing a plan never rewrites an existing
// licence — the same rule as workflow_runs.steps_snapshot.
//
// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a
// metered plan is priced by several components and a single map on this row
// cannot express that.
type Plan struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Tier string `bson:"tier" json:"tier"`
Name string `bson:"name" json:"name"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features []string `bson:"features" json:"features"`
PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
Active bool `bson:"active" json:"active"`
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
Name string `bson:"name" json:"name"`
// BaseLimits is the allowance before anything is bought. The field is named
// `base_` rather than `limits` because that is a different claim from the one
// the old field made, and a reader must not assume it is the total.
BaseLimits license.Limits `bson:"base_limits" json:"base_limits"`
BaseFeatures Features `bson:"base_features" json:"base_features"`
SupportLevel string `bson:"support_level" json:"support_level"`
Active bool `bson:"active" json:"active"`
}
type StaffUser struct {
+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
}
+36 -26
View File
@@ -4,44 +4,54 @@ 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"
)
// SeedPlans inserts the tier table from shared/license on first boot.
// SeedPlans inserts the six (deployment, tier) rows from shared/license on first
// boot.
//
// It uses $setOnInsert only: once a plan exists, staff edits to limits, features
// and Paddle IDs are authoritative and a redeploy must not stamp over them.
// It uses $setOnInsert only: once a plan exists, staff edits to allowances,
// features and support level are authoritative and a redeploy must not stamp
// over them.
func SeedPlans(ctx context.Context) error {
for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} {
p, ok := license.PlanFor(tier)
if !ok {
continue
}
_, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"tier": tier},
bson.M{"$setOnInsert": bson.M{
"tier": p.Tier,
"name": p.Name,
"deployment": p.Deployment,
"limits": p.Limits,
"features": p.Features,
"active": true,
}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return err
for _, deployment := range license.Deployments() {
for _, tier := range license.Tiers() {
p, ok := license.PlanFor(deployment, tier)
if !ok {
continue
}
_, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"deployment": deployment, "tier": tier},
bson.M{"$setOnInsert": bson.M{
"deployment": p.Deployment,
"tier": p.Tier,
"name": p.Name,
"base_limits": p.Limits,
"base_features": Features(p.Features).OrEmpty(),
"support_level": p.SupportLevel,
"active": true,
}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return err
}
}
}
return nil
}
// GetPlan reads a tier's authoritative definition.
func GetPlan(ctx context.Context, tier string) (*Plan, error) {
// GetPlan reads one pair's authoritative definition.
//
// It normalises the tier first, so a legacy self_hosted licence being reissued
// resolves to the plan that replaced it.
func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
var p Plan
if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil {
if err := db.Admin("plans").FindOne(ctx,
bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil {
return nil, err
}
return &p, nil
+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))
}
+7
View File
@@ -19,6 +19,13 @@ 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
RUN npm run build
FROM node:26-alpine AS runner
+98 -45
View File
@@ -3,60 +3,113 @@
import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { formatDate } from "@/lib/format";
export default function BillingPage() {
const { data, error, isLoading } = useQuery({
queryKey: ["subscriptions"],
queryFn: api.subscriptions,
});
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (isLoading) return <p className="text-ink-3">Loading</p>;
if (subs.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (subs.isLoading) return <p className="text-ink-3">Loading</p>;
const rows = subs.data ?? [];
// Each subscription names the instance it pays for, because tier and term
// are per-licence rather than per-account. Resolving the name here is the
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) =>
account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
return (
<div className="grid gap-6">
<h1 className="text-3xl">Billing</h1>
<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
}
/>
{!data || data.length === 0 ? (
<p className="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">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
<th className="px-4 py-2.5">Plan</th>
<th className="px-4 py-2.5">Term</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Renews</th>
</tr>
</thead>
<tbody>
{data.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<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>
<PageFrame
aside={
<>
<RailCard title="Account">
<RailFacts
rows={[
{
label: "Billing contact",
value: account.data?.account.billing_email ?? "—",
},
{ label: "Status", value: account.data?.account.status ?? "—" },
{ label: "Subscriptions", value: rows.length },
]}
/>
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">
Change a card, download an invoice or cancel from the billing
portal. It covers every subscription on this account.
</p>
<ManageBillingButton />
<p className="text-[0.82rem] text-ink-2">
Anything else, email support.
</p>
<a
href="mailto:support@hostxtra.co.uk"
className="text-[0.82rem] font-semibold text-accent underline"
>
support@hostxtra.co.uk
</a>
</RailCard>
</>
}
>
{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>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
<th className="px-4 py-2.5 font-normal">Instance</th>
<th className="px-4 py-2.5 font-normal">Plan</th>
<th className="px-4 py-2.5 font-normal">Term</th>
<th className="px-4 py-2.5 font-normal">Status</th>
<th className="px-4 py-2.5 font-normal">Renews</th>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="max-w-xl text-[0.82rem] text-ink-3">
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>
</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>
<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>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageFrame>
</div>
);
}
+111 -57
View File
@@ -9,6 +9,8 @@ import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
export default function InstancePage() {
@@ -47,68 +49,120 @@ export default function InstancePage() {
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
return (
<div className="grid gap-8">
<header className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl">{instance.name}</h1>
<StatePill state={state} />
</div>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{instance.instance_id}
</p>
</header>
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title={instance.name || "Unnamed instance"}
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
} · created ${formatDate(instance.created_at)}`}
record={[
{ key: "Instance", value: instance.instance_id, copy: true },
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
]}
status={<StatePill state={state} />}
/>
{lic ? (
<>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Fact label="Tier" value={lic.tier.replace("_", " ")} />
<Fact label="Expires" value={formatDate(lic.expires_at)} />
<Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Fact label="Features" value={lic.features.join(", ") || "none"} />
</dl>
<PageFrame
aside={
<>
<RailCard title="Licence">
{lic ? (
<RailFacts
rows={[
{ label: "Tier", value: lic.tier.replace("_", " ") },
{ label: "Issued", value: formatDate(lic.issued_at) },
{ label: "Expires", value: formatDate(lic.expires_at) },
{ label: "Reason", value: lic.reason },
]}
/>
) : (
<p className="text-[0.82rem] text-ink-2">
No licence issued yet.
</p>
)}
</RailCard>
{instance.deployment === "self_hosted" && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
)}
</>
) : (
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
)}
{lic && (
<RailCard title="Included">
<RailFacts
rows={[
{
label: "Servers",
value: limitLabel(lic.limits.max_servers),
},
{
label: "Secret groups",
value: limitLabel(lic.limits.max_secret_groups),
},
{
label: "Channels",
value: limitLabel(lic.limits.max_channels),
},
{
label: "Features",
value: lic.features.join(", ") || "none",
},
]}
/>
</RailCard>
)}
{instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do not
have access to your own deployment.
</p>
)}
</div>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</dt>
<dd className="font-mono tabular-nums">{value}</dd>
{!cloud && (
<RailCard title="Moves">
<RailFacts
rows={[
{
label: "Relinks used",
value: `${instance.relink_count} of ${
account.data?.max_relinks ?? 3
}`,
},
]}
/>
<p className="text-[0.82rem] text-ink-2">
Moving a licence to a different install counts as one.
</p>
</RailCard>
)}
</>
}
>
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do
not have access to your own deployment.
</p>
)}
{lic && !cloud && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
)}
{!lic && (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
No licence has been issued for this instance yet.
</p>
)}
</PageFrame>
</div>
);
}
@@ -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,23 +1,34 @@
"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";
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">
<header className="grid gap-2">
<h1 className="text-3xl">Link an install</h1>
<p className="text-ink-2">
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.
</p>
</header>
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Link an install"
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,20 +0,0 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Create a free instance</h1>
<p className="max-w-prose text-ink-2">
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.
</p>
</header>
<CreateForm />
</div>
);
}
+29 -21
View File
@@ -1,31 +1,39 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
/*
* Three destinations, not five. Settings moved into the account menu it is
* your password, not a place and Instances went with it, because Overview
* already lists them and a second door to the same room is just a second thing
* to keep in sync. Linking an install is an action, so it is a button on
* Overview rather than a permanent nav entry.
*/
const LINKS: NavLink[] = [
{ href: "/", label: "Overview" },
{ href: "/users", label: "People" },
{ href: "/billing", label: "Billing" },
];
function AccountName() {
// Shares the ["account"] key with Overview, so this costs no extra request.
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
if (!data) return null;
return (
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
{data.account.name}
</span>
);
}
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="customer">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
<Link href="/" className="text-accent">
Overview
</Link>
<Link href="/instances/link" className="text-ink-3">
Link an install
</Link>
<Link href="/billing" className="text-ink-3">
Billing
</Link>
<Link href="/users" className="text-ink-3">
People
</Link>
<Link href="/settings" className="text-ink-3">
Settings
</Link>
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
<AppBar links={LINKS} context={<AccountName />} />
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
+174 -56
View File
@@ -4,10 +4,16 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { InstanceCard } from "@/components/InstanceCard";
import { InstanceRecord } from "@/components/InstanceRecord";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { StatePill } from "@/components/StatePill";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
export default function OverviewPage() {
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const licences = useQueries({
queries: (data?.instances ?? [])
@@ -26,72 +32,184 @@ export default function OverviewPage() {
if (q.data) byInstance.set(q.data.instance_id, q.data);
});
const unlinked = data.instances.filter((i) => i.status === "awaiting_link");
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.
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 === "warn")
return [
{
id: i.instance_id,
text: `${i.name} expires`,
note: `${daysRemaining(lic!.expires_at)}d`,
},
];
return [];
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const subtitle =
live.length === 0
? "Nothing here yet."
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
}`;
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="text-ink-2">{data.account.billing_email}</p>
</header>
<div className="grid gap-6">
<PageHeader
title="Overview"
subtitle={subtitle}
actions={
live.length > 0 ? (
<LinkButton href="/purchase">Buy a plan</LinkButton>
) : undefined
}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Billing", value: data.account.billing_email },
]}
status={
attention.length === 0 && live.length > 0 ? (
<StatePill state="valid" />
) : undefined
}
/>
{unlinked.length > 0 && (
<div className="rounded border border-accent bg-accent-wash p-4">
<h2 className="text-xl">Finish setting up your licence</h2>
<p className="mt-1 text-[0.82rem] text-ink-2">
{unlinked.length === 1
? "One purchase is"
: `${unlinked.length} purchases are`}{" "}
not attached to an install yet, so no licence has been issued for{" "}
{unlinked.length === 1 ? "it" : "them"}.
</p>
<Link
href="/instances/link"
className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
</div>
)}
{data.instances.length === 0 ? (
{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.
automatically. Or run Vantage on your own server and get its licence free
or paid from the purchase page.
</p>
<Link
href="/instances/new"
className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink"
>
Create a free instance
</Link>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/purchase">Buy a plan</LinkButton>
</div>
</div>
) : (
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.instances.map((i) => (
<InstanceCard
key={i.instance_id}
instance={i}
license={byInstance.get(i.instance_id)}
/>
))}
</section>
)}
<PageFrame
aside={
<>
{attention.length > 0 && (
<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"
>
<span>{a.text}</span>
{a.note && (
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
{a.note}
</span>
)}
</li>
))}
</ul>
</RailCard>
)}
{data.instances.length > 0 &&
!data.instances.some(
(i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted",
) && (
<Link
href="/instances/new"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Create a free instance
</Link>
)}
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
<li
key={p.user_id}
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
>
<span className="truncate">{p.email}</span>
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
{p.account_role}
</span>
</li>
))}
</ul>
{pending > 0 && (
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
{pending} {pending === 1 ? "invitation" : "invitations"} not
accepted yet
</p>
)}
<Link
href="/users"
className="text-[0.82rem] font-semibold text-accent underline"
>
Manage people
</Link>
</RailCard>
{/*
* Account-level facts only. Tier, limits and renewal date
* belong to a licence, and a licence belongs to one
* instance an account holding a Free cloud instance and
* a Professional self-hosted one has no single plan.
*/}
<RailCard title="Account">
<RailFacts
rows={[
{ label: "Billing contact", value: data.account.billing_email },
{ label: "Status", value: data.account.status },
{
label: "Customer since",
value: formatDate(data.account.created_at),
},
]}
/>
<Link
href="/billing"
className="text-[0.82rem] font-semibold text-accent underline"
>
Billing history
</Link>
</RailCard>
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">
Get a licence for your own install free or paid from the
purchase page. It keeps its own users.
</p>
<Link
href="/purchase"
className="text-[0.82rem] font-semibold text-accent underline"
>
Get a licence
</Link>
</RailCard>
</>
}
>
{live.map((i, n) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
return (
<InstanceRecord
key={i.instance_id}
instance={i}
license={lic}
// Open when it is the only one, or when it is the
// first thing that needs a decision. A saved toggle
// beats this from then on.
defaultOpen={
live.length === 1 ||
(state !== "valid" &&
attention[0]?.id === i.instance_id) ||
(attention.length === 0 && n === 0)
}
/>
);
})}
</PageFrame>
)}
</div>
);
}
@@ -0,0 +1,798 @@
"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>
);
}
+7 -8
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
export default function SettingsPage() {
const [current, setCurrent] = useState("");
@@ -28,14 +29,12 @@ export default function SettingsPage() {
});
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">Settings</h1>
<p className="text-ink-2">
Your password signs you in here and into every Vantage instance you belong to.
Changing it changes all of them.
</p>
</header>
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Settings"
subtitle="Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them."
/>
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
+148 -111
View File
@@ -7,9 +7,17 @@ import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { formatDate } from "@/lib/format";
const ROLES: AccountRole[] = ["owner", "admin", "member"];
const WHAT_ROLES_DO: [AccountRole, string][] = [
["owner", "Everything, including billing."],
["admin", "Invite people, create instances, grant access. No billing."],
["member", "Sign in to the instances they are given."],
];
export function InvitePanel() {
const qc = useQueryClient();
const { session } = useSession();
@@ -49,125 +57,154 @@ export function InvitePanel() {
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<div className="grid gap-6">
<PageFrame
aside={
<>
{canManage && (
<RailCard title="Invite someone">
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
</RailCard>
)}
<RailCard title="What the roles do">
<dl className="grid gap-2">
{WHAT_ROLES_DO.map(([r, what]) => (
<div key={r} className="grid gap-0.5">
<dt className="font-mono text-[0.68rem] uppercase tracking-[0.08em] text-ink">
{r}
</dt>
<dd className="m-0 text-[0.8rem] text-ink-2">{what}</dd>
</div>
))}
</dl>
<p className="border-t border-rule-soft pt-2 text-[0.8rem] text-ink-2">
An account role is not access to an instance. Give someone that on the
instance itself.
</p>
</RailCard>
</>
}
>
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
<th className="py-2">Email</th>
<th className="py-2">Account role</th>
<th className="py-2">Status</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft">
<td className="py-2.5">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="py-2.5">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="py-2.5 text-ink-2">
{u.verified_at ? "Active" : "Invitation pending"}
</td>
<td className="py-2.5 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
<th className="px-4 py-2.5 font-normal">Email</th>
<th className="px-4 py-2.5 font-normal">Account role</th>
<th className="px-4 py-2.5 font-normal">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="px-4 py-3">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="px-4 py-3 text-ink-2">
{u.verified_at
? `Active since ${formatDate(u.verified_at)}`
: "Invitation pending"}
</td>
<td className="px-4 py-3 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
)
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
</td>
</tr>
);
})}
{users.data?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-ink-3">
Nobody yet.
</td>
</tr>
);
})}
</tbody>
</table>
{canManage && (
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<h2 className="text-xl">Invite someone</h2>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<p className="text-[0.82rem] text-ink-2">
An account role is not access to an instance. Give them that on the
instance itself.
</p>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
)}
</div>
)}
</tbody>
</table>
</div>
</PageFrame>
);
}
+8 -8
View File
@@ -1,15 +1,15 @@
"use client";
import { InvitePanel } from "./InvitePanel";
import { PageHeader } from "@/components/PageHeader";
export default function UsersPage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">People</h1>
<p className="text-ink-2">
Everyone on this account. Owners and admins can invite people and grant them
access to instances; billing stays with owners.
</p>
</header>
<div className="grid gap-6">
<PageHeader
title="People"
subtitle="Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners."
/>
<InvitePanel />
</div>
);
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
export default function AccountDetailPage() {
const id = String(useParams().id);
@@ -17,12 +18,18 @@ export default function AccountDetailPage() {
return (
<div className="grid gap-8">
<header className="grid gap-1">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="font-mono text-[0.82rem] text-ink-3">
{data.account.billing_email} · {data.account.account_id}
</p>
</header>
<PageHeader
back={{ href: "/staff/accounts", label: "Accounts" }}
title={data.account.name}
subtitle={data.account.billing_email}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Status", value: data.account.status },
...(data.account.paddle_customer_id
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
: []),
]}
/>
<Panel title="Instances">
<ul className="grid gap-2">
@@ -1,9 +1,13 @@
import { AccountSearch } from "./AccountSearch";
import { PageHeader } from "@/components/PageHeader";
export default function AccountsPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Accounts</h1>
<PageHeader
title="Accounts"
subtitle="Search by name, email, Paddle ID or instance UUID."
/>
<AccountSearch />
</div>
);
+6 -1
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
export default function AuditPage() {
const [filter, setFilter] = useState("");
@@ -18,7 +19,11 @@ export default function AuditPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Audit</h1>
<PageHeader
title="Audit"
subtitle="Every mutating action across every account, newest first."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<Field
label="Filter"
value={filter}
@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/PageHeader";
import { PageFrame } from "@/components/PageFrame";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
* rather than rendered and rejected. The backend refuses one either way; this is
* so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return "Base fee";
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
return `Feature: ${r.feature_key}`;
}
function rowKey(r: CatalogueRow): string {
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
export default function CataloguePage() {
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
const save = useMutation({
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
});
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
return (
<div className="grid gap-6">
<PageHeader
title="Catalogue"
back={{ href: "/staff", label: "Operations" }}
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
/>
<PageFrame
aside={
<aside className="space-y-3 text-[0.82rem] text-ink-2">
<p>
A component with no price ID is free. A feature with no price is a
toggle a customer may take at no charge; giving it a price here is
all it takes to start charging for it.
</p>
<p>
Free is priced by nothing and has no rows. That absence is what
keeps it outside Paddle.
</p>
<p>
Changing a price affects the next checkout only. It cannot touch an
issued licence.
</p>
</aside>
}
>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="space-y-6">
{groups.map((g) => {
const [deployment, tier] = g.split("/");
const terms = termsFor(deployment);
return (
<section key={g} className="space-y-2">
<h2 className="text-[0.95rem] font-medium text-ink">
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
{tier}
</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
<thead>
<tr className="border-b border-rule text-left text-ink-3">
<th className="py-2 pr-3 font-normal">Component</th>
{ENVS.map((env) =>
terms.map((t) => (
<th
key={`${env}-${t}`}
className="py-2 pr-3 font-normal"
>
{env} / {t}
</th>
)),
)}
<th className="py-2 font-normal" />
</tr>
</thead>
<tbody>
{rows
.filter(
(r) =>
r.deployment === deployment &&
r.tier === tier,
)
.map((r) => {
const k = rowKey(r);
const ids = drafts[k] ?? r.price_ids ?? {};
const dirty =
JSON.stringify(ids) !==
JSON.stringify(r.price_ids ?? {});
return (
<tr
key={k}
className="border-b border-rule/60"
>
<td className="py-2 pr-3 text-ink">
{componentLabel(r)}
</td>
{ENVS.map((env) =>
terms.map((t) => (
<td
key={`${env}-${t}`}
className="py-2 pr-3"
>
<input
value={
ids[env]?.[t] ?? ""
}
placeholder="pri_…"
onChange={(e) =>
setDrafts({
...drafts,
[k]: {
...ids,
[env]: {
...(ids[
env
] ?? {}),
[t]: e
.target
.value,
},
},
})
}
className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
/>
</td>
)),
)}
<td className="py-2">
<button
type="button"
disabled={
!dirty || save.isPending
}
onClick={() =>
save.mutate({
...r,
price_ids: ids,
})
}
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
>
Save
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
})}
</div>
)}
</PageFrame>
</div>
);
}
@@ -8,13 +8,11 @@ import { Field } from "@/components/Field";
export function IssuePanel({
instanceId,
deployment,
}: {
instanceId: string;
deployment: string;
}) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
const [tier, setTier] = useState<Tier>("professional");
const [term, setTerm] = useState("annual");
const [newId, setNewId] = useState("");
const [error, setError] = useState<string | undefined>();
@@ -47,7 +45,7 @@ export function IssuePanel({
>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label className="grid gap-1.5">
@@ -1,11 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { api, type Deployment, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
@@ -32,38 +35,150 @@ export default function StaffInstancePage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.instance.name || data.instance.instance_id}</h1>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{data.instance.instance_id}
</p>
<p className="text-[0.82rem]">
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 &&
` · ${data.instance.relink_count} relinks this term`}
</span>
</p>
<div className="grid gap-3">
<PageHeader
back={{
href: `/staff/accounts/${data.account.account_id}`,
label: data.account.name || "Account",
}}
title={data.instance.name || data.instance.instance_id}
subtitle={
<>
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 &&
` · ${data.instance.relink_count} relinks this term`}
</span>
</>
}
record={[
{ key: "Instance", value: data.instance.instance_id, copy: true },
...(data.instance.slug
? [{ key: "Slug", value: data.instance.slug }]
: []),
]}
/>
{data.injection.applicable && inj && (
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
)}
</header>
</div>
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
<IssuePanel
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
<IssuePanel instanceId={data.instance.instance_id} />
</section>
<EntitlementSection
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
</div>
);
}
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
const qc = useQueryClient();
const { data: plans = [] } = useQuery({
queryKey: ["staff", "plans"],
queryFn: api.staff.plans,
});
const { data: catalogue = [] } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const { data } = useQuery({
queryKey: ["staff", "entitlement", instanceId],
queryFn: () => api.staff.entitlement(instanceId),
retry: false,
});
const ent = data?.entitlement;
const [draft, setDraft] = useState<PlanChoice | null>(null);
const choice: PlanChoice =
draft ??
(ent
? {
tier: ent.tier,
term: ent.term,
servers: ent.desired.servers,
features: ent.desired.features ?? [],
}
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
const save = useMutation({
mutationFn: (grant: boolean) =>
api.staff.setEntitlement(instanceId, { ...choice, grant }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
},
});
return (
<section className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3">
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
<p className="text-[0.78rem] text-ink-3">
What this instance is allowed. A licence is signed from{" "}
<em>granted</em>, never from <em>desired</em>.
</p>
</header>
{ent && data?.pending && (
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
Pending change currently granted {ent.granted.servers} servers,
configured for {ent.desired.servers}
{ent.scheduled_change_at
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
: ""}
.
</p>
)}
<PlanConfigurator
deployment={deployment}
value={choice}
plans={plans}
catalogue={catalogue}
onChange={setDraft}
disabled={save.isPending}
/>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(false)}
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
>
Save as configured
</button>
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(true)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
Save and grant
</button>
</div>
<p className="mt-2 text-[0.72rem] text-ink-3">
Granting takes effect on the next licence issued. It does not issue
one.
</p>
{save.error && (
<p className="mt-2 text-[0.82rem] text-expired">
{String((save.error as Error).message)}
</p>
)}
</section>
);
}
+19 -18
View File
@@ -1,29 +1,30 @@
"use client";
import Link from "next/link";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
const LINKS = [
["/staff", "Operations"],
["/staff/accounts", "Accounts"],
["/staff/licenses", "Licences"],
["/staff/plans", "Plans"],
["/staff/audit", "Audit"],
] as const;
const LINKS: NavLink[] = [
{ href: "/staff", label: "Operations" },
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/catalogue", label: "Catalogue" },
{ href: "/staff/audit", label: "Audit" },
];
export default function StaffLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="staff">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
{LINKS.map(([href, label]) => (
<Link key={href} href={href} className="text-ink-3 hover:text-accent">
{label}
</Link>
))}
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
<AppBar
links={LINKS}
staff
context={
<span className="rounded-sm border border-accent px-1.5 py-0.5 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-accent">
Staff
</span>
}
/>
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
@@ -19,7 +20,11 @@ export default function LicensesPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Licences</h1>
<PageHeader
title="Licences"
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<div className="flex flex-wrap gap-3">
<select
value={tier}
@@ -30,7 +35,8 @@ export default function LicensesPage() {
<option value="">All tiers</option>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
<option value="self_hosted">Self-Hosted (legacy)</option>
</select>
<select
value={reason}
+110 -3
View File
@@ -1,10 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Queue } from "@/components/Queue";
import { daysRemaining } from "@/lib/format";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { StatePill } from "@/components/StatePill";
import { LinkButton } from "@/components/Button";
import { daysRemaining, formatStamp } from "@/lib/format";
const HOURS_48 = 48 * 3600_000;
@@ -22,6 +27,11 @@ export default function StaffDashboard() {
queryKey: ["instances", "awaiting_link"],
queryFn: () => api.staff.instances({ status: "awaiting_link" }),
});
const audit = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
const allInstances = useQuery({
queryKey: ["instances", "all"],
queryFn: () => api.staff.instances(),
});
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -29,14 +39,32 @@ export default function StaffDashboard() {
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
);
const failed = injection.data?.count ?? 0;
const instances = allInstances.data ?? [];
return (
<div className="grid gap-6">
<h1 className="text-3xl">Operations</h1>
<PageHeader
title="Operations"
subtitle={
failed > 0
? "Injection failures come first — those instances are paying for a licence they have not received."
: "Nothing failing. Queues below are routine chasing."
}
actions={
<LinkButton variant="line" href="/staff/accounts">
Find an account
</LinkButton>
}
record={[{ key: "Checked", value: formatStamp(new Date().toISOString()) }]}
status={failed > 0 ? <StatePill state="expired" /> : <StatePill state="valid" />}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Queue
title="Failed injections"
tone="expired"
count={injection.data?.count ?? 0}
count={failed}
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
@@ -76,6 +104,85 @@ export default function StaffDashboard() {
}))}
/>
</div>
<PageFrame
aside={
<RailCard title="Fleet">
<RailFacts
rows={[
{ label: "Instances", value: instances.length },
{
label: "Cloud",
value: instances.filter((i) => i.deployment === "cloud").length,
},
{
label: "Self-hosted",
value: instances.filter((i) => i.deployment === "self_hosted")
.length,
},
{
label: "Awaiting link",
value: (unlinked.data ?? []).length,
},
]}
/>
<Link
href="/staff/licenses"
className="text-[0.82rem] font-semibold text-accent underline"
>
All licences
</Link>
</RailCard>
}
>
<section className="overflow-hidden rounded border border-rule bg-panel">
<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>
</div>
<Link
href="/staff/audit"
className="text-[0.82rem] font-semibold text-accent underline"
>
Full audit
</Link>
</header>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-[0.86rem]">
<thead>
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
<th className="px-4 py-2 font-normal">Time</th>
<th className="px-4 py-2 font-normal">Action</th>
<th className="px-4 py-2 font-normal">Target</th>
<th className="px-4 py-2 font-normal">Actor</th>
</tr>
</thead>
<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">{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>
</tr>
))}
{audit.data?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-ink-3">
Nothing yet today.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</PageFrame>
</div>
);
}
+151 -85
View File
@@ -2,10 +2,114 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Plan } from "@/lib/api";
import { Button } from "@/components/Button";
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { limitLabel } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
/*
* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all.
*/
function AllowanceForm({
plan,
onSave,
saving,
}: {
plan: Plan;
onSave: (next: Plan) => void;
saving: boolean;
}) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
return (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">
{f.label}
</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: {
...draft.base_limits,
[f.key]: Number(e.target.value),
},
})
}
className="w-full rounded border border-rule bg-panel 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>
</label>
))}
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">
Support level
</span>
<select
value={draft.support_level}
onChange={(e) =>
setDraft({ ...draft, support_level: e.target.value })
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
<input
type="checkbox"
checked={draft.active}
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
/>
Offered to customers
</label>
<p className="text-[0.78rem] text-ink-3">
Changes apply to licences issued from now on. Existing licences
snapshotted their plan and are unaffected.
</p>
<button
type="button"
disabled={!dirty || saving}
onClick={() => onSave(draft)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
);
}
export default function PlansPage() {
const qc = useQueryClient();
@@ -15,28 +119,28 @@ export default function PlansPage() {
queryFn: () => api.staff.licenses(),
});
const [draft, setDraft] = useState<Plan | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const save = useMutation({
mutationFn: (p: Plan) =>
api.staff.updatePlan(p.tier, {
name: p.name,
limits: p.limits,
features: p.features,
paddle_product_id: p.paddle_product_id,
paddle_price_ids: p.paddle_price_ids,
active: p.active,
}),
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setDraft(null);
setSaving(null);
},
onError: () => setSaving(null),
});
const original = plans.data?.find((p) => p.tier === draft?.tier);
const original = plans.data?.find(
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
);
return (
<div className="grid gap-6">
<h1 className="text-3xl">Plans</h1>
<PageHeader
title="Plans"
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
<ConfirmPlanChange
@@ -44,84 +148,46 @@ export default function PlansPage() {
next={draft}
issuedCount={
(licenses.data ?? []).filter(
(l) => l.tier === draft.tier,
(l) => l.tier === draft.tier && l.deployment === draft.deployment,
).length
}
onConfirm={() => save.mutate(draft)}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
save.mutate(draft);
}}
onCancel={() => setDraft(null)}
/>
)}
<div className="grid gap-4 lg:grid-cols-3">
{(plans.data ?? []).map((p) => (
<section
key={p.tier}
className="grid gap-3 rounded border border-rule bg-panel p-5"
>
<h2 className="text-xl">{p.name}</h2>
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<div className="flex justify-between gap-2">
<dt>servers</dt>
<dd>{limitLabel(p.limits.max_servers)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>secret groups</dt>
<dd>
{limitLabel(p.limits.max_secret_groups)}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt>channels</dt>
<dd>{limitLabel(p.limits.max_channels)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>features</dt>
<dd>{p.features?.join(", ") || "none"}</dd>
</div>
</dl>
{/* Guard rail two: deployment is shown, never edited. */}
<p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
<span aria-hidden="true">🔒</span>
<span>
Deployment is fixed at{" "}
<b className="font-mono">{p.deployment}</b>.
Moving a tier between cloud and self-hosted is a
code change, not a form field.
</span>
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="line"
onClick={() =>
setDraft({
...p,
limits: { ...p.limits, max_servers: 7 },
})
}
{(["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>
{(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"
>
Cap servers at 7
</Button>
<Button
type="button"
variant="line"
onClick={() =>
setDraft({
...p,
features: p.features.filter(
(f) => f !== "oidc",
),
})
}
>
Remove OIDC
</Button>
</div>
</section>
))}
</div>
<header className="mb-3 flex items-baseline justify-between gap-3">
<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)}
/>
</article>
))}
</section>
))}
</div>
);
}
+11 -13
View File
@@ -1,28 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { EnvBadge } from "@/components/EnvBadge";
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
export const metadata: Metadata = {
title: "Vantage Licensing",
title: "Vantage HQ",
description: "Licences, instances and billing for Vantage.",
};
/*
* The masthead deliberately does NOT live here. It belongs to the authenticated
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
* whose navigation and account menu they cannot use.
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Runs before first paint, so a dark-preferring viewer never sees white. */}
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
</head>
<body>
<header className="border-b border-rule bg-panel">
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-5 py-4">
<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">
Licensing
</span>
</span>
<EnvBadge />
</div>
</header>
<Providers>{children}</Providers>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useSession } from "@/lib/session";
import { useTheme, type ThemePref } from "@/lib/theme";
const APPEARANCE: { value: ThemePref; label: string }[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
/*
* Everything here is about YOU rather than about the account: your settings,
* your password, how you want the app to look, and leaving. None of it is a
* destination worth a slot in the primary nav, which is why Settings moved off
* the bar and into this menu.
*/
export function AccountMenu({ staff = false }: { staff?: boolean }) {
const { session } = useSession();
const [open, setOpen] = useState(false);
const [pref, setPref] = useTheme();
const wrap = useRef<HTMLDivElement>(null);
const router = useRouter();
const qc = useQueryClient();
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const signOut = useMutation({
mutationFn: api.logout,
// Clear the cache before leaving: a cached account response outliving
// the session would show the next person who signs in on this browser
// the previous account's name for a beat.
onSettled: () => {
qc.clear();
router.replace("/login");
},
});
const email = session?.email ?? "";
const initials =
email
.split("@")[0]
.split(/[.\-_]/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("") || "?";
return (
<div className="relative" ref={wrap}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
className={`flex items-center gap-2 rounded-sm border px-2 py-1 text-[0.8rem] ${
open ? "border-accent text-ink" : "border-rule text-ink-2"
} bg-panel hover:border-ink-3`}
>
<span className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{initials}
</span>
<span className="hidden max-w-[16ch] truncate sm:inline">{email}</span>
<span aria-hidden className="text-[0.6rem] text-ink-3">
</span>
</button>
{open && (
<div
role="menu"
// --shadow rather than a literal: globals.css defines it per
// theme, and a hardcoded rgba would be a colour value living
// in a component, which this app's tokens rule forbids.
className="absolute right-0 top-[calc(100%+8px)] z-50 grid w-64 overflow-hidden rounded border border-rule bg-panel shadow-[var(--shadow)]"
>
<div className="grid gap-0.5 border-b border-rule-soft px-3 py-2.5">
<strong className="truncate text-[0.86rem]">{email}</strong>
<span className="font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">
{staff ? "Vantage staff" : (session?.account_role ?? "member")}
</span>
</div>
{!staff && (
<Link
href="/settings"
role="menuitem"
onClick={() => setOpen(false)}
className="px-3 py-2 text-[0.86rem] text-ink hover:bg-accent-wash"
>
Settings
</Link>
)}
<div className="grid gap-1.5 border-y border-rule-soft px-3 py-2.5">
<span className="font-mono text-[0.66rem] uppercase tracking-[0.12em] text-ink-3">
Appearance
</span>
<div className="flex overflow-hidden rounded-sm border border-rule">
{APPEARANCE.map((a) => (
<button
key={a.value}
type="button"
onClick={() => setPref(a.value)}
aria-pressed={pref === a.value}
className={`flex-1 px-0 py-1 font-mono text-[0.62rem] uppercase tracking-[0.08em] ${
pref === a.value
? "bg-accent text-accent-ink"
: "bg-panel text-ink-3 hover:text-ink-2"
}`}
>
{a.label}
</button>
))}
</div>
</div>
<button
type="button"
role="menuitem"
onClick={() => signOut.mutate()}
disabled={signOut.isPending}
className="px-3 py-2 text-left text-[0.86rem] text-expired hover:bg-accent-wash"
>
{signOut.isPending ? "Signing out…" : "Sign out"}
</button>
</div>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AccountMenu } from "@/components/AccountMenu";
import { EnvBadge } from "@/components/EnvBadge";
export type NavLink = { href: string; label: string };
/*
* One masthead in three zones: who you are acting as, where you can go, and
* which environment you are in.
*
* It replaces a brand bar and a separate nav strip. The nav's active state is
* derived from the pathname rather than hardcoded the previous customer nav
* marked Overview as current on every page, including the ones that weren't it.
*
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
* to dark so this app defaults to light for the same reason CLAUDE.md gives:
* telling two consoles apart before you click Reissue. Staff and customer need
* that distinction from each other too, and one shade plus one chip buys it
* without a second palette.
*/
export function AppBar({
links,
context,
staff = false,
}: {
links: NavLink[];
context?: React.ReactNode;
staff?: boolean;
}) {
const pathname = usePathname();
const isCurrent = (href: string) =>
// The section root matches only exactly; deeper routes match by prefix,
// so /staff/accounts/:id still lights Accounts while /staff/accounts
// does not light Operations.
href === "/" || href === "/staff"
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`);
return (
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
<div className="mx-auto grid max-w-rail grid-cols-[auto_1fr_auto] items-center gap-4 px-5 md:gap-7">
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
HQ
</span>
</span>
{context && (
<>
<span aria-hidden className="hidden h-[22px] w-px bg-rule sm:block" />
<span className="hidden min-w-0 sm:block">{context}</span>
</>
)}
</div>
<nav
aria-label={staff ? "Staff" : "Account"}
className="col-span-3 col-start-1 row-start-2 flex items-stretch gap-1 overflow-x-auto border-t border-rule-soft md:col-span-1 md:col-start-2 md:row-start-1 md:border-t-0"
>
{links.map((l) => {
const on = isCurrent(l.href);
return (
<Link
key={l.href}
href={l.href}
aria-current={on ? "page" : undefined}
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
on
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
: "text-ink-3 hover:text-ink-2"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="col-start-3 row-start-1 flex items-center justify-end gap-2.5 py-2.5">
<span className="hidden sm:block">
<EnvBadge />
</span>
<AccountMenu staff={staff} />
</div>
</div>
</header>
);
}
+47 -15
View File
@@ -1,25 +1,57 @@
import clsx from "clsx";
import Link from "next/link";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };
type Variant = "solid" | "line";
/*
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
export function Button({ variant = "solid", className, ...rest }: Props) {
return (
<button
{...rest}
className={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",
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
)}
/>
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
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",
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
);
}
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant };
export function Button({ variant = "solid", className, ...rest }: Props) {
return <button {...rest} className={buttonClass(variant, rest.disabled, className)} />;
}
/*
* A link that looks like a button. It exists so a navigation action never has to
* be an <a> wrapped around a <button> invalid markup, and it gives screen
* readers two nested controls where the page means one.
*/
export function LinkButton({
href,
variant = "solid",
external,
className,
children,
}: {
href: string;
variant?: Variant;
external?: boolean;
className?: string;
children: React.ReactNode;
}) {
const cls = buttonClass(variant, false, className);
return external ? (
<a href={href} className={cls}>
{children}
</a>
) : (
<Link href={href} className={cls}>
{children}
</Link>
);
}
+40
View File
@@ -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>
);
}
+22 -19
View File
@@ -22,29 +22,32 @@ export function ConfirmPlanChange({
onCancel: () => void;
}) {
const rows: { field: string; was: string; now: string }[] = [];
if (plan.limits.max_servers !== next.limits.max_servers)
const fields = [
"max_servers",
"max_monitors",
"max_secret_groups",
"max_channels",
"audit_retention_days",
] as const;
for (const f of fields) {
if (plan.base_limits[f] !== next.base_limits[f])
rows.push({
field: f,
was: limitLabel(plan.base_limits[f]),
now: limitLabel(next.base_limits[f]),
});
}
if (plan.support_level !== next.support_level)
rows.push({
field: "max_servers",
was: limitLabel(plan.limits.max_servers),
now: limitLabel(next.limits.max_servers),
field: "support_level",
was: plan.support_level || "none",
now: next.support_level || "none",
});
if (plan.limits.max_secret_groups !== next.limits.max_secret_groups)
rows.push({
field: "max_secret_groups",
was: limitLabel(plan.limits.max_secret_groups),
now: limitLabel(next.limits.max_secret_groups),
});
if (plan.limits.max_channels !== next.limits.max_channels)
rows.push({
field: "max_channels",
was: limitLabel(plan.limits.max_channels),
now: limitLabel(next.limits.max_channels),
});
if (plan.features.join(",") !== next.features.join(","))
if (plan.base_features.join(",") !== next.base_features.join(","))
rows.push({
field: "features",
was: plan.features.join(", ") || "none",
now: next.features.join(", ") || "none",
was: plan.base_features.join(", ") || "none",
now: next.base_features.join(", ") || "none",
});
return (
-131
View File
@@ -1,131 +0,0 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
import { StatePill } from "./StatePill";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
export function InstanceCard({
instance,
license,
reapAfterDays,
}: {
instance: Instance;
license?: License;
reapAfterDays?: number;
}) {
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 qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
return (
<article
className={clsx(
"relative grid gap-3 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 items-start justify-between gap-3">
<div>
<h3 className="text-lg">{instance.name || "Unnamed instance"}</h3>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
</p>
</div>
<StatePill state={state} />
</div>
{state === "expired" && (
<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>
)}
{state === "expired" && deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
{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>
)}
{license && state !== "expired" && (
<div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{ width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%` }}
/>
</div>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
{state === "none" ? (
<Link
href="/instances/link"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
) : cloud && instance.slug ? (
<a
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Open {instance.slug}.vantage.hostxtra.co.uk
</a>
) : (
<Link
href={`/instances/${instance.instance_id}`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
{state === "expired" ? "Renew and download" : "Licence and download"}
</Link>
)}
{canRenew && (
<button
type="button"
onClick={() => renew.mutate()}
disabled={renew.isPending}
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
>
{renew.isPending ? "Renewing…" : "Renew"}
</button>
)}
</div>
</article>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { Button, LinkButton } from "./Button";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
const KEY = (id: string) => `vantage-hq-record-open:${id}`;
/*
* One instance, open or closed.
*
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
* licence includes, who can sign in, and the actions. Deliberately ONE component
* rather than a card and a detail panel: two components meant a single-instance
* account got a third of a row of summary with its substance a click away, and
* a six-instance account got a grid of summaries with no way to look closer.
*
* It defaults open when it is the only instance or when it needs attention,
* because the thing that needs you is the thing that should be open. A manual
* toggle is remembered per instance and beats the default from then on.
*/
export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen = false }: { instance: Instance; license?: License; reapAfterDays?: number; defaultOpen?: boolean }) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
const saved = localStorage.getItem(KEY(instance.instance_id));
if (saved !== null) setOpen(saved === "1");
}, [instance.instance_id]);
const toggle = () => {
setOpen((v) => {
localStorage.setItem(KEY(instance.instance_id), v ? "0" : "1");
return !v;
});
};
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
// Only fetched once the record is open, and only for cloud: a self-hosted
// install manages its own users and the endpoint refuses it.
const members = useQuery({
queryKey: ["members", instance.instance_id],
queryFn: () => api.members(instance.instance_id),
enabled: open && cloud,
});
const panelId = `record-${instance.instance_id}`;
return (
<article className={clsx("relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5", "before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", STRIPE[state])}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[1.22rem]">{instance.name || "Unnamed instance"}</h2>
<p className="mt-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
{` · created ${formatDate(instance.created_at)}`}
</p>
{cloud && instance.slug && (
<a href={`https://${instance.slug}.vantage.hostxtra.co.uk`} className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline">
{instance.slug}.vantage.hostxtra.co.uk &rarr;
</a>
)}
</div>
<div className="flex shrink-0 items-center gap-2.5">
<StatePill state={state} />
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? "Hide details" : "Show details"}
className="grid h-[26px] w-[26px] place-items-center rounded-sm border border-rule bg-panel text-[0.6rem] text-ink-3 hover:border-accent hover:text-accent"
>
<span aria-hidden className={clsx("block transition-transform", open && "rotate-180")}>
</span>
</button>
</div>
</div>
{license && state !== "expired" && (
<div className="grid max-w-md gap-1.5">
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
}}
/>
</div>
</div>
)}
{state === "expired" && (
<div className="grid gap-1">
<p className="text-[0.82rem] text-ink-2">Servers and monitors are still running, and your agents keep their keys. Changes are disabled until you renew.</p>
{deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0 ? "Scheduled for deletion." : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
</div>
)}
{state === "none" && <p className="text-[0.82rem] text-ink-2">You have paid for this but it is not attached to an install yet, so no licence has been issued. Linking takes a minute.</p>}
<div id={panelId} className={clsx("gap-3.5", open ? "grid" : "hidden")}>
{license && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Included in {instance.tier?.replace("_", " ") ?? "this licence"}</p>
<div className="flex flex-wrap gap-x-7 gap-y-2.5">
<Stat n={limitLabel(license.limits.max_servers)} label="Servers" />
<Stat n={limitLabel(license.limits.max_secret_groups)} label="Secret groups" />
<Stat n={limitLabel(license.limits.max_channels)} label="Channels" />
<Stat n={license.features.length ? license.features.join(" · ") : "None"} label="Features" quiet={license.features.length === 0} />
</div>
</div>
)}
{cloud && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Who can sign in</p>
<div className="flex flex-wrap items-center gap-2">
{(members.data ?? []).map((m) => (
<span key={m.member_id} className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2">
<span className="grid h-[18px] w-[18px] place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
{m.email}
</span>
))}
{members.isLoading && <span className="text-[0.82rem] text-ink-3">Loading</span>}
{members.data?.length === 0 && <span className="text-[0.82rem] text-ink-3">Nobody yet.</span>}
<Link href={`/instances/${instance.instance_id}`} className="text-[0.82rem] font-semibold text-accent underline">
Manage access
</Link>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
// 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 Cloud Instance
</LinkButton>
<LinkButton variant="line" href={`/instances/${instance.instance_id}`}>
View Instance Settings
</LinkButton>
</>
) : (
<LinkButton href={`/instances/${instance.instance_id}`}>View Instance Settings</LinkButton>
)}
{canRenew && (
<Button type="button" variant="line" onClick={() => renew.mutate()} disabled={renew.isPending}>
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
</div>
</div>
</article>
);
}
function Stat({ n, label, quiet }: { n: string; label: string; quiet?: boolean }) {
return (
<div className="grid gap-px">
<b className={clsx("tabular-nums tracking-[-0.02em]", quiet ? "text-[0.95rem] font-semibold text-ink-3" : "text-[1.18rem] font-extrabold")}>{n}</b>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
</div>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ 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">
<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">
@@ -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>
);
}
+69
View File
@@ -0,0 +1,69 @@
/*
* Main column plus a fixed support rail.
*
* The rail is what stops a page being empty and the main column is what stops
* it being thin: an account with one instance used to render a third of a row
* of summary and nothing else. The rail carries what is true regardless of how
* many instances exist, so the page has a floor.
*
* It collapses below lg in source order, which puts the main column first on a
* phone. Nothing is hidden at any width if content only fits on a desktop it
* does not belong in the rail.
*/
export function PageFrame({
children,
aside,
}: {
children: React.ReactNode;
aside?: React.ReactNode;
}) {
if (!aside) return <div className="grid gap-5">{children}</div>;
return (
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-w-0 gap-4">{children}</div>
<aside className="grid gap-3.5">{aside}</aside>
</div>
);
}
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
export function RailCard({
title,
count,
children,
}: {
title: string;
count?: number | string;
children: React.ReactNode;
}) {
return (
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
<header className="flex items-baseline justify-between gap-2.5">
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
{title}
</h2>
{count !== undefined && (
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
)}
</header>
{children}
</section>
);
}
/** Key/value rows for the rail. Values are mono so numbers line up. */
export function RailFacts({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
return (
<dl className="grid gap-1.5">
{rows.map((r) => (
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
<dt className="text-ink-3">{r.label}</dt>
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
{r.value}
</dd>
</div>
))}
</dl>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { useState } from "react";
/*
* One record-line entry. `copy` marks the value as worth lifting to the
* clipboard an instance UUID or a licence ID, the strings people paste into
* support tickets.
*/
export type RecordField = { key: string; value: string; copy?: boolean };
function CopyButton({ value }: { value: string }) {
const [done, setDone] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
// Clipboard is refused without a secure context or a user
// gesture the browser trusts. The value is on screen and
// selectable either way, so this needs no error state.
}
}}
className="rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
>
{done ? "Copied" : "Copy"}
</button>
);
}
/*
* The page frame every screen starts with, replacing nine hand-rolled header
* blocks that each picked their own gaps and their own place for actions.
*
* The record line is the one new idea: Vantage HQ is a registry, so every screen
* is a record and records have reference numbers. Giving the reference a fixed
* slot, in mono, above the fold, means "where is the ID" stops being a per-page
* question. It costs one hairline rule.
*/
export function PageHeader({
back,
title,
subtitle,
actions,
record,
status,
}: {
back?: { href: string; label: string };
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
record?: RecordField[];
status?: React.ReactNode;
}) {
return (
<header className="grid gap-3">
{back && (
<Link
href={back.href}
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
>
&larr; {back.label}
</Link>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[1.9rem]">{title}</h1>
{subtitle && <p className="mt-1 text-[0.92rem] text-ink-2">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{(record?.length || status) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
{record?.map((f) => (
<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>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
{status && <span className="ml-auto">{status}</span>}
</div>
)}
</header>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
export interface PlanChoice {
tier: Tier;
term: Term;
servers: number;
features: string[];
}
/* Self-hosted sells annual only. The reason is in shared/license: an offline
* licence cannot be revoked, so the term length IS the revocation window. */
function termsFor(deployment: Deployment): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
/*
* PlanConfigurator is the whole of "what is this instance allowed", driven
* entirely by the plans and catalogue it is handed.
*
* A feature appears because a catalogue row offers it, and shows a price because
* that row has one. Nothing here is hardcoded per tier, which is what lets a new
* paid add-on ship as a staff edit rather than a frontend release.
*
* It saves nothing and knows nothing about who is using it. Staff mount it to
* set an entitlement; the customer purchase flow mounts the same component and
* hands it a checkout.
*/
export default function PlanConfigurator({
deployment,
value,
plans,
catalogue,
onChange,
disabled,
}: {
deployment: Deployment;
value: PlanChoice;
plans: Plan[];
catalogue: CatalogueRow[];
onChange: (next: PlanChoice) => void;
disabled?: boolean;
}) {
const available = useMemo(
() => plans.filter((p) => p.deployment === deployment && p.active),
[plans, deployment],
);
const plan = available.find((p) => p.tier === value.tier);
const rows = useMemo(
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
[catalogue, deployment, value.tier],
);
const featureRows = rows.filter((r) => r.kind === "feature");
const base = plan?.base_limits.max_servers ?? 0;
const extra = Math.max(0, value.servers - base);
const priceOf = (r: CatalogueRow) =>
r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? "";
return (
<div className="space-y-4">
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Tier</legend>
<div className="flex flex-wrap gap-2">
{available.map((p) => (
<button
key={p.tier}
type="button"
disabled={disabled}
onClick={() =>
onChange({
...value,
tier: p.tier,
/* Moving tier moves the floor, so clamp up
* rather than leaving an invalid count the
* backend would refuse. */
servers: Math.max(value.servers, p.base_limits.max_servers),
})
}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
p.tier === value.tier
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{p.name}
</button>
))}
</div>
</fieldset>
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Term</legend>
<div className="flex flex-wrap gap-2">
{termsFor(deployment).map((t) => (
<button
key={t}
type="button"
disabled={disabled}
onClick={() => onChange({ ...value, term: t })}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
t === value.term
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{t === "monthly" ? "Monthly" : "Annual"}
</button>
))}
</div>
{deployment === "self_hosted" && (
<p className="text-[0.72rem] text-ink-3">
Self-hosted is annual only.
</p>
)}
</fieldset>
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
<input
type="number"
min={base}
value={value.servers}
disabled={disabled}
onChange={(e) =>
onChange({ ...value, servers: Number(e.target.value) })
}
className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="ml-2 text-[0.78rem] text-ink-3">
{base} included{extra > 0 ? `, ${extra} extra` : ""}
</span>
</label>
{featureRows.length > 0 && (
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Features</legend>
{featureRows.map((r) => {
const key = r.feature_key!;
const on = value.features.includes(key);
const priced = priceOf(r) !== "";
return (
<label
key={key}
className="flex items-center gap-2 text-[0.85rem] text-ink-2"
>
<input
type="checkbox"
checked={on}
disabled={disabled}
onChange={(e) =>
onChange({
...value,
features: e.target.checked
? [...value.features, key]
: value.features.filter((f) => f !== key),
})
}
/>
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
</label>
);
})}
</fieldset>
)}
</div>
);
}
+120 -11
View File
@@ -61,7 +61,8 @@ const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
// --- types ---------------------------------------------------------------
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type Tier = "free" | "professional" | "enterprise";
export type Term = "monthly" | "annual";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
/*
@@ -103,8 +104,10 @@ export interface InstanceMember {
export interface Limits {
max_servers: number;
max_monitors: number;
max_secret_groups: number;
max_channels: number;
audit_retention_days: number;
}
export interface Account {
@@ -157,16 +160,93 @@ export interface Subscription {
}
export interface Plan {
deployment: Deployment;
tier: Tier;
name: string;
deployment: Deployment;
limits: Limits;
features: string[];
paddle_product_id?: string;
paddle_price_ids?: Record<string, string>;
/* The allowance BEFORE anything is bought. Not the total a metered
* dimension adds to it. */
base_limits: Limits;
base_features: string[];
support_level: string;
active: boolean;
}
export interface CatalogueRow {
kind: "base" | "limit" | "feature";
deployment: Deployment;
tier: Tier;
limit_key?: string;
feature_key?: string;
/* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the
* inner map; both environments are stored so promotion is a config change
* rather than a data migration. */
price_ids?: Record<string, Partial<Record<Term, string>>>;
}
export interface EntitlementConfig {
servers: number;
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;
deployment: Deployment;
tier: Tier;
term: Term;
desired: EntitlementConfig;
granted: EntitlementConfig;
resolved_limits: Limits;
scheduled_change_at?: string;
granted_at: string;
updated_at: string;
}
export interface CustomerUser {
user_id: string;
account_id: string;
@@ -228,12 +308,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 }),
@@ -274,11 +373,21 @@ export const api = {
licenses: (params?: Record<string, string>) =>
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
plans: () => req<Plan[]>("/api/staff/plans"),
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
method: "PUT",
body: JSON.stringify(plan),
}),
updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) =>
put<{ updated: boolean }>(`/api/staff/plans/${deployment}/${tier}`, plan),
catalogue: () => req<CatalogueRow[]>("/api/staff/catalogue"),
updateCatalogue: (row: CatalogueRow) =>
put<{ updated: boolean }>("/api/staff/catalogue", row),
entitlement: (id: string) =>
req<{ entitlement: Entitlement; pending: boolean }>(
`/api/staff/instances/${id}/entitlement`,
),
setEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean },
) =>
put<{ entitlement: Entitlement; pending: boolean }>(
`/api/staff/instances/${id}/entitlement`, body),
audit: (accountId?: string) =>
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
injectionHealth: () =>
+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;
}
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
export type ThemePref = "light" | "dark" | "system";
const KEY = "vantage-hq-theme";
/*
* globals.css has always defined the dark tokens under BOTH
* :root[data-theme="dark"] and :root[data-theme="light"], specifically so an
* in-page control can win over the OS preference in either direction. Nothing
* ever set the attribute. This is that missing half.
*
* "system" removes the attribute rather than writing a value, which hands the
* decision back to the prefers-color-scheme block.
*/
export function applyTheme(pref: ThemePref) {
const root = document.documentElement;
if (pref === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", pref);
}
export function readTheme(): ThemePref {
if (typeof localStorage === "undefined") return "system";
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" ? v : "system";
}
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.
const [pref, setPref] = useState<ThemePref>("system");
useEffect(() => setPref(readTheme()), []);
return [
pref,
(next: ThemePref) => {
setPref(next);
if (next === "system") localStorage.removeItem(KEY);
else localStorage.setItem(KEY, next);
applyTheme(next);
},
];
}
/*
* 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){}`;
+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"
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/agent
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
go 1.26
+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)
+1 -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,6 @@ 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)
}
+3 -5
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
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
// 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) {}
+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()
+15 -38
View File
@@ -17,14 +17,14 @@ 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"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
)
func Run(ctx context.Context, cfg *config.Config, version string) error {
@@ -34,7 +34,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 +63,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,8 +117,6 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return nil
}
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
@@ -177,9 +169,6 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
log.Println("command stream connected")
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
sendMu.Lock()
@@ -219,7 +208,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 +268,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 +285,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 +295,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
return
case <-ticker.C:
tick++
report(tick%30 == 0)
report(tick%30 == 0)
}
}
}
@@ -321,7 +308,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
@@ -363,21 +349,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 +386,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
@@ -434,8 +413,7 @@ 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 {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
@@ -444,7 +422,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
resp, err := http.Get(url)
if err != nil {
return err
}
@@ -462,7 +440,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 +532,6 @@ func localIP() string {
return ""
}
func GenerateAndUpload(cfg *config.Config, label string) error {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
+165 -15
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,15 @@ 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/ # InstanceCard, Ledger, Queue, EnvBadge
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
│ └── lib/ # api client, session guards, formatters
├── 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
@@ -179,10 +179,81 @@ Signup is **account-first**: it creates an HQ account and an unverified `custome
- If sending the verification email fails, the freshly inserted `customer_user` (and account, on first signup) is rolled back rather than left stranded holding the unique index on email.
- Rate limited per client IP, plus a honeypot field.
An account is a team, not a person. `customer_users.account_role` is `owner`,
`admin` or `member` — the same three words as the control plane's roles, on
purpose. Owners and admins invite people, create instances and grant instance
access; billing is owner-only.
An invitation creates a `customer_user` with an **empty password hash**, which
cannot authenticate, and the invitee sets their own at `/accept-invite`. An
inviter-chosen password would be a shared credential to every instance that
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.
### Grants project, they do not federate
Granting someone access to a cloud instance writes a real control-plane `users`
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
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
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.
**Self-hosted instances are never projected into.** All three mutating member
endpoints refuse when `deployment != cloud`.
The HQ password is the single source of truth for every `hq`-sourced row.
`PUT /api/account/password` rehashes and has `cloudprov` copy the hash to every
projected row; propagation is best-effort, and `admin/internal/hqsync` compares
and repairs every 15 minutes. It is **its own package rather than a pass inside
`inject`** — `inject` writes three licence fields and nothing else, and that
narrowness is what makes admin's reach into the control plane reviewable.
The control plane refuses to change an `hq`-sourced user's role or delete it
(`services.ErrHQManaged`, 409). `web/` shows those rows read-only with a link to
the portal, but the API is the boundary; the UI is a courtesy. There is no local
password-change endpoint at all, so there is no competing writer for the hash.
---
## Auth and Orgs
@@ -263,10 +334,17 @@ console POST /console/connect · GET /console/tunnel (websocket)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
licence GET /license · POST /license (POST: self-hosted only)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
```
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
`POST /license` is also in `licenceExemptPaths`: pasting a valid licence has to work while the current one is expired, because it is the way out of degraded mode.
Free exists in both deployments, so it is no longer cloud-only by construction. The one-Free-per-account rule is enforced per account **and deployment**, in `licensing.checkFreeLimit` and in `createInstance`'s friendly pre-check — the two must stay scoped identically, because a pre-check stricter than the issuer refuses what would have worked. A self-hosted Free licence is claimed with `POST /api/instances/:id/claim-free` after the install is linked; the metered server count and per-instance feature toggles come from the instance's `entitlement`, which a licence snapshots at issue time (see the `catalogue`/`entitlements` note under MongoDB Collections).
---
## Admin REST API (`admin`, :8083)
@@ -279,31 +357,57 @@ GET /auth/me # who am I; 401 drives the UI's r
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`:
```
GET /account # account, instances, max_relinks
POST /instances # create a cloud instance (Free tier, capped at one per account)
POST /instances # create a cloud instance (Free tier, one Free per account per deployment)
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
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
PUT /account/password # propagates to every projected user
GET,POST /instances/:id/members # cloud only
PUT /instances/:id/members/:uid/role · DELETE /instances/:id/members/:uid
```
Reading is open to any signed-in member; every mutation above except
`/account/password` (which is your own) sits behind `RequireAccountRole(owner,
admin)`. `:uid` is the **`customer_users.user_id`**, not the projected
control-plane user_id — the portal never has to know that one.
Staff-session (`/api/staff`):
```
GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID
GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state
POST /instances/:id/issue · /instances/:id/relink
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:tier
GET /health/injection
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
GET,PUT /catalogue
GET,PUT /instances/:id/entitlement
GET /health/injection · /health/billing
```
**Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters.
### Billing (Paddle)
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired``granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase 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`
@@ -320,6 +424,10 @@ Notes that are not obvious from the structs:
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
- `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` · `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.
### Migrations
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
@@ -439,17 +547,31 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
There are **three separate visual identities**, and the split is deliberate:
All three 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/` | `#0f1117` | indigo `#6366f1` | dark only, locked |
| `site/` | token-based | brand navy `#0b2a58` / `#5b9be8` | light + dark |
| `adminsite/` | **the same tokens as `site/`** | brand navy | light + dark, light default |
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. **Change them in both files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. Tailwind in `adminsite/` maps `var(--…)` references only, so no component may carry a hex value. `site/` names the semantic three `--up`/`--pend`/`--down` for monitor state; `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state — same colours.
`adminsite/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/` defaults to **light** on purpose: `web/` is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to `#5b9be8`, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label.
There is a **fourth** 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 three 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.
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the three token blocks still be diffed by eye. `web/` also adds three tokens site/ has no use for: `--accent-hover` and `--down-hover` (site/ brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
`web/` is locked to dark and `adminsite/` defaults to **light**, and that pairing is the point: an operator with both open should never mistake one for the other before clicking Reissue. Now that both are drawn from the same palette the distinction rests **entirely** on the ground, so do not make dark the adminsite default and do not give web/ a light theme. State never reads by colour alone in either: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either.
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
**The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan.
Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions.
`InstanceRecord` is one component open or closed, and it **replaced** `InstanceCard`. Closed it is a row; open it adds licence contents, members and actions. It defaults open when the instance is the only one or needs attention, and a manual toggle is remembered per instance in `localStorage`. Do not reintroduce a second summary component — the split is what left a one-instance account showing a third of a row and nothing else.
| Route | Purpose |
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
@@ -464,7 +586,17 @@ There are **three separate visual identities**, and the split is deliberate:
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
| `/secrets`, `/secrets/[group]` | Vault |
| `/audit` | Audit log |
| `/settings`, `/settings/org`, `/settings/notifications` | Alerts, members, OIDC, channels |
| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence |
**`/settings` is one page, not a section.** Members and single sign-on used to
live at `/settings/instance` with their own sidebar entry; they are now the
**Access** group at the top of `/settings`, above **Monitoring** and
**Integrations**. Splitting "who can sign in" away from "how this instance
behaves" made two half-pages and a nav entry called Instance that no one could
distinguish from Settings. `next.config.ts` keeps a permanent redirect from the
old path. The cards live in `web/components/settings/` rather than in the page,
which is also where the `Field`/`inputClass` pair the three of them share now
lives — one copy instead of the three that existed while they were apart.
---
@@ -484,13 +616,26 @@ GOOS=linux GOARCH=amd64 go build \
Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`.
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host:
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
```bash
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
```
**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 |
`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.
### Tagging
```bash
@@ -512,6 +657,11 @@ git push origin main # server + web deploy
| `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. |
| `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. |
---
+24
View File
@@ -0,0 +1,24 @@
# Vantage self-hosted — copy to .env and fill in.
# Used by: docker compose up -d
# --- Required ---
# host:port agents dial for gRPC. No default; boot fails without it.
# Must be reachable from managed servers. Use the public host, port 9090.
GRPC_HOST=192.168.1.250:9090
# Gitea host used to build agent install scripts and download URLs.
GITEA_HOST=gitea.hostxtra.co.uk
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
# secrets, OIDC secrets, RDP/VNC credentials.
# Generate: openssl rand -hex 32
KEY_ENCRYPTION_KEY=
# --- Optional (defaults shown) ---
# MongoDB is bundled in this compose file. Override only to use an external DB.
MONGO_URI=mongodb://mongo:27017/vantage
# Where workflow run logs are written inside the server container.
# VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
+3 -18
View File
@@ -1,9 +1,4 @@
services:
# Adds to the base `server` service defined in docker-compose.yml —
# only the cloud deployment reaps abandoned Free instances.
server:
environment:
FREE_INSTANCE_REAP_AFTER: "336h"
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
@@ -18,7 +13,6 @@ services:
- 8082:8082
environment:
PORT: "8082"
MONGO_URI: ${MONGO_URI:-}
PUBLIC_URL: ${PUBLIC_URL:-}
SITE_ORIGIN: ${SITE_ORIGIN:-}
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
@@ -42,12 +36,6 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:-}
PUBLIC_URL: ${ADMIN_PUBLIC_URL:-}
# Comma-separated, and it needs BOTH browser origins that call admin:
# vantage-hq (the console) and the marketing site, whose /start form
# posts account signups here. An origin missing from this list is not
# a 403 — admin omits the CORS header entirely and the browser blocks
# the preflight, so the form fails with nothing in admin's logs.
# ADMIN_ORIGIN="https://vantage-hq.hostxtra.co.uk,https://vantage.hostxtra.co.uk"
ADMIN_ORIGIN: ${ADMIN_ORIGIN:-}
TRUST_PROXY: ${TRUST_PROXY:-true}
SMTP_HOST: ${SMTP_HOST:-}
@@ -57,16 +45,13 @@ services:
SMTP_FROM: ${SMTP_FROM:-}
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
FREE_INSTANCE_REAP_AFTER: "336h"
# The staff and customer console, served at vantage-hq.hostxtra.co.uk.
# ADMIN_API_URL is baked into the image at build time, not read here, so
# changing it needs a rebuild rather than a restart — and it must appear in
# admin's ADMIN_ORIGIN above or the browser blocks every request.
PADDLE_ENV: ${PADDLE_ENV:-sandbox}
PADDLE_API_KEY: ${PADDLE_API_KEY:-}
PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-}
adminsite:
image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
restart: unless-stopped
ports:
# 3000 is web, 3003 is the marketing site; this takes 3004.
- 3004:3000
depends_on:
- admin
+18 -1
View File
@@ -12,6 +12,21 @@ services:
interval: 10s
timeout: 5s
retries: 5
mongo:
image: mongo:7
restart: unless-stopped
volumes:
- mongo_data:/data/db
healthcheck:
test:
- CMD
- mongosh
- --quiet
- --eval
- "db.adminCommand('ping')"
interval: 10s
timeout: 5s
retries: 5
guacd:
image: docker.io/guacamole/guacd:1.6.0
restart: unless-stopped
@@ -24,7 +39,7 @@ services:
- 8080:8080
- 9090:9090
environment:
MONGO_URI: ${MONGO_URI:-}
MONGO_URI: ${MONGO_URI:-mongodb://mongo:27017/vantage}
REDIS_ADDR: redis:6379
GITEA_HOST: ${GITEA_HOST}
GRPC_HOST: ${GRPC_HOST}
@@ -37,6 +52,8 @@ services:
depends_on:
redis:
condition: service_healthy
mongo:
condition: service_healthy
volumes:
- ./data:/data
web:
+153 -134
View File
@@ -12,13 +12,13 @@
- **No automated Go tests.** Verification is by compiler, `grep`, `lkctl`, and running the built image against scratch databases. Every "confirm" step below is a container command with expected output.
- **Never run `go run` or `npm` on the host.** Everything runs in a container. The wrapper from plans 1 and 2:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
-e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" golang:1.26 "$@"
```
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
-e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" golang:1.26 "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry" — this exact bug shipped and was caught by the image build in plan 2, not by `go build`.
- Admin is **excluded from the self-hosted deployment**: `deploy/docker-compose.site.yml` only, like sitesvc.
@@ -40,29 +40,29 @@ There is deliberately no automated backfill. Those instances get licensed by han
**Created:**
| Path | Responsibility |
|---|---|
| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` |
| `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP |
| `admin/cmd/adminctl/main.go` | staff user creation; deliberately has no HTTP surface |
| `admin/internal/config/config.go` | env parsing, fail-fast validation |
| `admin/internal/db/db.go` | `Admin()` and `Control()` collections, connect, index creation |
| `admin/internal/models/models.go` | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` |
| `admin/internal/models/plans.go` | plan seeding from `shared/license` |
| `admin/internal/licensing/issue.go` | `Issue`, the Free rule, the deployment check, supersession |
| `admin/internal/licensing/link.go` | `LinkInstance`, `Relink` |
| `admin/internal/inject/inject.go` | `InjectCloud`, `Reconcile`, `StartReconciler` |
| `admin/internal/auth/session.go` | Redis sessions, `admin_session` cookie |
| `admin/internal/auth/staff.go` | staff login |
| `admin/internal/auth/cloud.go` | cloud-owner login against the control plane |
| `admin/internal/auth/customer.go` | self-hosted customer login, signup, email verification |
| `admin/internal/auth/ratelimit.go` | per-email and per-IP limiters |
| `admin/internal/auth/middleware.go` | `RequireStaff`, `RequireCustomer`, `AccountID` |
| `admin/internal/api/routes.go` | route table — the single place scoping is guaranteed |
| `admin/internal/api/customer.go` | customer handlers |
| `admin/internal/api/staff.go` | staff handlers |
| `admin/internal/mail/mail.go` | licence delivery and verification email |
| `admin/internal/audit/audit.go` | `admin_audit` writes |
| Path | Responsibility |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` |
| `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP |
| `admin/cmd/adminctl/main.go` | staff user creation; deliberately has no HTTP surface |
| `admin/internal/config/config.go` | env parsing, fail-fast validation |
| `admin/internal/db/db.go` | `Admin()` and `Control()` collections, connect, index creation |
| `admin/internal/models/models.go` | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` |
| `admin/internal/models/plans.go` | plan seeding from `shared/license` |
| `admin/internal/licensing/issue.go` | `Issue`, the Free rule, the deployment check, supersession |
| `admin/internal/licensing/link.go` | `LinkInstance`, `Relink` |
| `admin/internal/inject/inject.go` | `InjectCloud`, `Reconcile`, `StartReconciler` |
| `admin/internal/auth/session.go` | Redis sessions, `admin_session` cookie |
| `admin/internal/auth/staff.go` | staff login |
| `admin/internal/auth/cloud.go` | cloud-owner login against the control plane |
| `admin/internal/auth/customer.go` | self-hosted customer login, signup, email verification |
| `admin/internal/auth/ratelimit.go` | per-email and per-IP limiters |
| `admin/internal/auth/middleware.go` | `RequireStaff`, `RequireCustomer`, `AccountID` |
| `admin/internal/api/routes.go` | route table — the single place scoping is guaranteed |
| `admin/internal/api/customer.go` | customer handlers |
| `admin/internal/api/staff.go` | staff handlers |
| `admin/internal/mail/mail.go` | licence delivery and verification email |
| `admin/internal/audit/audit.go` | `admin_audit` writes |
**Modified:** `go.work`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`.
@@ -71,14 +71,16 @@ There is deliberately no automated backfill. Those instances get licensed by han
### Task 1: Module, config and boot
**Files:**
- Create: `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore`, `admin/cmd/main.go`, `admin/internal/config/config.go`, `admin/internal/db/db.go`
- Modify: `go.work`
**Interfaces:**
- Consumes: `shared/models`, `shared/license`
- Produces:
- `config.Config` with `Load() (Config, error)`
- `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`
- `config.Config` with `Load() (Config, error)`
- `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`
- [x] **Step 1: Create the module**
@@ -90,7 +92,7 @@ mkdir -p admin/cmd admin/internal/config admin/internal/db
Create `admin/go.mod`:
```
module github.com/mrhid6/vantage/admin
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26.4
@@ -98,13 +100,13 @@ 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
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
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
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
```
Add the module to `go.work`:
@@ -255,7 +257,7 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -323,8 +325,8 @@ import (
"time"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
)
func main() {
@@ -445,16 +447,18 @@ git commit -m "feat(admin): module skeleton, config and two database connections
### Task 2: Models, indexes and the plan seed
**Files:**
- Create: `admin/internal/models/models.go`, `admin/internal/models/plans.go`
- Modify: `admin/internal/db/db.go` (add `EnsureIndexes`), `admin/cmd/main.go` (call it)
**Interfaces:**
- Consumes: `shared/license`
- Produces:
- `models.Account`, `models.Instance`, `models.License`, `models.Subscription`, `models.Plan`, `models.StaffUser`, `models.CustomerUser`, `models.AuditEntry`
- status and reason constants
- `models.SeedPlans(ctx) error`
- `db.EnsureIndexes(ctx) error`
- `models.Account`, `models.Instance`, `models.License`, `models.Subscription`, `models.Plan`, `models.StaffUser`, `models.CustomerUser`, `models.AuditEntry`
- status and reason constants
- `models.SeedPlans(ctx) error`
- `db.EnsureIndexes(ctx) error`
- [x] **Step 1: Write the documents**
@@ -471,7 +475,7 @@ package models
import (
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -639,8 +643,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"
)
@@ -761,7 +765,7 @@ In `admin/cmd/main.go`, after the connect block:
idxCancel()
```
Import `"github.com/mrhid6/vantage/admin/internal/models"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"`.
- [x] **Step 5: Build**
@@ -785,15 +789,17 @@ git commit -m "feat(admin): documents, indexes and the plan seed"
The core of the service. Everything else exists to call this correctly.
**Files:**
- Create: `admin/internal/licensing/issue.go`, `admin/internal/audit/audit.go`
**Interfaces:**
- Consumes: `models`, `db`, `license.Sign`
- Produces:
- `licensing.Issue(ctx, in IssueInput) (*models.License, error)`
- `type IssueInput struct { InstanceID, Tier, Term, Reason, IssuedBy string; ExpiresAt time.Time }`
- `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
- `audit.Write(ctx, e models.AuditEntry)`
- `licensing.Issue(ctx, in IssueInput) (*models.License, error)`
- `type IssueInput struct { InstanceID, Tier, Term, Reason, IssuedBy string; ExpiresAt time.Time }`
- `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
- `audit.Write(ctx, e models.AuditEntry)`
- [x] **Step 1: Write the audit helper**
@@ -809,8 +815,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
@@ -838,10 +844,10 @@ import (
"time"
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1016,7 +1022,7 @@ In `admin/cmd/main.go`, after config loads:
licensing.SetSigningKey(cfg.SigningKey)
```
Import `"github.com/mrhid6/vantage/admin/internal/licensing"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"`.
- [x] **Step 4: Build**
@@ -1038,15 +1044,17 @@ git commit -m "feat(admin): licence issuance with plan snapshots and supersessio
### Task 4: Injection and reconciliation
**Files:**
- Create: `admin/internal/inject/inject.go`
- Modify: `admin/cmd/main.go`
**Interfaces:**
- Consumes: `db.Control`, `models`
- Produces:
- `inject.Cloud(ctx, lic *models.License) error`
- `inject.Reconcile(ctx) (checked, repaired int, err error)`
- `inject.StartReconciler(ctx)`
- `inject.Cloud(ctx, lic *models.License) error`
- `inject.Reconcile(ctx) (checked, repaired int, err error)`
- `inject.StartReconciler(ctx)`
- [x] **Step 1: Write it**
@@ -1066,10 +1074,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"
)
@@ -1215,7 +1223,7 @@ In `admin/cmd/main.go`, before the HTTP server starts:
inject.StartReconciler(reconcileCtx)
```
Import `"github.com/mrhid6/vantage/admin/internal/inject"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"`.
- [x] **Step 3: Build**
@@ -1245,16 +1253,18 @@ git commit -m "feat(admin): cloud injection and the 15-minute reconciler"
### Task 5: Sessions and staff auth
**Files:**
- Create: `admin/internal/auth/session.go`, `admin/internal/auth/staff.go`, `admin/internal/auth/middleware.go`, `admin/cmd/adminctl/main.go`
**Interfaces:**
- Consumes: `db`, Redis
- Produces:
- `auth.Session{UserID, Kind, Email, AccountID}` where `Kind` is `"staff"` or `"customer"`
- `auth.InitRedis(addr)`, `auth.Save`, `auth.Get`, `auth.Destroy`, `auth.SetCookie`
- `auth.RequireStaff()`, `auth.RequireCustomer()`, `auth.Current(c) *Session`
- `auth.HandleStaffLogin`, `auth.HandleLogout`
- `adminctl staff-add`
- `auth.Session{UserID, Kind, Email, AccountID}` where `Kind` is `"staff"` or `"customer"`
- `auth.InitRedis(addr)`, `auth.Save`, `auth.Get`, `auth.Destroy`, `auth.SetCookie`
- `auth.RequireStaff()`, `auth.RequireCustomer()`, `auth.Current(c) *Session`
- `auth.HandleStaffLogin`, `auth.HandleLogout`
- `adminctl staff-add`
- [x] **Step 1: Write sessions**
@@ -1430,9 +1440,9 @@ import (
"strings"
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -1587,9 +1597,9 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -1688,9 +1698,11 @@ git commit -m "feat(admin): sessions, staff auth and adminctl"
### Task 6: Cloud customer auth
**Files:**
- Create: `admin/internal/auth/cloud.go`
**Interfaces:**
- Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models`
- Produces: `auth.HandleCloudLogin`
@@ -1706,10 +1718,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
adminmodels "github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
adminmodels "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -1817,9 +1829,11 @@ git commit -m "feat(admin): cloud owner login against the control plane"
### Task 7: Self-hosted customer auth
**Files:**
- Create: `admin/internal/auth/customer.go`, `admin/internal/mail/mail.go`
**Interfaces:**
- Consumes: `db.Admin("customer_users")`, config SMTP
- Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense`
@@ -1903,10 +1917,10 @@ import (
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -2068,16 +2082,18 @@ git commit -m "feat(admin): self-hosted customer accounts with email verificatio
### Task 8: Linking, relink and the customer API
**Files:**
- Create: `admin/internal/licensing/link.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`
- Modify: `admin/cmd/main.go`
**Interfaces:**
- Consumes: `licensing.Issue`, `inject.Deliver`, `mail.SendLicense`
- Produces:
- `licensing.LinkInstance(ctx, accountID, instanceID, name string) (*models.Instance, error)`
- `licensing.Relink(ctx, accountID, oldID, newID string, staff bool) (*models.License, error)`
- `api.Routes(cfg) http.Handler`
- `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper
- `licensing.LinkInstance(ctx, accountID, instanceID, name string) (*models.Instance, error)`
- `licensing.Relink(ctx, accountID, oldID, newID string, staff bool) (*models.License, error)`
- `api.Routes(cfg) http.Handler`
- `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper
- [x] **Step 1: Write linking and relink**
@@ -2093,10 +2109,10 @@ import (
"time"
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -2220,13 +2236,13 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"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"
"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/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"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -2404,8 +2420,8 @@ import (
"slices"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
)
func Routes(cfg config.Config) http.Handler {
@@ -2481,7 +2497,7 @@ In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:
Handler: api.Routes(cfg),
```
Import `"github.com/mrhid6/vantage/admin/internal/api"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"`.
- [x] **Step 5: Build**
@@ -2503,9 +2519,11 @@ git commit -m "feat(admin): linking, relink and the scoped customer API"
### Task 9: Staff API
**Files:**
- Create: `admin/internal/api/staff.go`
**Interfaces:**
- Consumes: `licensing.Issue`, `licensing.Relink`, `models`
- Produces: the handlers named in `routes.go`
@@ -2522,13 +2540,13 @@ import (
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -2888,6 +2906,7 @@ git commit -m "feat(admin): staff API"
### Task 10: Deployment wiring
**Files:**
- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`
- [x] **Step 1: Add the service**
@@ -2895,26 +2914,26 @@ git commit -m "feat(admin): staff API"
In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:
```yaml
admin:
admin:
image: ${DOCKER_HOST}/mrhid6/vantage-admin:latest
restart: unless-stopped
ports:
- "8083:8083"
- "8083:8083"
environment:
ADMIN_MONGO_URI: ${ADMIN_MONGO_URI}
CONTROL_MONGO_URI: ${CONTROL_MONGO_URI}
REDIS_ADDR: redis:6379
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY}
PUBLIC_URL: ${ADMIN_PUBLIC_URL}
ADMIN_ORIGIN: ${ADMIN_ORIGIN}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_FROM: ${SMTP_FROM}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
TRUST_PROXY: "true"
ADMIN_MONGO_URI: ${ADMIN_MONGO_URI}
CONTROL_MONGO_URI: ${CONTROL_MONGO_URI}
REDIS_ADDR: redis:6379
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY}
PUBLIC_URL: ${ADMIN_PUBLIC_URL}
ADMIN_ORIGIN: ${ADMIN_ORIGIN}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_FROM: ${SMTP_FROM}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
TRUST_PROXY: "true"
depends_on:
- redis
- redis
```
`LICENSE_SIGNING_KEY` appears in exactly one service in exactly one compose file. It must never be added to `server`, and `deploy/docker-compose.yml` — the self-hosted deployment — must not mention admin at all.
@@ -3149,14 +3168,14 @@ git commit -m "chore: verify the admin backend end to end" --allow-empty
## Risks
| Risk | Mitigation |
|---|---|
| Admin becomes a runtime dependency | Task 11 Step 10 verifies instances work with admin stopped |
| Signing key exposure | One service, one variable, one compose file; never in `server`; rotation path from plan 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, every attempt audited; state it in the release notes |
| Injection silently fails | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection` |
| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it |
| Self-hosted UUID squatted | Unique index on `admin_instances.instance_id`, non-disclosing error |
| A customer route forgets to scope | `ownedInstance` helper, audited by hand in Task 9 Step 3 |
| Risk | Mitigation |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| Admin becomes a runtime dependency | Task 11 Step 10 verifies instances work with admin stopped |
| Signing key exposure | One service, one variable, one compose file; never in `server`; rotation path from plan 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, every attempt audited; state it in the release notes |
| Injection silently fails | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection` |
| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it |
| Self-hosted UUID squatted | Unique index on `admin_instances.instance_id`, non-disclosing error |
| A customer route forgets to scope | `ownedInstance` helper, audited by hand in Task 9 Step 3 |
| Existing cloud instances stay read-only longer than intended | Licensing them is the first job the admin UI is used for; Task 11 Step 5 proves the flow before the UI exists |
| An admin instance row points at no real cloud instance | `staffCreateInstance` refuses a cloud instance the control plane does not have |
| An admin instance row points at no real cloud instance | `staffCreateInstance` refuses a cloud instance the control plane does not have |
@@ -28,14 +28,14 @@
**Created:**
| Path | Responsibility |
|---|---|
| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode |
| Path | Responsibility |
| -------------------------------------------- | ------------------------------------------------------------------------------------ |
| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode |
| `server/internal/services/licence_limits.go` | `CheckServerLimit`, `CheckSecretGroupLimit`, `CheckChannelLimit`, `ErrLimitExceeded` |
| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` |
| `web/app/(app)/settings/license/page.tsx` | Licence settings page |
| `web/components/LicenseBanner.tsx` | Persistent banner |
| `web/lib/useLicense.ts` | `useLicense()` hook |
| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` |
| `web/app/(app)/settings/license/page.tsx` | Licence settings page |
| `web/components/LicenseBanner.tsx` | Persistent banner |
| `web/lib/useLicense.ts` | `useLicense()` hook |
**Modified:** `shared/models/instance.go`, `server/internal/api/handlers.go`, `server/internal/services/servers.go`, `secrets.go`, `channels.go`, `server/internal/auth/oidc.go`, `server/internal/auth/instancehost.go`, `server/internal/api/org.go` (renamed), `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/layout.tsx`, `web/app/setup/page.tsx`.
@@ -46,11 +46,13 @@
Plan 0b's naming map said the rename was total. It is not: 18 private identifiers still say `Org`. None affect the wire format, the database or any route, so nothing is broken — but this plan adds a licence cache next to the instance cache in the same file, and leaving two naming conventions side by side there is how the next person gets confused.
**Files:**
- Modify: `server/internal/auth/instancehost.go`, `server/internal/auth/oidc.go`, `server/internal/auth/session.go`
- Modify: `server/internal/services/instance_oidc.go`
- Rename: `server/internal/api/org.go``server/internal/api/instance.go`
**Interfaces:**
- Consumes: nothing
- Produces: `services.SaveInstanceOIDC` replacing `SaveOrgOIDC`; `auth.SaveStateInstance` / `auth.ConsumeStateInstance` replacing the `...Org` forms
@@ -118,9 +120,11 @@ field or route changes."
### Task 2: Store the licence on the instance
**Files:**
- Modify: `shared/models/instance.go`
**Interfaces:**
- Consumes: nothing
- Produces: `models.Instance` gains `LicenseBlob string`, `LicenseTier string`, `LicenseExpiry *time.Time`
@@ -177,18 +181,20 @@ git commit -m "feat(shared): add licence fields to Instance"
### Task 3: Runtime licence state
**Files:**
- Create: `server/internal/services/licence.go`
**Interfaces:**
- Consumes: `license.Verify`, `license.Result`, `services.GetInstance`
- Produces:
- `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }`
- `func (s LicenseState) Active() bool`
- `func (s LicenseState) Feature(name string) bool`
- `func DeploymentMode() string`
- `func GetLicenseState(instanceID string) LicenseState`
- `func StoreLicense(instanceID, blob string) (LicenseState, error)`
- `func InvalidateLicenseCache(instanceID string)`
- `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }`
- `func (s LicenseState) Active() bool`
- `func (s LicenseState) Feature(name string) bool`
- `func DeploymentMode() string`
- `func GetLicenseState(instanceID string) LicenseState`
- `func StoreLicense(instanceID, blob string) (LicenseState, error)`
- `func InvalidateLicenseCache(instanceID string)`
- [ ] **Step 1: Write it**
@@ -205,8 +211,8 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -391,17 +397,19 @@ git commit -m "feat(server): resolve licence state per instance"
Limits need a count, which middleware does not have.
**Files:**
- Create: `server/internal/services/licence_limits.go`
- Modify: `server/internal/services/servers.go` (`CreateServer`), `secrets.go` (`UpsertSecrets`), `channels.go` (`CreateChannel`)
**Interfaces:**
- Consumes: `GetLicenseState`, `license.WithinLimit`
- Produces:
- `type LimitError struct { Limit string; Current, Max int }` with `Error() string`
- `func CheckServerLimit(instanceID string) error`
- `func CheckSecretGroupLimit(instanceID, group string) error`
- `func CheckChannelLimit(instanceID string) error`
- `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)`
- `type LimitError struct { Limit string; Current, Max int }` with `Error() string`
- `func CheckServerLimit(instanceID string) error`
- `func CheckSecretGroupLimit(instanceID, group string) error`
- `func CheckChannelLimit(instanceID string) error`
- `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)`
- [ ] **Step 1: Write the limit checks**
@@ -415,8 +423,8 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -574,15 +582,17 @@ git commit -m "feat(server): enforce licence limits on servers, secret groups an
### Task 5: The API and the two middlewares
**Files:**
- Create: `server/internal/api/licence.go`
- Modify: `server/internal/api/handlers.go`
**Interfaces:**
- Consumes: `GetLicenseState`, `StoreLicense`, `LicenseUsage`, `LimitError`
- Produces:
- `func RequireActiveLicense() gin.HandlerFunc`
- `func RequireFeature(name string) gin.HandlerFunc`
- `GET /api/license`, `POST /api/license`
- `func RequireActiveLicense() gin.HandlerFunc`
- `func RequireFeature(name string) gin.HandlerFunc`
- `GET /api/license`, `POST /api/license`
- [ ] **Step 1: Write the handlers and middlewares**
@@ -597,10 +607,10 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
@@ -924,10 +934,12 @@ git commit -m "feat(server): gate mutations and features on the licence"
The paths that do not go through gin. This is where "read-only" has to be specific.
**Files:**
- Modify: `server/internal/monitorsched/scheduler.go` (comment only)
- Modify: `server/internal/grpc/server.go` (`Register`)
**Interfaces:**
- Consumes: `services.GetLicenseState`, `services.CheckServerLimit`
- Produces: no new API
@@ -997,10 +1009,12 @@ ability to know their infrastructure is on fire."
A self-hosted operator needs the UUID to activate a licence, and `/setup` is where they first need it.
**Files:**
- Modify: `server/internal/auth/local.go` (`HandleBootstrap` response)
- Modify: `web/app/setup/page.tsx`
**Interfaces:**
- Consumes: nothing
- Produces: `POST /auth/bootstrap` response gains `instance_id`
@@ -1025,11 +1039,7 @@ In `web/app/setup/page.tsx`, in the "Instance created" block, add below the slug
<p className="text-xs text-text-tertiary">Instance ID — needed to activate a licence</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm text-text-primary">{created.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(created.instance_id)}
>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(created.instance_id)}>
Copy
</button>
</div>
@@ -1059,10 +1069,12 @@ git commit -m "feat: show the instance ID after setup"
### Task 8: Frontend licence state
**Files:**
- Create: `web/lib/useLicense.ts`, `web/components/LicenseBanner.tsx`, `web/app/(app)/settings/license/page.tsx`
- Modify: `web/lib/api.ts`, `web/app/(app)/layout.tsx`, `web/components/Sidebar.tsx`
**Interfaces:**
- Consumes: `GET /api/license`, `POST /api/license`
- Produces: `useLicense()`, `<LicenseBanner />`
@@ -1144,9 +1156,10 @@ export function LicenseBanner() {
const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently";
return (
<div className="bg-amber-900/40 px-4 py-2 text-sm text-amber-100">
Your Vantage licence expired on {when}. Your servers and monitors are still running,
but changes are disabled until it is renewed.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "}
<Link href="/settings/license" className="underline">
Add a licence
</Link>
</div>
);
}
@@ -1155,7 +1168,9 @@ export function LicenseBanner() {
return (
<div className="bg-red-900/40 px-4 py-2 text-sm text-red-100">
This instance has no valid licence. Changes are disabled.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
<Link href="/settings/license" className="underline">
Add a licence
</Link>
</div>
);
}
@@ -1218,19 +1233,18 @@ export default function LicensePage() {
<section className="rounded border border-border p-4">
<p className="text-sm text-text-secondary">
State: <b>{license.state}</b>
{license.tier ? <> · Tier: <b>{license.tier}</b></> : null}
{license.tier ? (
<>
{" "}
· Tier: <b>{license.tier}</b>
</>
) : null}
{license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()}</> : null}
</p>
<p className="mt-2 text-xs text-text-tertiary">
Instance ID — quote this when buying or activating a licence
</p>
<p className="mt-2 text-xs text-text-tertiary">Instance ID — quote this when buying or activating a licence</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm">{license.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(license.instance_id)}
>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(license.instance_id)}>
Copy
</button>
</div>
@@ -1239,9 +1253,15 @@ export default function LicensePage() {
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Usage</h2>
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
<li>Servers: {license.usage.servers} of {cap(license.limits.max_servers)}</li>
<li>Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}</li>
<li>Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}</li>
<li>
Servers: {license.usage.servers} of {cap(license.limits.max_servers)}
</li>
<li>
Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}
</li>
<li>
Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}
</li>
<li>Browser console: {license.features.console ? "Included" : "Not included"}</li>
<li>Single sign-on: {license.features.oidc ? "Included" : "Not included"}</li>
</ul>
@@ -1265,12 +1285,7 @@ export default function LicensePage() {
}}
/>
{error ? <p className="mt-2 text-sm text-red-400">{error}</p> : null}
<button
type="button"
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm"
disabled={!blob.trim() || save.isPending}
onClick={() => save.mutate()}
>
<button type="button" className="mt-3 rounded bg-accent px-3 py-1.5 text-sm" disabled={!blob.trim() || save.isPending} onClick={() => save.mutate()}>
{save.isPending ? "Checking…" : "Save licence"}
</button>
</section>
@@ -1308,10 +1323,12 @@ Migration `0005`, cloud only. It cannot sign — the server holds no private key
Clumsy, and correct. The alternative is putting a signing key in the control plane, which is the thing this design most wants to avoid.
**Files:**
- Create: `server/internal/services/migrate_licence.go`
- Modify: `server/cmd/main.go`
**Interfaces:**
- Consumes: `license.Verify`, `DeploymentMode`
- Produces: `func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error`
@@ -1330,8 +1347,8 @@ import (
"os"
"time"
"github.com/mrhid6/vantage/shared/license"
"github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -1551,6 +1568,7 @@ curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
```
Expected, in order:
1. `"This licence was issued for a different instance. Your instance ID is <UUID>."`
2. `"This licence is for Vantage Cloud and cannot be used on a self-hosted install."` — **this is the check that makes Free cloud-only**
3. `"This licence key is not valid. Check it was copied in full."`
@@ -1636,11 +1654,11 @@ git commit -m "chore: verify instance licensing end to end" --allow-empty
## Risks
| Risk | Mitigation |
|---|---|
| A mutating route added later without a gate | Mounted on the `/api` group, so new routes are covered by default; Task 5 Step 6 audits the exceptions |
| Customer locked out and unable to recover | `POST /api/license` and every `DELETE` are exempt |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch; it refuses to record its marker if any instance was missed |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Monitoring lost on billing failure | Designed out — the scheduler has no licence check, and Task 10 Step 7 verifies it |
| Clock wrong on a self-hosted host | `Verify` reports `ClockSkewed`; surface it in the settings page if it becomes a support theme |
| Risk | Mitigation |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| A mutating route added later without a gate | Mounted on the `/api` group, so new routes are covered by default; Task 5 Step 6 audits the exceptions |
| Customer locked out and unable to recover | `POST /api/license` and every `DELETE` are exempt |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch; it refuses to record its marker if any instance was missed |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Monitoring lost on billing failure | Designed out — the scheduler has no licence check, and Task 10 Step 7 verifies it |
| Clock wrong on a self-hosted host | `Verify` reports `ClockSkewed`; surface it in the settings page if it becomes a support theme |
@@ -24,26 +24,26 @@
Apply exactly. Every task references this table.
| Today | After |
|---|---|
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| `services/orgs.go` | `services/instances.go` |
| Today | After |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| `services/orgs.go` | `services/instances.go` |
| `GetOrg`, `GetOrgBySlug`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg` | `GetInstance`, `GetInstanceBySlug`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance` |
| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` |
| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` |
| UI copy "Organisation"/"Organization" (tenant) | "Instance" |
| UI copy "Organisation" (customer, marketing site) | "Account" |
| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` |
| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` |
| UI copy "Organisation"/"Organization" (tenant) | "Instance" |
| UI copy "Organisation" (customer, marketing site) | "Account" |
---
@@ -51,15 +51,15 @@ Apply exactly. Every task references this table.
**Created:**
| Path | Responsibility |
|---|---|
| `shared/models/instance.go` | `Instance` (replaces `org.go`) |
| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) |
| `server/internal/services/instances.go` | replaces `orgs.go` |
| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` |
| `server/internal/auth/instancehost.go` | replaces `orghost.go` |
| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion |
| `server/cmd/rename-rollback/main.go` | one-shot inverse rename |
| Path | Responsibility |
| ---------------------------------------------- | -------------------------------------------------------- |
| `shared/models/instance.go` | `Instance` (replaces `org.go`) |
| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) |
| `server/internal/services/instances.go` | replaces `orgs.go` |
| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` |
| `server/internal/auth/instancehost.go` | replaces `orghost.go` |
| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion |
| `server/cmd/rename-rollback/main.go` | one-shot inverse rename |
**Modified:** `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`, every file in `server/internal/` referencing a renamed symbol, `server/cmd/main.go`, `sitesvc/internal/{models,store,api}`, `sitesvc/cmd/main.go`, `web/` and `site/` sources.
@@ -70,20 +70,22 @@ Apply exactly. Every task references this table.
Everything downstream depends on these names, so they change first.
**Files:**
- Rename: `shared/models/org.go``shared/models/instance.go`
- Rename: `shared/provision/org.go``shared/provision/instance.go`
- Modify: `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: the plan 0a API
- Produces:
- `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"`
- `models.User.InstanceID`, `models.Settings.InstanceID`
- `provision.CreateInstance(ctx, db, name) (*models.Instance, error)`
- `provision.RollbackInstance(ctx, db, instanceID) error`
- `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged
- `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)`
- `indexes.EnsureCoreIndexes` — now indexes `instances.slug`
- `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"`
- `models.User.InstanceID`, `models.Settings.InstanceID`
- `provision.CreateInstance(ctx, db, name) (*models.Instance, error)`
- `provision.RollbackInstance(ctx, db, instanceID) error`
- `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged
- `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)`
- `indexes.EnsureCoreIndexes` — now indexes `instances.slug`
- [ ] **Step 1: Rename the Instance model**
@@ -152,7 +154,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -219,7 +221,7 @@ func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string
}
```
The `ErrNameRejected` text keeps saying "organisation name" — that message is about the *customer's* organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it.
The `ErrNameRejected` text keeps saying "organisation name" — that message is about the _customer's_ organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it.
- [ ] **Step 4: Rename the user provisioning parameter**
@@ -304,13 +306,15 @@ git commit -m "refactor(shared): rename Org to Instance"
A code constant, not a runbook list. A collection missing from it is a collection whose tenant key never gets renamed — and with no test suite, the boot assertion is what catches that.
**Files:**
- Create: `server/internal/services/migrate_instance.go`
**Interfaces:**
- Consumes: nothing
- Produces:
- `var ScopedCollections []string`
- `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error`
- `var ScopedCollections []string`
- `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error`
- [ ] **Step 1: Write the list and the assertion**
@@ -428,9 +432,11 @@ git commit -m "chore(server): add ScopedCollections and the boot assertion"
The risky part of the plan.
**Files:**
- Modify: `server/internal/services/migrate_instance.go`
**Interfaces:**
- Consumes: `ScopedCollections`
- Produces: `func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error`
@@ -593,7 +599,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -637,7 +643,7 @@ mongorestore --drop --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dum
```
```javascript
db.adminCommand({renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances"})
db.adminCommand({ renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances" });
```
Then: `cd server && go run ./cmd/migratecheck`
@@ -671,6 +677,7 @@ Keep `/tmp/dump` — Task 8 needs it.
Mechanical, wide, and guarded by the compiler at every step. Work file by file and let `go build` drive.
**Files:**
- Rename: `server/internal/services/orgs.go``instances.go`
- Rename: `server/internal/services/org_oidc.go``instance_oidc.go`
- Rename: `server/internal/auth/orghost.go``instancehost.go`
@@ -679,6 +686,7 @@ Mechanical, wide, and guarded by the compiler at every step. Work file by file a
- Modify: `server/cmd/main.go`
**Interfaces:**
- Consumes: Task 1's shared API, Tasks 23's migration
- Produces: `services.GetInstance`, `GetInstanceBySlug`, `CreateInstance`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance`, `CountInstanceUsers`, `GetUserInInstance` — all with the same signatures as their `Org` predecessors
@@ -746,7 +754,7 @@ Replace `server/internal/models/instance.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
// Instance is defined in the shared module because sitesvc and the admin
// control plane write the same documents.
@@ -758,6 +766,7 @@ type Instance = shared.Instance
Run: `cd server && go build ./... 2>&1 | head -40`
Expected initially: a list of errors. Work through them. Common ones:
- A server-only model still declaring `OrgID` — rename the field there too.
- `reservedSlugs` references in `AdoptInstance` — should already point at `provision.ReservedSlugs` from plan 0a.
@@ -835,9 +844,11 @@ git commit -m "refactor(server): rename Org to Instance"
The recovery path. Deliberately a separate one-shot command rather than a migration — the only reason to run it is a decision to revert the release, which is a human decision.
**Files:**
- Create: `server/cmd/rename-rollback/main.go`
**Interfaces:**
- Consumes: `services.ScopedCollections`
- Produces: a binary, not an API
@@ -862,7 +873,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -969,9 +980,11 @@ git commit -m "feat(server): add rename-rollback command for migration 0004"
sitesvc writes the same documents. A version skew where it writes `org_id` while the control plane reads `instance_id` creates tenants the application cannot see — so it refuses to start against an unmigrated database.
**Files:**
- Modify: `sitesvc/internal/models/models.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/api/*.go`, `sitesvc/cmd/main.go`
**Interfaces:**
- Consumes: Task 1's shared API
- Produces: `store.RequireMigratedDatabase(ctx context.Context) error`
@@ -1107,10 +1120,12 @@ git commit -m "refactor(sitesvc): rename Org to Instance, refuse an unmigrated d
Breaking route changes ship here, in the same release as Task 4.
**Files:**
- Modify: `web/lib/` API client, every route under `web/app/(app)/`, `web/components/`
- Rename: `web/app/(app)/settings/org/``web/app/(app)/settings/instance/`
**Interfaces:**
- Consumes: the renamed REST API from Task 4
- Produces: no API
@@ -1182,6 +1197,7 @@ git commit -m "refactor(web): rename Organisation to Instance"
The marketing site uses "organisation" for two different things, and only one becomes "Instance". Where it means the customer, it becomes **Account** — a word that now has a specific meaning in this system, and the marketing site is where a customer meets it first.
**Files:**
- Modify: `site/app/`, `site/components/`
- [ ] **Step 1: Find every reference**
@@ -1264,14 +1280,22 @@ Pick three real tenants from `db.orgs.find({}, {org_id: 1})`. For each:
```javascript
["<tenant-1>", "<tenant-2>", "<tenant-3>"].forEach(function (t) {
print(t
+ " servers=" + db.servers.countDocuments({org_id: t})
+ " keys=" + db.keys.countDocuments({org_id: t})
+ " workflows=" + db.workflows.countDocuments({org_id: t})
+ " monitors=" + db.monitors.countDocuments({org_id: t})
+ " secrets=" + db.secrets.countDocuments({org_id: t})
+ " audit=" + db.audit_logs.countDocuments({org_id: t}))
})
print(
t +
" servers=" +
db.servers.countDocuments({ org_id: t }) +
" keys=" +
db.keys.countDocuments({ org_id: t }) +
" workflows=" +
db.workflows.countDocuments({ org_id: t }) +
" monitors=" +
db.monitors.countDocuments({ org_id: t }) +
" secrets=" +
db.secrets.countDocuments({ org_id: t }) +
" audit=" +
db.audit_logs.countDocuments({ org_id: t }),
);
});
```
Save to `/tmp/tenants-before.txt`.
@@ -1307,14 +1331,22 @@ For the same three tenants:
```javascript
["<tenant-1>", "<tenant-2>", "<tenant-3>"].forEach(function (t) {
print(t
+ " servers=" + db.servers.countDocuments({instance_id: t})
+ " keys=" + db.keys.countDocuments({instance_id: t})
+ " workflows=" + db.workflows.countDocuments({instance_id: t})
+ " monitors=" + db.monitors.countDocuments({instance_id: t})
+ " secrets=" + db.secrets.countDocuments({instance_id: t})
+ " audit=" + db.audit_logs.countDocuments({instance_id: t}))
})
print(
t +
" servers=" +
db.servers.countDocuments({ instance_id: t }) +
" keys=" +
db.keys.countDocuments({ instance_id: t }) +
" workflows=" +
db.workflows.countDocuments({ instance_id: t }) +
" monitors=" +
db.monitors.countDocuments({ instance_id: t }) +
" secrets=" +
db.secrets.countDocuments({ instance_id: t }) +
" audit=" +
db.audit_logs.countDocuments({ instance_id: t }),
);
});
```
Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name in the query. **This is the step that proves tenant isolation survived.** Anything else stops the release.
@@ -1323,9 +1355,9 @@ Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name
```javascript
db.getCollectionNames().forEach(function (c) {
var n = db.getCollection(c).countDocuments({org_id: {$exists: true}});
if (n > 0) print("STALE " + c + " " + n);
})
var n = db.getCollection(c).countDocuments({ org_id: { $exists: true } });
if (n > 0) print("STALE " + c + " " + n);
});
```
Expected: no output.
@@ -1390,8 +1422,9 @@ Expected: no output.
- [ ] **Step 13: Clean up and commit**
```javascript
["vantage_snapshot","vantage_dryrun","vantage_guardtest","vantage_unmigrated","vantage_rollback"]
.forEach(function (d) { db.getSiblingDB(d).dropDatabase() })
["vantage_snapshot", "vantage_dryrun", "vantage_guardtest", "vantage_unmigrated", "vantage_rollback"].forEach(function (d) {
db.getSiblingDB(d).dropDatabase();
});
```
```bash
@@ -1422,7 +1455,7 @@ Expect a short API outage during the server restart while the migration runs. Ag
**In-flight signups are lost.** Any unverified signup recorded before the deploy carries `org_name` and will fail verification. There are at most 24 hours' worth. Either accept it, or wait for the collection to drain:
```javascript
db.site_pending_signups.countDocuments({})
db.site_pending_signups.countDocuments({});
```
Post-deploy checks:

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