Compare commits

...
31 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
217 changed files with 9343 additions and 3059 deletions
+2
View File
@@ -147,6 +147,8 @@ jobs:
docker build \
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
-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"
)
+19 -11
View File
@@ -10,17 +10,20 @@ import (
"syscall"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/lifecycle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/lifecycle"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
)
func main() {
@@ -34,7 +37,11 @@ func main() {
licensing.SetSigningKey(cfg.SigningKey)
api.SetAppLoginURL(cfg.AppLoginURL)
mail.Init(mail.Config{
if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil {
log.Fatalf("paddle init: %v", err)
}
mail.Init(sharedmail.Sender{
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
PublicURL: cfg.PublicURL,
@@ -88,6 +95,7 @@ func main() {
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
billing.StartPlaceholderReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/admin
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26
@@ -6,7 +6,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
@@ -52,4 +52,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
+301
View File
@@ -0,0 +1,301 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// checkoutOptions serves everything the browser configurator needs to price a
// plan: the active plans (base allowances), the full catalogue (component prices
// in the running environment), and the environment name so the client can refuse
// a mismatch. The client token itself is baked into the adminsite build, never
// served from here.
func checkoutOptions(c *gin.Context) {
ctx := c.Request.Context()
plans := []models.Plan{}
if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
_ = cur.All(ctx, &plans)
}
rows, err := models.AllCatalogue(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"plans": plans,
"catalogue": rows,
"env": paddle.Get().Env(),
})
}
// createSelfHostedPlaceholder makes an instance row that exists only so a
// checkout has something to put in custom_data. It carries no licence and is
// flagged Placeholder until the customer pastes their install's real UUID. The
// generated id is temporary; linking replaces the identity.
func createSelfHostedPlaceholder(c *gin.Context) {
s := auth.Current(c)
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
ctx := c.Request.Context()
inst := models.Instance{
InstanceID: uuid.NewString(),
AccountID: s.AccountID,
Name: body.Name,
Deployment: license.DeploymentSelfHosted,
Status: models.StatusAwaitingLink,
Placeholder: true,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
Target: inst.InstanceID, IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
// createCloudCheckout creates a PAID cloud placeholder and hands back its id so
// the browser can open a Paddle checkout keyed to it. Nothing is provisioned yet:
// a cloud instance costs real infrastructure, so it is created only once payment
// is confirmed, by the subscription webhook (billing.handleSubscription).
//
// This mirrors the self-hosted placeholder, with one difference that matters:
// admin owns the cloud UUID, so the id generated here is the id the instance
// will keep. Provisioning on the webhook reuses it (provision.CreateInstanceWithID),
// which is why there is no claim-and-rewrite step and the subscription's
// custom_data never goes stale. PendingOwnerUserID remembers who bought it so the
// webhook can make them the instance owner.
//
// An abandoned checkout therefore leaves only this row — no infrastructure — the
// same cheap, reap-safe state a self-hosted placeholder leaves.
func createCloudCheckout(c *gin.Context) {
s := auth.Current(c)
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
ctx := c.Request.Context()
inst := models.Instance{
InstanceID: uuid.NewString(),
AccountID: s.AccountID,
Name: strings.TrimSpace(body.Name),
Deployment: license.DeploymentCloud,
Status: models.StatusAwaitingLink,
Placeholder: true,
PendingOwnerUserID: s.UserID,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.cloud_placeholder_created", AccountID: s.AccountID,
Target: inst.InstanceID, IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
// updateEntitlement sets an instance's DESIRED configuration and pushes the
// resulting line items to Paddle. It does NOT issue — the resulting
// subscription.updated webhook does, from granted. An increase is prorated
// immediately by Paddle; a reduction is recorded as desired and takes effect at
// renewal, so this never shrinks a live licence.
func updateEntitlement(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
var body struct {
Tier string `json:"tier"`
Term string `json:"term"`
Servers int `json:"servers"`
Features []string `json:"features"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
return
}
plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
return
}
if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
return
}
desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// A live subscription is required to update. None means this instance has
// never been paid for — that is a checkout, not an update.
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
return
}
pItems := make([]paddle.LineItem, 0, len(items))
for _, it := range items {
pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
return
}
// Record desired now; the webhook Paddle sends back promotes to granted and
// reissues. Recording here makes the portal reflect the intent instantly
// rather than waiting on the round-trip.
limits, _, _ := catalogue.Resolve(ctx, plan, desired)
next := models.Entitlement{
InstanceID: inst.InstanceID, AccountID: inst.AccountID,
Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
Desired: desired, ResolvedLimits: limits,
}
ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
if ent != nil {
next.Granted = ent.Granted
next.GrantedAt = ent.GrantedAt
if desired.Servers < ent.Granted.Servers {
now := time.Now().UTC()
next.ScheduledChangeAt = &now
}
} else {
next.Granted = desired
}
if err := models.UpsertEntitlement(ctx, next); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "entitlement.requested",
AccountID: inst.AccountID, Target: inst.InstanceID})
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
}
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
// real install UUID, then issues.
//
// :id is the placeholder (generated at checkout, carried in the subscription's
// custom_data); the body carries the UUID the install actually reports. The
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
// placeholder row's identity is rewritten to it and the subscription re-pointed,
// then billing issues from the recorded subscription. Linking and claiming stay
// one call here because, unlike Free, the payment already happened.
func claimPlaceholderLink(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if !inst.Placeholder {
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
return
}
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
ctx := c.Request.Context()
// The real UUID must be free across every account — the unique index on
// instance_id is the tenant-isolation property, so refuse rather than collide.
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
bson.M{"instance_id": body.InstanceID}); n > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
return
}
placeholderID := inst.InstanceID
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{
"instance_id": body.InstanceID,
"status": models.StatusActive,
"placeholder": false,
}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-point the subscription from the placeholder id to the real UUID so
// billing.IssueForInstance (and every later webhook) finds it.
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
// The link stuck; issuance did not. The reconciler and a retry recover it,
// and the customer is not blocked from linking. Surface it, do not roll back.
c.JSON(http.StatusAccepted, gin.H{
"instance_id": body.InstanceID,
"warning": "linked, but licence issuance is pending: " + err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
AccountID: inst.AccountID, Target: body.InstanceID,
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
}
// billingPortal mints a Paddle customer-portal URL. The account must already
// have a paddle_customer_id, which it learns from its first subscription webhook.
func billingPortal(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
var acc models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
return
}
if acc.PaddleCustomerID == "" {
c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
return
}
url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
+107 -80
View File
@@ -9,19 +9,19 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -200,6 +200,96 @@ func listSubscriptions(c *gin.Context) {
c.JSON(http.StatusOK, subs)
}
// provisionCloudInstance provisions a real cloud instance in the control plane
// and records admin's row for it, WITHOUT issuing a licence. Both the Free
// create path and the paid-checkout path share it, so the provisioning — and its
// unwind-in-reverse rollback — has one definition rather than two that drift.
//
// It leaves the instance unlicensed on purpose: createInstance then issues Free,
// and createCloudCheckout leaves it for the paid subscription webhook to license.
// The returned rec carries no Tier or CurrentLicense; the caller sets those once
// it has issued.
//
// Errors are returned unwrapped for the provisioning step so the caller can still
// match provision.ErrEmailTaken / ErrNameRejected; later steps are wrapped.
func provisionCloudInstance(c *gin.Context, name string) (*models.Instance, error) {
ctx := c.Request.Context()
s := auth.Current(c)
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
return nil, fmt.Errorf("read account: %w", err)
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
return nil, err
}
rec := models.Instance{
InstanceID: inst.InstanceID,
AccountID: s.AccountID,
Name: inst.Name,
Slug: inst.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
// Unwind in reverse: the owner first, because RollbackInstance refuses
// an instance that still has users.
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
}
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
log.Printf("provisionCloudInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
return nil, fmt.Errorf("record instance: %w", err)
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("provisionCloudInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("provisionCloudInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
return &rec, nil
}
// cloudProvisionError maps the errors provisionCloudInstance can surface onto the
// customer-facing responses shared by the Free and paid-checkout paths.
func cloudProvisionError(c *gin.Context, err error) {
switch {
case errors.Is(err, provision.ErrEmailTaken):
// users.email is unique per instance, so this means the address already
// owns a user in an instance we are not creating — a legacy cloud tenant.
// Staff have to attach that one by hand.
c.JSON(http.StatusConflict, gin.H{
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
}
}
// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
@@ -241,75 +331,12 @@ func createInstance(c *gin.Context) {
return
}
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
return
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
rec, err := provisionCloudInstance(c, name)
if err != nil {
if errors.Is(err, provision.ErrEmailTaken) {
// users.email is unique per instance, so this means the address
// already owns a user in an instance we are not creating — a legacy
// cloud tenant. Staff have to attach that one by hand.
c.JSON(http.StatusConflict, gin.H{
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
return
}
if errors.Is(err, provision.ErrNameRejected) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
cloudProvisionError(c, err)
return
}
rec := models.Instance{
InstanceID: inst.InstanceID,
AccountID: s.AccountID,
Name: inst.Name,
Slug: inst.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
// Unwind in reverse: the owner first, because RollbackInstance refuses
// an instance that still has users.
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
}
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
return
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
inst := rec
// Past this point nothing fails the request.
lic, err := licensing.Issue(ctx, licensing.IssueInput{
@@ -327,7 +354,7 @@ func createInstance(c *gin.Context) {
inject.Deliver(ctx, lic)
if mail.Enabled() {
if err := mail.SendInstanceReady(s.Email, inst.Name,
if err := mail.Default.SendInstanceReady(s.Email, inst.Name,
loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
}
@@ -404,7 +431,7 @@ func renewInstance(c *gin.Context) {
Target: inst.InstanceID, IP: c.ClientIP()})
if mail.Enabled() {
if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
}
}
@@ -523,6 +550,6 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
+5 -5
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+8 -8
View File
@@ -6,16 +6,16 @@ import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"github.com/gin-gonic/gin"
)
// paddleWebhook is the ingress for every Paddle event.
//
// Order is load-bearing: read the RAW body first (the signature is over the
// exact bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature
// is 401 and processes nothing; a duplicate of a handled event is 200 and does
// nothing; a handler error is 500 so Paddle retries, and is recorded for staff.
func paddleWebhook(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
return
}
if !paddle.VerifySignature(cfg.PaddleWebhookSecret,
c.GetHeader("Paddle-Signature"), body) {
log.Printf("paddle webhook: bad signature from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
return
}
var ev billing.Event
if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"})
return
}
ctx := c.Request.Context()
claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
return
}
if !claimed {
// Already handled (or in flight). 200 so Paddle stops retrying.
c.JSON(http.StatusOK, gin.H{"duplicate": true})
return
}
if err := billing.Dispatch(ctx, ev); err != nil {
log.Printf("paddle webhook: handler %s failed for %s: %v",
ev.EventType, ev.EventID, err)
_ = models.MarkEventProcessed(ctx, ev.EventID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"})
return
}
_ = models.MarkEventProcessed(ctx, ev.EventID, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
+6 -6
View File
@@ -7,13 +7,13 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+23 -3
View File
@@ -10,10 +10,10 @@ import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
)
func Routes(cfg config.Config) http.Handler {
@@ -39,6 +39,10 @@ func Routes(cfg config.Config) http.Handler {
r.POST("/auth/signup", auth.HandleSignup)
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
// Public: Paddle carries no session cookie; its signature is its auth. Must
// NOT sit under the cust group's session middleware.
r.POST("/api/paddle/webhook", paddleWebhook(cfg))
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{
@@ -72,6 +76,21 @@ func Routes(cfg config.Config) http.Handler {
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimFree)
cust.GET("/instances/:id/entitlement", getEntitlement)
cust.GET("/checkout/options", checkoutOptions)
cust.POST("/instances/self-hosted",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createSelfHostedPlaceholder)
// Paid cloud: provisions a real instance the paid webhook then licenses.
cust.POST("/instances/cloud",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createCloudCheckout)
cust.PUT("/instances/:id/entitlement",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateEntitlement)
cust.POST("/billing/portal", billingPortal)
cust.POST("/instances/:id/claim-link",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimPlaceholderLink)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
@@ -113,6 +132,7 @@ func Routes(cfg config.Config) http.Handler {
staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
staff.GET("/health/billing", staffBillingHealth)
}
return r
+30 -7
View File
@@ -6,15 +6,15 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -380,6 +380,29 @@ func staffListLicenses(c *gin.Context) {
c.JSON(http.StatusOK, lics)
}
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
// placeholders, so a customer who paid and got nothing is visible rather than
// stuck in a support queue.
func staffBillingHealth(c *gin.Context) {
ctx := c.Request.Context()
failed := []models.PaddleEvent{}
if cur, err := db.Admin("paddle_events").Find(ctx,
bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
_ = cur.All(ctx, &failed)
}
unlinked := []models.Instance{}
if cur, err := db.Admin("admin_instances").Find(ctx,
bson.M{"placeholder": true, "status": models.StatusAwaitingLink}); err == nil {
_ = cur.All(ctx, &unlinked)
}
c.JSON(http.StatusOK, gin.H{
"failed_events": failed,
"failed_count": len(failed),
"unlinked_paid": unlinked,
"unlinked_count": len(unlinked),
})
}
func staffListPlans(c *gin.Context) {
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
if err != nil {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
)
// Write never returns an error: an audit failure must not roll back the action
+12 -7
View File
@@ -10,12 +10,13 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -26,7 +27,11 @@ const BcryptCost = 12
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
//
// It is shared/mail's constant rather than a second copy because the
// verification email states the number of hours: a window that disagreed with
// what the email promised would expire links early with no explanation.
const VerifyWindow = sharedmail.VerifyWindow
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
@@ -58,7 +63,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password, account
return err
}
if err := mail.SendVerification(u.Email, token); err != nil {
if err := mail.Default.SendVerification(u.Email, token); err != nil {
// Undo the insert. A row whose verification link was never delivered is
// worse than no row: it can never be signed in to, and it holds the
// unique index on email, so the customer cannot sign up again with the
@@ -109,7 +114,7 @@ func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accou
return err
}
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
if err := mail.Default.SendInvite(u.Email, accountName, token); err != nil {
// Same rollback rule, and the same detached context, as signup: a row
// whose link was never delivered can never be signed in to and holds
// the unique index on email against the person it was meant for.
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+196
View File
@@ -0,0 +1,196 @@
package billing
import (
"context"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// placeholderReconcileInterval is how often placeholders are swept: paid cloud
// ones a failed webhook left unprovisioned are completed, and stale unpaid ones
// of either deployment are reaped.
const placeholderReconcileInterval = 5 * time.Minute
// abandonedPlaceholderAfter is how long an unpaid placeholder may sit before it
// is treated as an abandoned checkout and deleted. Comfortably longer than a
// webhook's delivery lag, so a just-paid placeholder awaiting its subscription
// event is never mistaken for an abandoned one.
const abandonedPlaceholderAfter = 24 * time.Hour
// StartPlaceholderReconciler owns the after-checkout lifecycle of placeholders.
//
// It recovers the one failure the webhook cannot on its own — a confirmed payment
// whose provisioning handler errored, which is not retried once its event is
// claimed and which the inject reconciler (licences only) does not repair — by
// completing paid cloud placeholders here. And it reaps abandoned ones: a
// placeholder with no subscription past abandonedPlaceholderAfter is a checkout
// nobody finished, and deleting it loses nothing, because a placeholder has no
// control-plane footprint until it is paid for and provisioned.
func StartPlaceholderReconciler(ctx context.Context) {
go func() {
t := time.NewTicker(placeholderReconcileInterval)
defer t.Stop()
reconcilePlaceholders(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
reconcilePlaceholders(ctx)
}
}
}()
}
func reconcilePlaceholders(ctx context.Context) {
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"placeholder": true})
if err != nil {
log.Printf("placeholder reconcile: query: %v", err)
return
}
var placeholders []models.Instance
if err := cur.All(ctx, &placeholders); err != nil {
log.Printf("placeholder reconcile: decode: %v", err)
return
}
now := time.Now().UTC()
for _, inst := range placeholders {
var sub models.Subscription
paid := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub) == nil
if !paid {
// Never paid for. Reap once it is old enough to be an abandoned
// checkout rather than one still awaiting its subscription webhook.
if now.Sub(inst.CreatedAt) > abandonedPlaceholderAfter {
if _, err := db.Admin("admin_instances").DeleteOne(ctx,
bson.M{"instance_id": inst.InstanceID, "placeholder": true}); err != nil {
log.Printf("placeholder reconcile: reap abandoned %s: %v", inst.InstanceID, err)
} else {
log.Printf("placeholder reconcile: reaped abandoned placeholder %s", inst.InstanceID)
}
}
continue
}
// Paid, self-hosted: nothing to provision — the customer installs and
// links, and lifecycle chases them. Only cloud is completed here.
if inst.Deployment != license.DeploymentCloud {
continue
}
items := make([]catalogue.Item, 0, len(sub.Items))
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
log.Printf("placeholder reconcile: resolve items for %s: %v", inst.InstanceID, err)
continue
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
log.Printf("placeholder reconcile: complete %s: %v", inst.InstanceID, err)
continue
}
if err := promoteAndIssue(ctx, provisioned, match, models.ReasonNew); err != nil {
log.Printf("placeholder reconcile: issue %s: %v", inst.InstanceID, err)
continue
}
log.Printf("placeholder reconcile: completed paid cloud instance %s", inst.InstanceID)
}
}
// completeCloudPlaceholder provisions the cloud instance a paid placeholder stands
// for, once payment is confirmed, and returns the row promoted to a real instance.
//
// It is the payment-first half of the paid-cloud flow: createCloudCheckout made
// the placeholder before payment, this provisions it after. The control-plane
// instance is created with the placeholder's OWN id (cloudprov.CreateInstanceWithID),
// so nothing is rewritten and the subscription's custom_data still resolves this
// row on every later webhook.
//
// Every step is idempotent, because a webhook can be retried after this partly
// ran: provisioning converges rather than duplicates, and the row flip and
// membership insert are guarded on what they write. The caller then issues.
func completeCloudPlaceholder(ctx context.Context, inst *models.Instance) (*models.Instance, error) {
cu, err := placeholderOwner(ctx, inst)
if err != nil {
return nil, err
}
prov, err := cloudprov.CreateInstanceWithID(ctx, inst.InstanceID, inst.Name,
cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
return nil, fmt.Errorf("provision cloud instance %s: %w", inst.InstanceID, err)
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{
"$set": bson.M{"slug": prov.Slug, "status": models.StatusActive, "placeholder": false},
"$unset": bson.M{"pending_owner_user_id": ""},
}); err != nil {
return nil, fmt.Errorf("promote placeholder %s: %w", inst.InstanceID, err)
}
// Record the owner's membership. Best-effort and guarded on absence: the
// projected user is what grants access, so a missing row costs a line in the
// members panel, not access — and the boot backfill rebuilds it.
if ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID); err == nil {
if n, _ := db.Admin("instance_members").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID, "customer_user_id": cu.UserID}); n == 0 {
if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("completeCloudPlaceholder: record owner membership for %s: %v",
inst.InstanceID, err)
}
}
}
next := *inst
next.Slug = prov.Slug
next.Status = models.StatusActive
next.Placeholder = false
next.PendingOwnerUserID = ""
return &next, nil
}
// placeholderOwner resolves the customer_user who should own a provisioned cloud
// placeholder: the buyer recorded at checkout, or the account owner if that
// pointer is somehow missing.
func placeholderOwner(ctx context.Context, inst *models.Instance) (*models.CustomerUser, error) {
var cu models.CustomerUser
if inst.PendingOwnerUserID != "" {
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": inst.PendingOwnerUserID}).Decode(&cu); err == nil {
return &cu, nil
}
}
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"account_id": inst.AccountID, "account_role": models.AccountRoleOwner}).Decode(&cu); err != nil {
return nil, fmt.Errorf("no owner for account %s to provision %s: %w",
inst.AccountID, inst.InstanceID, err)
}
return &cu, nil
}
+24
View File
@@ -0,0 +1,24 @@
package billing
import (
"context"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
// self-hosted is emailed the blob (their database is theirs). This mirrors the
// api-side deliver helper but takes no gin context — webhooks have none, and the
// customer is not on the other end of the request.
func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) {
if inst.Deployment == license.DeploymentCloud {
inject.Deliver(ctx, lic)
return
}
if to != "" && mail.Enabled() {
_ = mail.Default.SendLicense(to, inst.Name, lic.Blob)
}
}
+51
View File
@@ -0,0 +1,51 @@
// Package billing turns verified Paddle webhooks into licence actions. It never
// verifies signatures (that is paddle.VerifySignature at the edge) and never
// signs (that is licensing.Issue); it decides what a subscription's current
// state means and calls the issuer.
package billing
import (
"context"
"encoding/json"
"fmt"
"time"
)
// Event is the decoded Paddle webhook envelope. Data is left raw so each handler
// decodes only the shape it needs.
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
Data json.RawMessage `json:"data"`
}
// Dispatch routes one event to its handler. Unknown event types are a no-op
// success: Paddle sends many we do not care about, and 200 stops it retrying.
func Dispatch(ctx context.Context, ev Event) error {
switch ev.EventType {
case "subscription.created", "subscription.updated", "subscription.activated":
return handleSubscription(ctx, ev)
case "subscription.canceled":
return handleCanceled(ctx, ev)
case "subscription.past_due":
return handlePastDue(ctx, ev)
case "transaction.completed":
return handleTransactionCompleted(ctx, ev)
case "transaction.payment_failed":
return handlePaymentFailed(ctx, ev)
case "customer.updated":
return handleCustomerUpdated(ctx, ev)
default:
return nil
}
}
// decode is a small helper so every handler decodes Data the same way.
func decode[T any](ev Event) (T, error) {
var v T
if err := json.Unmarshal(ev.Data, &v); err != nil {
return v, fmt.Errorf("decode %s: %w", ev.EventType, err)
}
return v, nil
}
+289
View File
@@ -0,0 +1,289 @@
package billing
import (
"context"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// subscriptionData is the slice of Paddle's subscription payload we read. Fields
// we ignore are simply absent — encoding/json drops them.
type subscriptionData struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
CustomData struct {
AccountID string `json:"account_id"`
InstanceID string `json:"instance_id"`
} `json:"custom_data"`
CurrentBillingPeriod struct {
EndsAt time.Time `json:"ends_at"`
} `json:"current_billing_period"`
Items []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
Quantity int `json:"quantity"`
} `json:"items"`
}
func (d subscriptionData) lineItems() []catalogue.Item {
items := make([]catalogue.Item, 0, len(d.Items))
for _, it := range d.Items {
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
}
return items
}
// handleSubscription folds created/updated/activated into one job: make the
// world match the subscription's CURRENT state. That is what keeps out-of-order
// delivery correct — an updated arriving before its created still carries the
// full item list, so reading all of it is reading current state, not a
// transition.
func handleSubscription(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if d.CustomData.InstanceID == "" {
return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
if err != nil {
// A price we cannot map is a configuration error, not a customer error.
// Fail loudly so it is retried and surfaced rather than guessed.
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
}
sub := models.Subscription{
AccountID: d.CustomData.AccountID,
InstanceID: d.CustomData.InstanceID,
PaddleSubscriptionID: d.ID,
Tier: match.Tier,
Term: match.Term,
Status: d.Status,
CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt,
Items: toSubItems(d.lineItems()),
}
if err := upsertSubscription(ctx, sub); err != nil {
return err
}
// Learn the Paddle customer ID onto the account the first time we see it.
if d.CustomerID != "" && d.CustomData.AccountID != "" {
_, _ = db.Admin("accounts").UpdateOne(ctx,
bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
// Placeholders are the payment-first path: the instance does not exist until
// this confirmed-payment event. A cloud placeholder is provisioned here and
// then issued (first term). A self-hosted placeholder has no UUID to bind to
// until the customer pastes their install's — its subscription is recorded and
// the link endpoint issues later.
reason := models.ReasonEntitlementChange
if inst.Placeholder {
if inst.Deployment != license.DeploymentCloud {
return nil
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
return err
}
inst = *provisioned
reason = models.ReasonNew
}
return promoteAndIssue(ctx, &inst, match, reason)
}
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the only promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
if err != nil {
return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
}
granted := models.Config{
Servers: match.Servers,
Features: models.Features(match.Features).OrEmpty(),
}
limits, _, err := catalogue.Resolve(ctx, plan, granted)
if err != nil {
return err
}
if err := models.UpsertEntitlement(ctx, models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: inst.Deployment,
Tier: match.Tier,
Term: match.Term,
Desired: granted,
Granted: granted,
ResolvedLimits: limits,
}); err != nil {
return err
}
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: match.Tier,
Term: match.Term,
Reason: reason,
IssuedBy: "paddle",
})
if err != nil {
return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
}
deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
return nil
}
// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
//
// The instance stays active until its licence expires, when the existing
// lifecycle sweep lapses it. Flipping the instance to cancelled here would stop
// inject.Reconcile and the sweep repairing a licence that is still valid — the
// opposite of "keeps working until it expires".
func handleCanceled(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.ID},
bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.Default.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
// handlePastDue flags the subscription and notifies, but leaves the licence
// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
func handlePastDue(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.ID},
bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.Default.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
// handleCustomerUpdated syncs the billing email onto the account.
func handleCustomerUpdated(ctx context.Context, ev Event) error {
d, err := decode[struct {
ID string `json:"id"`
Email string `json:"email"`
}](ev)
if err != nil {
return err
}
if d.ID == "" || d.Email == "" {
return nil
}
_, err = db.Admin("accounts").UpdateOne(ctx,
bson.M{"paddle_customer_id": d.ID},
bson.M{"$set": bson.M{"billing_email": d.Email}})
return err
}
// IssueForInstance issues from an instance's recorded subscription. Called when
// a self-hosted customer finally links a placeholder they have already paid for.
func IssueForInstance(ctx context.Context, instanceID string) error {
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
return err
}
items := make([]catalogue.Item, 0, len(sub.Items))
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
return err
}
return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
}
func toSubItems(items []catalogue.Item) []models.SubItem {
out := make([]models.SubItem, 0, len(items))
for _, it := range items {
out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return out
}
func upsertSubscription(ctx context.Context, sub models.Subscription) error {
_, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
bson.M{"$set": bson.M{
"account_id": sub.AccountID,
"instance_id": sub.InstanceID,
"tier": sub.Tier,
"term": sub.Term,
"status": sub.Status,
"current_period_end": sub.CurrentPeriodEnd,
"items": sub.Items,
}, "$setOnInsert": bson.M{
"subscription_id": uuid.NewString(),
"paddle_subscription_id": sub.PaddleSubscriptionID,
}},
options.UpdateOne().SetUpsert(true))
return err
}
// billingEmailFor reads the account's billing email for self-hosted delivery.
func billingEmailFor(ctx context.Context, accountID string) string {
var acc models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": accountID}).Decode(&acc); err != nil {
return ""
}
return acc.BillingEmail
}
// instanceNameFor is a best-effort display name for an email subject.
func instanceNameFor(ctx context.Context, instanceID string) string {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
return "your instance"
}
return inst.Name
}
+124
View File
@@ -0,0 +1,124 @@
package billing
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
)
type transactionData struct {
ID string `json:"id"`
SubscriptionID string `json:"subscription_id"`
Origin string `json:"origin"`
Items []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
Quantity int `json:"quantity"`
} `json:"items"`
}
// handleTransactionCompleted issues the next term's licence on a renewal.
//
// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's
// desired (smaller) configuration becomes granted. Mid-term reductions never
// shrink a live licence. On a first charge (origin not recurring) the
// subscription.created/updated handler already issued, so this is a no-op to
// avoid a double issue.
func handleTransactionCompleted(ctx context.Context, ev Event) error {
d, err := decode[transactionData](ev)
if err != nil {
return err
}
if d.Origin != "subscription_recurring" {
return nil
}
if d.SubscriptionID == "" {
return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID)
}
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil {
return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err)
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err)
}
// Prefer the transaction's own item list (authoritative for this period);
// fall back to the subscription's recorded items.
items := make([]catalogue.Item, 0, len(d.Items))
for _, it := range d.Items {
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
}
if len(items) == 0 {
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err)
}
// Collapse a scheduled reduction: desired becomes granted, and the pending
// marker is cleared, since a new term has begun. This is the only place a
// licence ever gets a smaller cap.
if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil {
return err
}
// Issue the next term. Renewal resets relink_count inside licensing.Issue.
if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil {
return err
}
// Clear lifecycle notices so the next term starts the sequence fresh (mirrors
// the self-serve renew path).
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"notices_sent": ""}})
return nil
}
// promoteScheduledReduction collapses a pending reduction into granted at
// renewal and clears scheduled_change_at. A no-op when nothing is pending — the
// match resolved from the renewal's items is authoritative either way, so this
// only keeps the entitlement's own bookkeeping honest.
func promoteScheduledReduction(ctx context.Context, instanceID string) error {
ent, err := models.GetEntitlement(ctx, instanceID)
if err != nil {
return nil // no entitlement to reconcile
}
if ent.ScheduledChangeAt == nil {
return nil
}
ent.Granted = ent.Desired
ent.ScheduledChangeAt = nil
return models.UpsertEntitlement(ctx, *ent)
}
// handlePaymentFailed records the failure for staff visibility. No licence
// action — the licence runs to its (grace-padded) expiry and Paddle retries.
func handlePaymentFailed(ctx context.Context, ev Event) error {
d, err := decode[transactionData](ev)
if err != nil {
return err
}
if d.SubscriptionID == "" {
return nil
}
_, err = db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.SubscriptionID},
bson.M{"$set": bson.M{"status": models.SubPastDue}})
return err
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"errors"
"fmt"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
var (
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// Item is one Paddle line item: a price and how many of it.
+23 -4
View File
@@ -15,9 +15,10 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/db"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -32,11 +33,29 @@ import (
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
return CreateInstanceWithID(ctx, uuid.NewString(), name, ownerEmail, ownerPasswordHash, hqUserID)
}
// CreateInstanceWithID provisions a cloud instance under a caller-supplied ID and
// its owner. It backs the paid-cloud flow, where the ID is a placeholder created
// before payment and provisioning runs on the confirmed-payment webhook (see
// provision.CreateInstanceWithID).
//
// It is idempotent, because a webhook can be retried after provisioning partly
// completed: the instance is created only if absent, and the owner only if the
// instance has none yet. A second call therefore converges to the same state
// rather than colliding on the per-instance email unique index.
func CreateInstanceWithID(ctx context.Context, instanceID, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
inst, err := provision.CreateInstanceWithID(ctx, db.ControlDB(), instanceID, name)
if err != nil {
return nil, err
}
// A retry that already created the owner must not create a second one.
if _, err := OwnerUserID(ctx, inst.InstanceID); err == nil {
return inst, nil
}
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
if err != nil {
+12
View File
@@ -29,6 +29,10 @@ type Config struct {
Addr string
ReapAfter time.Duration
PaddleEnv string // "sandbox" or "production"
PaddleAPIKey string
PaddleWebhookSecret string
SMTPHost string
SMTPPort string
SMTPFrom string
@@ -75,6 +79,10 @@ func Load() (Config, error) {
SMTPFrom: os.Getenv("SMTP_FROM"),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
PaddleEnv: envOr("PADDLE_ENV", "sandbox"),
PaddleAPIKey: os.Getenv("PADDLE_API_KEY"),
PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"),
}
var missing []string
@@ -85,6 +93,10 @@ func Load() (Config, error) {
"LICENSE_SIGNING_KEY": c.SigningKey,
"PUBLIC_URL": c.PublicURL,
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
// An unverified webhook endpoint is one anyone can issue licences
// through, so the secret and API key are boot-required.
"PADDLE_API_KEY": c.PaddleAPIKey,
"PADDLE_WEBHOOK_SECRET": c.PaddleWebhookSecret,
} {
if v == "" {
missing = append(missing, name)
+2 -1
View File
@@ -14,7 +14,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -85,6 +85,7 @@ func EnsureIndexes(ctx context.Context) error {
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"paddle_events", "event_id"},
{"staff_users", "email"},
{"customer_users", "email"},
}
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -12,10 +12,10 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+5 -5
View File
@@ -8,12 +8,12 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -6,11 +6,11 @@ import (
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+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() }
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package models
import (
"context"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"errors"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+48 -4
View File
@@ -9,7 +9,7 @@ import (
"encoding/json"
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -77,6 +77,21 @@ const (
ReasonEntitlementChange = "entitlement_change"
)
// Subscription statuses, mirrored from Paddle. Ours, not a vendor SDK's, so the
// billing package does not import anything Paddle.
const (
SubActive = "active"
SubCanceled = "canceled"
SubPastDue = "past_due"
SubTrialing = "trialing"
)
// Billing terms. These match catalogue price-ID keys and license.TermsFor.
const (
TermMonthly = "monthly"
TermAnnual = "annual"
)
// MaxRelinksPerTerm is the customer-facing relink cap.
//
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
@@ -127,8 +142,16 @@ type Instance struct {
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
// clears it, so the next term starts the sequence again. It is what stops a
// restart re-sending a notice.
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
// Placeholder is true while a self-hosted instance row exists only so a
// checkout has something to attach custom_data to, before the customer has
// pasted their install's real UUID. Cleared when the instance is linked.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
// remembered so the confirmed-payment webhook can provision the instance with
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
PendingOwnerUserID string `bson:"pending_owner_user_id,omitempty" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// License is append-only. A renewal writes a new row and sets SupersededBy on
@@ -158,11 +181,32 @@ type Subscription struct {
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Status string `bson:"status" json:"status"`
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
// Items is the full line-item list. Spec 7 made a subscription several
// prices — a base, a per-server unit at quantity N, an item per paid
// feature — so a single price ID can no longer describe it.
Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
}
// SubItem is one line of a subscription: a price and its quantity, the shape
// catalogue.ResolveItems reads back into a plan and configuration.
type SubItem struct {
PriceID string `bson:"price_id" json:"price_id"`
Quantity int `bson:"quantity" json:"quantity"`
}
// PaddleEvent is the idempotency record for one webhook delivery. The unique
// index on EventID is what makes a retry a no-op rather than a second licence.
type PaddleEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
EventID string `bson:"event_id" json:"event_id"`
EventType string `bson:"event_type" json:"event_type"`
ReceivedAt time.Time `bson:"received_at" json:"received_at"`
ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
}
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
+48
View File
@@ -0,0 +1,48 @@
package models
import (
"context"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ClaimEvent records an event ID before it is processed and reports whether THIS
// call is the one that claimed it.
//
// The unique index on event_id turns a duplicate insert into a duplicate-key
// error, which is the signal that another delivery of the same event already
// owns it — so this returns (false, nil) and the caller answers 200 without
// acting. A genuine error returns (false, err).
func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
_, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
EventID: eventID,
EventType: eventType,
ReceivedAt: time.Now().UTC(),
})
if err == nil {
return true, nil
}
if mongo.IsDuplicateKeyError(err) {
return false, nil
}
return false, err
}
// MarkEventProcessed stamps success, or records the error for staff visibility.
// A failed event keeps no processed_at, so a retry re-runs it.
func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
set := bson.M{}
if procErr != nil {
set["error"] = procErr.Error()
} else {
now := time.Now().UTC()
set["processed_at"] = now
set["error"] = ""
}
_, err := db.Admin("paddle_events").UpdateOne(ctx,
bson.M{"event_id": eventID}, bson.M{"$set": set})
return err
}
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+54
View File
@@ -0,0 +1,54 @@
// Package paddle is the only place that talks to Paddle. Everything outside it
// depends on the Client interface and our own types, never on Paddle's wire
// shapes — so a change at Paddle is confined to http.go, and the billing package
// can be reasoned about without knowing Paddle exists.
//
// It is a thin REST client rather than the vendor SDK on purpose: the surface we
// need is two calls, and a hand-rolled client has no version-drift risk and no
// dependency to keep in go.sum.
package paddle
import "context"
// LineItem is one price at a quantity, the shape both a checkout and a
// subscription update are built from.
type LineItem struct {
PriceID string
Quantity int
}
// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
// the browser via paddle-js; the server only updates an existing subscription
// and mints a portal session.
type Client interface {
// UpdateSubscriptionItems replaces a subscription's items, prorated
// immediately by Paddle. This is the one outbound mutation, used when a
// customer changes their server count or features on an existing plan.
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
// PortalSession returns a customer-portal URL for managing billing.
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
// Env is "sandbox" or "production", the same value catalogue price lookups
// are keyed on.
Env() string
}
var current Client
// Init constructs the client from config and stores it. Called once at boot.
func Init(apiKey, env string) (Client, error) {
c, err := newHTTPClient(apiKey, env)
if err != nil {
return nil, err
}
current = c
return c, nil
}
// Get returns the client initialised at boot. Panics if unset, which can only
// happen if a caller runs before Init — a programming error, not a runtime one.
func Get() Client {
if current == nil {
panic("paddle.Get before paddle.Init")
}
return current
}
+120
View File
@@ -0,0 +1,120 @@
package paddle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// httpClient is the only implementation of Client. It is the single place that
// knows Paddle's base URLs, auth header and request shapes — swap the whole
// vendor here without the rest of the tree noticing.
type httpClient struct {
apiKey string
env string
base string
http *http.Client
}
func newHTTPClient(apiKey, env string) (Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("paddle: empty API key")
}
base := "https://sandbox-api.paddle.com"
if env == "production" {
base = "https://api.paddle.com"
}
return &httpClient{
apiKey: apiKey,
env: env,
base: base,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (c *httpClient) Env() string { return c.env }
// do sends a JSON request and decodes the `data` envelope Paddle wraps every
// response in. A non-2xx is returned as an error carrying the body, so a
// configuration or auth failure is loud rather than silent.
func (c *httpClient) do(ctx context.Context, method, path string, body any, out any) error {
var buf io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("paddle: marshal %s %s: %w", method, path, err)
}
buf = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, buf)
if err != nil {
return fmt.Errorf("paddle: build %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("paddle: %s %s: %w", method, path, err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("paddle: %s %s returned %d: %s", method, path, res.StatusCode, string(raw))
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("paddle: decode %s %s: %w", method, path, err)
}
return nil
}
type updateSubscriptionRequest struct {
Items []reqItem `json:"items"`
ProrationBillingMode string `json:"proration_billing_mode"`
}
type reqItem struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
reqItems := make([]reqItem, 0, len(items))
for _, it := range items {
reqItems = append(reqItems, reqItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, updateSubscriptionRequest{
Items: reqItems,
ProrationBillingMode: "prorated_immediately",
}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
}
var out struct {
Data struct {
URLs struct {
General struct {
Overview string `json:"overview"`
} `json:"general"`
} `json:"urls"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodPost,
"/customers/"+customerID+"/portal-sessions", struct{}{}, &out); err != nil {
return "", err
}
return out.Data.URLs.General.Overview, nil
}
+42
View File
@@ -0,0 +1,42 @@
package paddle
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifySignature checks a raw webhook body against the Paddle-Signature header.
//
// Paddle signs an HMAC-SHA256 over "ts:body", carried as "ts=<unix>;h1=<hex>".
// It uses a constant-time compare and never logs the secret. A false return is
// always a 401 with nothing processed — an unverified body could be anyone
// claiming a subscription was paid for.
func VerifySignature(secret, header string, body []byte) bool {
if secret == "" || header == "" {
return false
}
var ts, h1 string
for _, part := range strings.Split(header, ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch k {
case "ts":
ts = v
case "h1":
h1 = v
}
}
if ts == "" || h1 == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte(":"))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(h1))
}
+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
+7 -3
View File
@@ -5,6 +5,7 @@ import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { formatDate } from "@/lib/format";
export default function BillingPage() {
@@ -51,9 +52,12 @@ export default function BillingPage() {
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">
To change a card, download an invoice or cancel, email support and
we will send you a billing link. Self-service billing arrives with
card payments.
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"
@@ -7,9 +7,17 @@ import { Field } from "@/components/Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) {
export function LinkForm({
onLinked,
claimId,
}: {
onLinked: (instanceId: string) => void;
// The PAID placeholder awaiting its real install UUID: claim it in place. The
// name was chosen at checkout, so it is not asked for again. Self-hosted Free
// is created on the purchase page instead, not here.
claimId: string;
}) {
const [id, setId] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
@@ -28,7 +36,7 @@ export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void
setBusy(true);
setError(undefined);
try {
const inst = await api.link(value, name.trim());
const inst = await api.claimLink(claimId, value);
onLinked(inst.instance_id);
} catch (err) {
setError(
@@ -58,12 +66,6 @@ export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void
</>
}
/>
<Field
label="Name it (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
hint="So you can tell it apart from your other installs."
/>
<Button type="submit" disabled={busy} className="justify-self-start">
{busy ? "Linking…" : "Link and issue licence"}
</Button>
@@ -1,6 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { LinkForm } from "./LinkForm";
import { PageHeader } from "@/components/PageHeader";
@@ -8,6 +9,16 @@ import { PageHeader } from "@/components/PageHeader";
export default function LinkPage() {
const router = useRouter();
const qc = useQueryClient();
// This page only claims a PAID placeholder's real install UUID. Self-hosted
// Free is created on the purchase page, so with no placeholder to claim there
// is nothing to do here — send them there.
const claimId = useSearchParams().get("claim") ?? undefined;
useEffect(() => {
if (!claimId) router.replace("/purchase");
}, [claimId, router]);
if (!claimId) return null;
return (
<div className="grid max-w-2xl gap-6">
@@ -17,6 +28,7 @@ export default function LinkPage() {
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
/>
<LinkForm
claimId={claimId}
onLinked={(instanceId) => {
qc.invalidateQueries({ queryKey: ["account"] });
// Straight to the download, not back to a list: the licence is
@@ -1,65 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function CreateForm() {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const qc = useQueryClient();
const create = useMutation({
mutationFn: () => api.createInstance(name.trim()),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["account"] });
router.push("/");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
const slug = slugify(name);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (name.trim()) create.mutate();
}}
>
<Field
label="Instance name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
required
error={error ?? undefined}
hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`}
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing your Vantage
HQ password changes it here too.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
</Button>
</form>
);
}
@@ -1,18 +0,0 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Create a free instance"
subtitle="An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Free covers three servers, and the licence runs for a month at a time — we email you before it needs renewing."
/>
<CreateForm />
</div>
);
}
+9 -15
View File
@@ -55,7 +55,6 @@ export default function OverviewPage() {
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const hasFree = live.some((i) => i.tier === "free" && i.status !== "cancelled");
const subtitle =
live.length === 0
@@ -70,10 +69,8 @@ export default function OverviewPage() {
title="Overview"
subtitle={subtitle}
actions={
!hasFree && live.length > 0 ? (
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
live.length > 0 ? (
<LinkButton href="/purchase">Buy a plan</LinkButton>
) : undefined
}
record={[
@@ -92,14 +89,11 @@ export default function OverviewPage() {
<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>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/instances/new">Create a free instance</LinkButton>
<LinkButton variant="line" href="/instances/link">
Link an install
</LinkButton>
<LinkButton href="/purchase">Buy a plan</LinkButton>
</div>
</div>
) : (
@@ -181,14 +175,14 @@ export default function OverviewPage() {
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">
Link your own install to get its licence file. It keeps its own
users.
Get a licence for your own install free or paid from the
purchase page. It keeps its own users.
</p>
<Link
href="/instances/link"
href="/purchase"
className="text-[0.82rem] font-semibold text-accent underline"
>
Link an install
Get a licence
</Link>
</RailCard>
</>
@@ -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>
);
}
+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>
);
}
+32 -101
View File
@@ -31,23 +31,12 @@ const KEY = (id: string) => `vantage-hq-record-open:${id}`;
* because the thing that needs you is the thing that should be open. A manual
* toggle is remembered per instance and beats the default from then on.
*/
export function InstanceRecord({
instance,
license,
reapAfterDays,
defaultOpen = false,
}: {
instance: Instance;
license?: License;
reapAfterDays?: number;
defaultOpen?: boolean;
}) {
export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen = false }: { instance: Instance; license?: License; reapAfterDays?: number; defaultOpen?: boolean }) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays =
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
@@ -80,13 +69,7 @@ export function InstanceRecord({
const panelId = `record-${instance.instance_id}`;
return (
<article
className={clsx(
"relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5",
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
STRIPE[state],
)}
>
<article className={clsx("relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5", "before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", STRIPE[state])}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[1.22rem]">{instance.name || "Unnamed instance"}</h2>
@@ -96,10 +79,7 @@ export function InstanceRecord({
{` · created ${formatDate(instance.created_at)}`}
</p>
{cloud && instance.slug && (
<a
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline"
>
<a href={`https://${instance.slug}.vantage.hostxtra.co.uk`} className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline">
{instance.slug}.vantage.hostxtra.co.uk &rarr;
</a>
)}
@@ -115,10 +95,7 @@ export function InstanceRecord({
aria-label={open ? "Hide details" : "Show details"}
className="grid h-[26px] w-[26px] place-items-center rounded-sm border border-rule bg-panel text-[0.6rem] text-ink-3 hover:border-accent hover:text-accent"
>
<span
aria-hidden
className={clsx("block transition-transform", open && "rotate-180")}
>
<span aria-hidden className={clsx("block transition-transform", open && "rotate-180")}>
</span>
</button>
@@ -144,60 +121,36 @@ export function InstanceRecord({
{state === "expired" && (
<div className="grid gap-1">
<p className="text-[0.82rem] text-ink-2">
Servers and monitors are still running, and your agents keep their keys.
Changes are disabled until you renew.
</p>
<p className="text-[0.82rem] text-ink-2">Servers and monitors are still running, and your agents keep their keys. Changes are disabled until you renew.</p>
{deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
{deleteInDays <= 0 ? "Scheduled for deletion." : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
</div>
)}
{state === "none" && (
<p className="text-[0.82rem] text-ink-2">
You have paid for this but it is not attached to an install yet, so no licence
has been issued. Linking takes a minute.
</p>
)}
{state === "none" && <p className="text-[0.82rem] text-ink-2">You have paid for this but it is not attached to an install yet, so no licence has been issued. Linking takes a minute.</p>}
<div id={panelId} hidden={!open} className="grid gap-3.5">
<div id={panelId} className={clsx("gap-3.5", open ? "grid" : "hidden")}>
{license && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">
Included in {instance.tier?.replace("_", " ") ?? "this licence"}
</p>
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Included in {instance.tier?.replace("_", " ") ?? "this licence"}</p>
<div className="flex flex-wrap gap-x-7 gap-y-2.5">
<Stat n={limitLabel(license.limits.max_servers)} label="Servers" />
<Stat
n={limitLabel(license.limits.max_secret_groups)}
label="Secret groups"
/>
<Stat n={limitLabel(license.limits.max_secret_groups)} label="Secret groups" />
<Stat n={limitLabel(license.limits.max_channels)} label="Channels" />
<Stat
n={license.features.length ? license.features.join(" · ") : "None"}
label="Features"
quiet={license.features.length === 0}
/>
<Stat n={license.features.length ? license.features.join(" · ") : "None"} label="Features" quiet={license.features.length === 0} />
</div>
</div>
)}
{cloud && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">
Who can sign in
</p>
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Who can sign in</p>
<div className="flex flex-wrap items-center gap-2">
{(members.data ?? []).map((m) => (
<span
key={m.member_id}
className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2"
>
<span key={m.member_id} className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2">
<span className="grid h-[18px] w-[18px] place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
@@ -205,13 +158,8 @@ export function InstanceRecord({
</span>
))}
{members.isLoading && <span className="text-[0.82rem] text-ink-3">Loading</span>}
{members.data?.length === 0 && (
<span className="text-[0.82rem] text-ink-3">Nobody yet.</span>
)}
<Link
href={`/instances/${instance.instance_id}`}
className="text-[0.82rem] font-semibold text-accent underline"
>
{members.data?.length === 0 && <span className="text-[0.82rem] text-ink-3">Nobody yet.</span>}
<Link href={`/instances/${instance.instance_id}`} className="text-[0.82rem] font-semibold text-accent underline">
Manage access
</Link>
</div>
@@ -220,35 +168,29 @@ export function InstanceRecord({
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
<LinkButton href="/instances/link">Link an install</LinkButton>
// A paid placeholder (awaiting_link) claims its real install
// UUID in place. Anything else without a licence gets one from
// the purchase page (self-hosted Free is created there).
instance.status === "awaiting_link" ? (
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>Link an install</LinkButton>
) : (
<LinkButton href="/purchase">Get a licence</LinkButton>
)
) : cloud && instance.slug ? (
<>
<LinkButton
external
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
>
Open instance
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
Open Cloud Instance
</LinkButton>
<LinkButton
variant="line"
href={`/instances/${instance.instance_id}`}
>
Instance settings
<LinkButton variant="line" href={`/instances/${instance.instance_id}`}>
View Instance Settings
</LinkButton>
</>
) : (
<LinkButton href={`/instances/${instance.instance_id}`}>
{state === "expired" ? "Renew and download" : "Licence and download"}
</LinkButton>
<LinkButton href={`/instances/${instance.instance_id}`}>View Instance Settings</LinkButton>
)}
{canRenew && (
<Button
type="button"
variant="line"
onClick={() => renew.mutate()}
disabled={renew.isPending}
>
<Button type="button" variant="line" onClick={() => renew.mutate()} disabled={renew.isPending}>
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
@@ -261,19 +203,8 @@ export function InstanceRecord({
function Stat({ n, label, quiet }: { n: string; label: string; quiet?: boolean }) {
return (
<div className="grid gap-px">
<b
className={clsx(
"tabular-nums tracking-[-0.02em]",
quiet
? "text-[0.95rem] font-semibold text-ink-3"
: "text-[1.18rem] font-extrabold",
)}
>
{n}
</b>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</span>
<b className={clsx("tabular-nums tracking-[-0.02em]", quiet ? "text-[0.95rem] font-semibold text-ink-3" : "text-[1.18rem] font-extrabold")}>{n}</b>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
</div>
);
}
+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>
);
}
+64
View File
@@ -188,6 +188,51 @@ export interface EntitlementConfig {
features: string[];
}
export interface CheckoutOptions {
plans: Plan[];
catalogue: CatalogueRow[];
env: "sandbox" | "production";
}
/*
* lineItemsFor builds the Paddle checkout items for a configuration, client-side
* from the catalogue already fetched. It mirrors the Go catalogue.LineItems and
* its billable() exactly: base is quantity 1; the per-server unit's quantity is
* servers MINUS the plan's base allowance (never charge for the base — the one
* subtraction, kept here to match the server); a feature contributes an item
* only when its row has a price in this environment/term.
*/
export function lineItemsFor(
opts: CheckoutOptions,
choice: { tier: Tier; term: Term; servers: number; features: string[] },
deployment: Deployment,
): { priceId: string; quantity: number }[] {
const env = opts.env;
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
if (!plan) return [];
const rows = opts.catalogue.filter(
(r) => r.deployment === deployment && r.tier === choice.tier,
);
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
const base = plan.base_limits.max_servers;
const items: { priceId: string; quantity: number }[] = [];
for (const r of rows) {
const id = priceOf(r);
if (r.kind === "base") {
if (id) items.push({ priceId: id, quantity: 1 });
} else if (r.kind === "limit" && r.limit_key === "max_servers") {
// -1 base is unlimited: nothing metered. Otherwise charge servers over base.
const qty = base === -1 ? 0 : choice.servers - base;
if (qty > 0 && id) items.push({ priceId: id, quantity: qty });
} else if (r.kind === "feature" && r.feature_key) {
if (choice.features.includes(r.feature_key) && id) {
items.push({ priceId: id, quantity: 1 });
}
}
}
return items;
}
export interface Entitlement {
instance_id: string;
account_id: string;
@@ -263,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 }),
+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;
}
}
+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 {
+54 -5
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,7 +87,7 @@ 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
@@ -96,6 +95,7 @@ vantage/
│ ├── 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
@@ -191,6 +191,37 @@ person is later granted. `GET /auth/verify` therefore peeks before it consumes:
a token belonging to a passwordless row answers `{"needs_password":true}` and is
left unspent.
### Email
`shared/mail` is the only email system. It owns the SMTP conversation, the RFC
5322 envelope and the look of every message; `server`, `admin` and `sitesvc`
each import it and none of them builds a subject line, a MIME part or a colour.
Before this existed the transport was copied three times, and the copies had
already diverged once — the 465-implicit-TLS fix landed in one of them while
the others silently delivered nothing.
`Sender` is a value, not a singleton: `server/internal/notify` builds one per
notification channel from the channel document in Mongo, while `sitesvc` builds
one at boot and `admin` holds one in `admin/internal/mail.Default`, alongside
its other boot-time singletons. Callers only ever see typed methods —
`SendVerification`, `SendExpiring`, `SendMonitorAlert`, `SendEnquiry` and the
rest, grouped by owner into `account.go`, `licence.go`, `billing.go`,
`monitor.go` and `contact.go`.
Every message is `multipart/alternative`, so each one is two templates:
`templates/<name>.html.tmpl` and `.txt.tmpl`, embedded with `go:embed`. They
define `subject`, `title`, `pill` and `body`; `layout.html.tmpl` and
`layout.txt.tmpl` provide the chrome and the helper templates (`p`, `lead`,
`button`, `well`, `note`, `rows`, `chip`) that the bodies compose. One template
set is parsed per message rather than one big set, because every message
defines those same four names. **`subject` is defined in the txt file only** —
`html/template` would escape an ampersand in an instance name and mail clients
show subjects verbatim.
`shared/mail/render_test.go` renders all of them and fails if a template exists
that no case covers, which is the only thing standing between a mistyped field
and a boot-time panic — the templates are parsed in `init()`.
### Shared provisioning
`shared/provision` (`instance.go`, `slug.go`, `user.go`) holds the slug rules, reserved names and instance/user creation logic that both `server` and `admin/internal/cloudprov` need, so there is no longer a second copy to drift: `cloudprov.CreateInstance` calls straight into it to create a control-plane instance and its owner from a customer request.
@@ -327,6 +358,7 @@ POST /auth/staff/login /auth/login /auth/logout
POST /auth/signup # self-hosted only; honeypot + rate limited
GET /auth/verify?token=…
POST /auth/accept-invite # an invitee sets their own password
POST /api/paddle/webhook # Paddle events; signature-verified, idempotent, no session
```
Customer-session (`/api`), every instance resolved through `ownedInstance`:
@@ -338,6 +370,11 @@ POST /instances/:id/renew # Free renewal; refuses outside t
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
POST /instances/link · /instances/:id/relink
GET /instances/:id/entitlement
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
POST /instances/self-hosted # create a paid-checkout placeholder (awaiting_link, no licence)
POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue
PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin)
POST /billing/portal # mint a Paddle customer-portal URL
GET /instances/:id/license · /instances/:id/license/download
GET /subscriptions
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
@@ -360,11 +397,17 @@ POST /instances/:id/issue · /instances/:id/relink
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
GET,PUT /catalogue
GET,PUT /instances/:id/entitlement
GET /health/injection
GET /health/injection · /health/billing
```
**Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters.
### Billing (Paddle)
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired``granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
@@ -381,7 +424,7 @@ 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` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
@@ -514,6 +557,8 @@ All three apps are **one visual system**, anchored on the logo navy. What differ
`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.
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.
@@ -613,6 +658,10 @@ git push origin main # server + web deploy
| `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:
@@ -12,7 +12,7 @@
## Global Constraints
- Package path: `github.com/mrhid6/vantage/shared/license`
- Package path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`
- `shared/go.mod` gains **one** dependency: `github.com/hyperboloide/lk`. This is a deliberate exception to plan 0a's three-dependency limit, and the only one. `shared` must still not import gin, redis, guac or the mongo-driver from this package.
- Tier and deployment values are exact strings: `free`, `professional`, `self_hosted`; `cloud`, `self_hosted`.
- Feature keys are exact strings: `console`, `oidc`.
@@ -28,14 +28,14 @@
**Created:**
| Path | Responsibility |
|---|---|
| Path | Responsibility |
| --------------------------- | ------------------------------------------------------ |
| `shared/license/license.go` | `License`, `Limits`, tier/deployment/feature constants |
| `shared/license/keys.go` | `trustedPublicKeys`, key lookup |
| `shared/license/sign.go` | `Sign`, build-tagged out of the server |
| `shared/license/verify.go` | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State` |
| `shared/license/plans.go` | The tier seed table used by `lkctl` |
| `shared/cmd/lkctl/main.go` | `keypair`, `issue`, `inspect` |
| `shared/license/keys.go` | `trustedPublicKeys`, key lookup |
| `shared/license/sign.go` | `Sign`, build-tagged out of the server |
| `shared/license/verify.go` | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State` |
| `shared/license/plans.go` | The tier seed table used by `lkctl` |
| `shared/cmd/lkctl/main.go` | `keypair`, `issue`, `inspect` |
**Modified:** `shared/go.mod`, `shared/go.sum`.
@@ -46,10 +46,12 @@
The rest of the plan assumes specific `lk` function names. Confirm them first rather than discovering a mismatch three tasks later.
**Files:**
- Modify: `shared/go.mod`, `shared/go.sum`
- Create (temporary): `shared/cmd/lkprobe/main.go`
**Interfaces:**
- Consumes: nothing
- Produces: a pinned `lk` version and a confirmed API surface
@@ -145,20 +147,22 @@ git commit -m "chore(shared): add hyperboloide/lk for licence signing"
### Task 2: The payload and the tier table
**Files:**
- Create: `shared/license/license.go`
- Create: `shared/license/plans.go`
**Interfaces:**
- Consumes: nothing
- Produces:
- `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
- `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
- `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
- `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
- `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
- `func (l License) HasFeature(name string) bool`
- `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
- `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`
- `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
- `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
- `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
- `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
- `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
- `func (l License) HasFeature(name string) bool`
- `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
- `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`
- [ ] **Step 1: Write the payload**
@@ -316,15 +320,17 @@ git commit -m "feat(license): add the licence payload and tier seed table"
### Task 3: Signing, and keeping it out of the server
**Files:**
- Create: `shared/license/keys.go`
- Create: `shared/license/sign.go`
**Interfaces:**
- Consumes: `License`
- Produces:
- `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
- `var trustedPublicKeys []string`
- `func publicKeys() ([]*lk.PublicKey, error)`
- `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
- `var trustedPublicKeys []string`
- `func publicKeys() ([]*lk.PublicKey, error)`
- [ ] **Step 1: Write the trusted key list**
@@ -447,17 +453,19 @@ git commit -m "feat(license): add signing and the trusted key list"
The heart of the system. Check order is part of the contract, because the reason drives the message a customer sees.
**Files:**
- Create: `shared/license/verify.go`
**Interfaces:**
- Consumes: `License`, `publicKeys()`
- Produces:
- `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
- `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
- `type Result struct { License License; State State; Reason string }`
- `func Verify(blob string, opts VerifyOpts) Result`
- `func Parse(blob string) (License, error)`
- Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`
- `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
- `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
- `type Result struct { License License; State State; Reason string }`
- `func Verify(blob string, opts VerifyOpts) Result`
- `func Parse(blob string) (License, error)`
- Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`
- [ ] **Step 1: Write the verifier**
@@ -624,9 +632,11 @@ git commit -m "feat(license): add offline verification"
The production issuance path until the admin service exists, and the disaster-recovery path forever after — if admin is down and a customer's licence expires, a blob can still be cut by hand.
**Files:**
- Create: `shared/cmd/lkctl/main.go`
**Interfaces:**
- Consumes: `Sign`, `Parse`, `PlanFor`, `License`
- Produces: the `lkctl` binary
@@ -654,7 +664,7 @@ import (
"github.com/google/uuid"
"github.com/hyperboloide/lk"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
func main() {
@@ -834,9 +844,11 @@ git commit -m "feat(license): add lkctl for issuing licences by hand"
This is the step that makes the system real. Do it once, carefully.
**Files:**
- Modify: `shared/license/keys.go`
**Interfaces:**
- Consumes: `lkctl keypair`
- Produces: a populated `trustedPublicKeys`
@@ -891,7 +903,7 @@ import (
"strings"
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
func main() {
@@ -4,7 +4,7 @@
**Goal:** Extract a `shared` Go module holding the document shapes and provisioning rules that `server` and `sitesvc` both write, deleting the hand-copied duplicates in sitesvc.
**Architecture:** A new Go module `github.com/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve.
**Architecture:** A new Go module `gitea.hostxtra.co.uk/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve.
**Tech Stack:** Go 1.26, MongoDB driver v2, `golang.org/x/crypto/bcrypt`, `github.com/google/uuid`, Docker, Gitea Actions.
@@ -13,7 +13,7 @@
## Global Constraints
- Go version in every `go.mod`: `go 1.26`
- Module path: `github.com/mrhid6/vantage/shared`
- Module path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared`
- `shared/go.mod` may require **only** these three: `go.mongodb.org/mongo-driver/v2`, `golang.org/x/crypto`, `github.com/google/uuid`. Any addition needs review — this constraint is what keeps sitesvc small.
- `MinSlugLength = 3`, `MaxSlugLength = 40`, `BcryptCost = 12` — exact values, no literals elsewhere.
- Reserved slugs, exactly: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, `default`
@@ -29,38 +29,38 @@
**Created:**
| Path | Responsibility |
|---|---|
| `go.work` | Workspace over shared, server, sitesvc |
| `shared/go.mod`, `shared/go.sum` | Module definition |
| `shared/models/org.go` | `Org` document |
| `shared/models/user.go` | `User` document, role constants, `ValidRole` |
| `shared/models/settings.go` | `Settings` and sub-structs |
| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants |
| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` |
| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` |
| `shared/indexes/indexes.go` | `EnsureCoreIndexes` |
| Path | Responsibility |
| -------------------------------- | -------------------------------------------------------------------- |
| `go.work` | Workspace over shared, server, sitesvc |
| `shared/go.mod`, `shared/go.sum` | Module definition |
| `shared/models/org.go` | `Org` document |
| `shared/models/user.go` | `User` document, role constants, `ValidRole` |
| `shared/models/settings.go` | `Settings` and sub-structs |
| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants |
| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` |
| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` |
| `shared/indexes/indexes.go` | `EnsureCoreIndexes` |
**Modified:**
| Path | Change |
|---|---|
| `server/go.mod` | require + replace shared |
| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared |
| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared |
| `server/internal/services/users.go` | `CreateUser` delegates to shared |
| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared |
| `server/Dockerfile` | Build from repo root |
| `sitesvc/go.mod` | require + replace shared |
| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` |
| `sitesvc/internal/store/store.go` | Uses shared provision |
| `sitesvc/Dockerfile` | Build from repo root |
| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images |
| Path | Change |
| --------------------------------------------------------- | --------------------------------------- |
| `server/go.mod` | require + replace shared |
| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared |
| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared |
| `server/internal/services/users.go` | `CreateUser` delegates to shared |
| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared |
| `server/Dockerfile` | Build from repo root |
| `sitesvc/go.mod` | require + replace shared |
| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` |
| `sitesvc/internal/store/store.go` | Uses shared provision |
| `sitesvc/Dockerfile` | Build from repo root |
| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images |
**Deleted:**
| Path |
|---|
| Path |
| ----------------------------------------- |
| `sitesvc/internal/provision/provision.go` |
---
@@ -68,10 +68,12 @@
### Task 1: Scaffold the shared module
**Files:**
- Create: `shared/go.mod`
- Create: `go.work`
**Interfaces:**
- Consumes: nothing
- Produces: an importable but empty module
@@ -81,7 +83,7 @@
cd c:/Work/Repos/vantage
mkdir -p shared
cd shared
go mod init github.com/mrhid6/vantage/shared
go mod init gitea.hostxtra.co.uk/mrhid6/vantage/shared
go get go.mongodb.org/mongo-driver/v2@latest
go get golang.org/x/crypto@latest
go get github.com/google/uuid@latest
@@ -106,7 +108,7 @@ use (
- [ ] **Step 3: Verify the workspace resolves**
Run: `cd c:/Work/Repos/vantage && go work sync && go list -m all | head -5`
Expected: output includes `github.com/mrhid6/vantage/shared`, `github.com/mrhid6/vantage/server` and `github.com/mrhid6/vantage/sitesvc`. No error.
Expected: output includes `gitea.hostxtra.co.uk/mrhid6/vantage/shared`, `gitea.hostxtra.co.uk/mrhid6/vantage/server` and `gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc`. No error.
- [ ] **Step 4: Commit**
@@ -122,21 +124,23 @@ git commit -m "chore: scaffold shared module"
Pure definitions with no database access. Grouped into one task because neither is independently reviewable — a struct with no consumer and a regex with no caller are the same review.
**Files:**
- Create: `shared/provision/slug.go`
- Create: `shared/models/org.go`
- Create: `shared/models/user.go`
- Create: `shared/models/settings.go`
**Interfaces:**
- Consumes: nothing
- Produces:
- `const MinSlugLength = 3`, `MaxSlugLength = 40`
- `var ReservedSlugs map[string]bool`
- `func Slugify(name string) string`
- `func BaseSlug(name string) (string, error)`
- `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2`
- `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings`
- `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool`
- `const MinSlugLength = 3`, `MaxSlugLength = 40`
- `var ReservedSlugs map[string]bool`
- `func Slugify(name string) string`
- `func BaseSlug(name string) (string, error)`
- `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2`
- `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings`
- `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool`
- [ ] **Step 1: Write the slug rules**
@@ -342,7 +346,7 @@ package main
import (
"fmt"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
)
func main() {
@@ -392,18 +396,20 @@ The single `CreateOrg`, `RollbackOrg` and `CreateUser`. **Two deliberate behavio
2. Error text uses "organisation", not "organization".
**Files:**
- Create: `shared/provision/org.go`
- Create: `shared/provision/user.go`
**Interfaces:**
- Consumes: `models.Org`, `models.User`, `models.ValidRole`, `provision.BaseSlug`, `provision.NextSlug`
- Produces:
- `var ErrNameRejected error`, `var ErrEmailTaken error`
- `const BcryptCost = 12`
- `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)`
- `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error`
- `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)`
- `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)`
- `var ErrNameRejected error`, `var ErrEmailTaken error`
- `const BcryptCost = 12`
- `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)`
- `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error`
- `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)`
- `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)`
`CreateUserWithHash` exists because sitesvc hashes the password at signup time and stores the hash in the pending record; by verification time it holds a hash, not a password. Without it sitesvc would have to insert the document by hand, which is the duplication this plan removes.
@@ -421,7 +427,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"
)
@@ -503,7 +509,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/mongo"
"golang.org/x/crypto/bcrypt"
)
@@ -582,9 +588,11 @@ previously returned an error when it lost the slug race."
`users.email` and `orgs.slug` unique indexes are a tenant-isolation property, not an optimisation — `GetUserByEmail` does an unscoped `FindOne`, so a duplicate email would let the OIDC cross-org guard compare against an arbitrary user. Both services declare them, and both treat failure as fatal.
**Files:**
- Create: `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: nothing
- Produces: `func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error`
@@ -639,7 +647,7 @@ func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
```bash
cd c:/Work/Repos/vantage/shared
go build ./... && go vet ./...
go list -m all | grep -Ev "^github.com/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org"
go list -m all | grep -Ev "^gitea.hostxtra.co.uk/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org"
```
Expected: no output from the build, and no unexpected module from the list. Gin, redis or guac appearing means something was moved into `shared` that should not have been.
@@ -658,6 +666,7 @@ git commit -m "feat(shared): add EnsureCoreIndexes"
Server keeps its own package paths so no call site outside these files changes. `server/internal/models` re-exports the shared types as aliases — a type alias is identical to the aliased type, so `models.Org` in existing server code keeps working untouched.
**Files:**
- Modify: `server/go.mod`
- Modify: `server/internal/models/org.go`, `user.go`, `settings.go`
- Modify: `server/internal/services/orgs.go` (`CreateOrg`, `AdoptOrg`, delete `reservedSlugs`)
@@ -665,6 +674,7 @@ Server keeps its own package paths so no call site outside these files changes.
- Modify: `server/internal/services/migrate.go` (`EnsureAuthIndexes`)
**Interfaces:**
- Consumes: everything from Tasks 24
- Produces: no new exported API. `services.CreateOrg(name string) (*models.Org, error)` and `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` keep their exact signatures.
@@ -672,8 +682,8 @@ Server keeps its own package paths so no call site outside these files changes.
```bash
cd c:/Work/Repos/vantage/server
go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=github.com/mrhid6/vantage/shared=../shared
go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
go mod tidy
```
@@ -684,7 +694,7 @@ Replace the entire contents of `server/internal/models/org.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
// Org is defined in the shared module because sitesvc writes the same
// documents. Aliased rather than re-declared so existing call sites are
@@ -697,7 +707,7 @@ Replace the entire contents of `server/internal/models/user.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type User = shared.User
@@ -715,7 +725,7 @@ Replace the entire contents of `server/internal/models/settings.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type (
Settings = shared.Settings
@@ -763,7 +773,7 @@ func CreateOrg(name string) (*models.Org, error) {
Add to the import block:
```go
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
```
Remove `"github.com/google/uuid"` and `"go.mongodb.org/mongo-driver/v2/mongo"` **only if** nothing else in the file still uses them — `go build` will tell you.
@@ -785,7 +795,7 @@ func CreateUser(orgID, email, password, role, authSource string) (*models.User,
}
```
The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"github.com/mrhid6/vantage/shared/provision"` to the imports.
The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"` to the imports.
- [ ] **Step 6: Delegate EnsureAuthIndexes**
@@ -799,7 +809,7 @@ func EnsureAuthIndexes() error {
}
```
Add `"github.com/mrhid6/vantage/shared/indexes"` to the imports.
Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"` to the imports.
- [ ] **Step 7: Build and vet**
@@ -831,12 +841,14 @@ git commit -m "refactor(server): use shared models, provision and indexes"
### Task 6: Wire sitesvc to shared and delete the duplicates
**Files:**
- Modify: `sitesvc/go.mod`
- Modify: `sitesvc/internal/models/models.go`
- Modify: `sitesvc/internal/store/store.go`
- Delete: `sitesvc/internal/provision/provision.go`
**Interfaces:**
- Consumes: everything from Tasks 24
- Produces: `store.Verify(ctx, rawToken) (*sharedmodels.Org, error)`. `store.CreatePending`, `store.EmailTaken`, `store.Connect`, `store.EnsureIndexes` keep their signatures.
@@ -844,8 +856,8 @@ git commit -m "refactor(server): use shared models, provision and indexes"
```bash
cd c:/Work/Repos/vantage/sitesvc
go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=github.com/mrhid6/vantage/shared=../shared
go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
go mod tidy
```
@@ -867,7 +879,7 @@ import (
// collection exists.
//
// Org and User used to be mirrored here by hand. They now come from
// github.com/mrhid6/vantage/shared/models, which is the only copy.
// gitea.hostxtra.co.uk/mrhid6/vantage/shared/models, which is the only copy.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
@@ -894,17 +906,17 @@ In `sitesvc/internal/store/store.go`:
Replace the two local imports
```go
"github.com/mrhid6/vantage/sitesvc/internal/models"
"github.com/mrhid6/vantage/sitesvc/internal/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/provision"
```
with
```go
"github.com/mrhid6/vantage/shared/indexes"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"github.com/mrhid6/vantage/sitesvc/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
```
Replace the error variables so `errors.Is` keeps working for existing callers in `internal/api`:
@@ -1006,7 +1018,7 @@ cd c:/Work/Repos/vantage
grep -rn "func Slugify\|ReservedSlugs =\|BcryptCost =\|bson:\"org_id\"" sitesvc/
```
Expected: no output. Any `provision.`-qualified *references* are fine; what must be gone are local *definitions*.
Expected: no output. Any `provision.`-qualified _references_ are fine; what must be gone are local _definitions_.
- [ ] **Step 7: Commit**
@@ -1025,10 +1037,12 @@ control plane and sitesvc now share one definition of both."
`replace => ../shared` cannot resolve when the build context is the module directory. Both Go images build from the repo root instead.
**Files:**
- Modify: `server/Dockerfile`, `sitesvc/Dockerfile`
- Modify: `.gitea/workflows/server-deploy.yml`
**Interfaces:**
- Consumes: the module layout from Tasks 16
- Produces: images identical in content to today's, built from a different context
@@ -1097,15 +1111,15 @@ Expected: both succeed, each ending with `naming to docker.io/library/vantage-..
In `.gitea/workflows/server-deploy.yml`, for the `server` and `sitesvc` image build steps only:
```yaml
context: .
file: server/Dockerfile
context: .
file: server/Dockerfile
```
and
```yaml
context: .
file: sitesvc/Dockerfile
context: .
file: sitesvc/Dockerfile
```
Leave the `web` and `site` build steps untouched — they are Node images and do not use the shared module.
@@ -1191,7 +1205,7 @@ curl -s -X POST localhost:8080/auth/bootstrap \
Expected: bootstrap succeeds. Then in `mongosh`:
```javascript
db.orgs.findOne({}, {org_id: 1, name: 1, slug: 1})
db.orgs.findOne({}, { org_id: 1, name: 1, slug: 1 });
```
Expected: `slug: "acme-corp"`, a non-empty `org_id`, `name: "Acme Corp"`.
@@ -1209,7 +1223,7 @@ curl -s -X POST localhost:8080/auth/login \
Then create two more organisations named `Acme Corp` — through the UI, or directly in `mongosh` by calling the server again if a bootstrap-only path is not available. Confirm:
```javascript
db.orgs.find({}, {slug: 1}).sort({slug: 1})
db.orgs.find({}, { slug: 1 }).sort({ slug: 1 });
```
Expected: `acme-corp`, `acme-corp-2`, `acme-corp-3`.
@@ -1261,7 +1275,7 @@ import (
"fmt"
"os"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -1298,6 +1312,7 @@ grep -rn "bson:\"org_id\"" --include=*.go .
```
Expected:
- `func Slugify` — exactly one hit, in `shared/provision/slug.go`
- `ReservedSlugs = map` — exactly one hit, in `shared/provision/slug.go`
- `bson:"org_id"` — hits only in `shared/models/` and `server/internal/models/` for server-only documents. **No hit anywhere under `sitesvc/`.**
@@ -12,13 +12,13 @@
- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
```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" \
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" \
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".
- **Admin's control-plane writes are confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry` on `instances`. Do not widen `inject`.
@@ -46,37 +46,37 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
**Created:**
| Path | Responsibility |
|---|---|
| `admin/internal/cloudprov/cloudprov.go` | admin's ONLY instance/user write path into the control plane |
| `admin/internal/lifecycle/lifecycle.go` | lapse sweep and the four renewal notices |
| `server/internal/services/reap.go` | the Free-instance purge and its scheduler |
| `adminsite/app/(customer)/instances/new/page.tsx` | create-instance form |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component |
| `site/components/AccountForm.tsx` | replaces `InstanceForm.tsx` |
| Path | Responsibility |
| ------------------------------------------------------- | ------------------------------------------------------------ |
| `admin/internal/cloudprov/cloudprov.go` | admin's ONLY instance/user write path into the control plane |
| `admin/internal/lifecycle/lifecycle.go` | lapse sweep and the four renewal notices |
| `server/internal/services/reap.go` | the Free-instance purge and its scheduler |
| `adminsite/app/(customer)/instances/new/page.tsx` | create-instance form |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component |
| `site/components/AccountForm.tsx` | replaces `InstanceForm.tsx` |
**Modified:**
| Path | Change |
|---|---|
| `admin/internal/db/db.go` | `ControlDB()` accessor |
| `admin/internal/config/config.go` | `AppLoginURL` from `APP_LOGIN_URL` |
| `admin/internal/models/models.go` | `StatusDeleted`, `NoticesSent` on `Instance` |
| `admin/internal/mail/mail.go` | four lifecycle emails |
| `admin/internal/api/customer.go` | `createInstance`, `renewInstance` |
| `admin/internal/api/routes.go` | the two new customer routes |
| `admin/internal/inject/inject.go` | reconciler marks vanished instances `deleted` |
| `admin/cmd/main.go` | start the lifecycle sweeper |
| `server/cmd/main.go` | start the reaper |
| `adminsite/lib/api.ts` | `createInstance`, `renewInstance`, `"deleted"` status |
| `adminsite/components/InstanceCard.tsx` | renew action, deletion countdown, monthly-aware bar |
| `adminsite/app/(customer)/page.tsx` | create-instance call to action |
| `site/app/start/page.tsx` | account-signup copy |
| `site/lib/submit.ts` | `submitSignup` posts to admin |
| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed |
| `deploy/docker-compose.site.yml` | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` |
| `.gitea/workflows/server-deploy.yml` | `ADMIN_API_URL` build arg for `site` |
| `CLAUDE.md` | the boundary paragraph, sitesvc's table, the env tables |
| Path | Change |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `admin/internal/db/db.go` | `ControlDB()` accessor |
| `admin/internal/config/config.go` | `AppLoginURL` from `APP_LOGIN_URL` |
| `admin/internal/models/models.go` | `StatusDeleted`, `NoticesSent` on `Instance` |
| `admin/internal/mail/mail.go` | four lifecycle emails |
| `admin/internal/api/customer.go` | `createInstance`, `renewInstance` |
| `admin/internal/api/routes.go` | the two new customer routes |
| `admin/internal/inject/inject.go` | reconciler marks vanished instances `deleted` |
| `admin/cmd/main.go` | start the lifecycle sweeper |
| `server/cmd/main.go` | start the reaper |
| `adminsite/lib/api.ts` | `createInstance`, `renewInstance`, `"deleted"` status |
| `adminsite/components/InstanceCard.tsx` | renew action, deletion countdown, monthly-aware bar |
| `adminsite/app/(customer)/page.tsx` | create-instance call to action |
| `site/app/start/page.tsx` | account-signup copy |
| `site/lib/submit.ts` | `submitSignup` posts to admin |
| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed |
| `deploy/docker-compose.site.yml` | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` |
| `.gitea/workflows/server-deploy.yml` | `ADMIN_API_URL` build arg for `site` |
| `CLAUDE.md` | the boundary paragraph, sitesvc's table, the env tables |
**Deleted:** `sitesvc/internal/api/signup.go`, `site/components/InstanceForm.tsx`.
@@ -85,17 +85,19 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
### Task 1: `cloudprov` — admin's instance and user write path
**Files:**
- Create: `admin/internal/cloudprov/cloudprov.go`
- Modify: `admin/internal/db/db.go`, `admin/internal/config/config.go`
**Interfaces:**
- Consumes: `shared/provision.CreateInstance`, `CreateUserWithHash`, `RollbackInstance`; `shared/models`.
- Produces:
- `db.ControlDB() *mongo.Database`
- `config.Config.AppLoginURL string`
- `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)`
- `cloudprov.DeleteUser(ctx, instanceID, userID string) error`
- `cloudprov.RollbackInstance(ctx, instanceID string) error`
- `db.ControlDB() *mongo.Database`
- `config.Config.AppLoginURL string`
- `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)`
- `cloudprov.DeleteUser(ctx, instanceID, userID string) error`
- `cloudprov.RollbackInstance(ctx, instanceID string) error`
- [ ] **Step 1: Add the `ControlDB` accessor**
@@ -152,9 +154,9 @@ 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"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -225,17 +227,21 @@ func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
- [ ] **Step 4: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 5: Confirm the write boundary holds**
Run:
```sh
grep -rn 'db.Control(' --include=*.go admin/ | grep -v '_test'
```
Expected: matches only in `admin/internal/inject/inject.go`, `admin/internal/cloudprov/cloudprov.go`, and read-only uses in `admin/internal/api/staff.go` and `admin/internal/licensing/link.go`. Any **write** (`UpdateOne`, `InsertOne`, `DeleteOne`) outside `inject` and `cloudprov` is a boundary violation — report it rather than fixing it silently.
- [ ] **Step 6: Commit**
@@ -260,15 +266,17 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 2: Create an instance
**Files:**
- Modify: `admin/internal/models/models.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go`
**Interfaces:**
- Consumes: `cloudprov` from Task 1; `licensing.Issue`, `licensing.ErrFreeLimit`; `inject.Deliver`; `auth.Current`.
- Produces:
- `models.StatusDeleted = "deleted"`
- `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty`
- `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error`
- `POST /api/instances`
- `models.StatusDeleted = "deleted"`
- `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty`
- `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error`
- `POST /api/instances`
- [ ] **Step 1: Add the model fields**
@@ -453,7 +461,7 @@ var appLoginURL string
func SetAppLoginURL(v string) { appLoginURL = v }
```
Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"github.com/mrhid6/vantage/admin/internal/audit"`, `"github.com/mrhid6/vantage/admin/internal/cloudprov"`, `"github.com/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported.
Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported.
- [ ] **Step 4: Wire the route and the config**
@@ -474,9 +482,11 @@ Import `admin/internal/api` there if it is not already imported (it will be, for
- [ ] **Step 5: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 6: Commit**
@@ -501,14 +511,16 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 3: Renew a Free instance
**Files:**
- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go`
**Interfaces:**
- Consumes: `ownedInstance`, `licensing.Issue`, `inject.Deliver`.
- Produces:
- `mail.SendRenewed(to, instanceName string, expires time.Time) error`
- `POST /api/instances/:id/renew`
- `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go`
- `mail.SendRenewed(to, instanceName string, expires time.Time) error`
- `POST /api/instances/:id/renew`
- `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go`
- [ ] **Step 1: Add the renewal window constant**
@@ -621,9 +633,11 @@ In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.POST("/i
- [ ] **Step 5: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 6: Commit**
@@ -646,23 +660,25 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 4: The lapse sweep and the four notices
**Files:**
- Create: `admin/internal/lifecycle/lifecycle.go`
- Modify: `admin/internal/mail/mail.go`, `admin/cmd/main.go`
**Interfaces:**
- Consumes: `models.Instance`, `models.License`, `mail`.
- Produces:
- `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning`
- `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)`
- `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning`
- `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)`
The notice schedule, all relative to the licence's `ExpiresAt` (which already includes the three-day grace):
| Key | Sent when | Says |
|---|---|---|
| `expiring` | 7 days before expiry | renew, one click |
| `expired` | at expiry | read-only; deleted in N days |
| `delete_7` | 7 days before deletion | deleted in 7 days |
| `delete_1` | 1 day before deletion | deleted tomorrow |
| Key | Sent when | Says |
| ---------- | ---------------------- | ---------------------------- |
| `expiring` | 7 days before expiry | renew, one click |
| `expired` | at expiry | read-only; deleted in N days |
| `delete_7` | 7 days before deletion | deleted in 7 days |
| `delete_1` | 1 day before deletion | deleted tomorrow |
- [ ] **Step 1: Add the three emails**
@@ -723,10 +739,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"
)
@@ -917,9 +933,11 @@ In `admin/cmd/main.go`, next to `inject.StartReconciler(ctx)`:
- [ ] **Step 4: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 5: Commit**
@@ -943,10 +961,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 5: The reaper
**Files:**
- Create: `server/internal/services/reap.go`
- Modify: `server/cmd/main.go`
**Interfaces:**
- Consumes: `db.Col`, `db.Database`.
- Produces: `services.ReapFreeInstances(ctx) (checked, purged int, err error)`, `services.StartReaper(ctx)`, `services.PurgeInstance(ctx, instanceID string) (map[string]int64, error)`.
@@ -966,8 +986,8 @@ import (
"os"
"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"
)
@@ -1173,18 +1193,23 @@ In `server/cmd/main.go`, next to `monitorsched.Start(context.Background())`:
- [ ] **Step 3: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: no output.
- [ ] **Step 4: Confirm the collection list is complete**
Run:
```sh
grep -rho 'db\.Col("[a-z_]*"' server/internal/ | sed 's/db.Col("//;s/"//' | sort -u
```
Expected output, exactly:
```
assignments
audit_logs
@@ -1232,9 +1257,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 6: The reconciler marks reaped instances
**Files:**
- Modify: `admin/internal/inject/inject.go`
**Interfaces:**
- Consumes: `models.StatusDeleted` from Task 2.
- Produces: nothing new.
@@ -1282,9 +1309,11 @@ Add `"errors"` and `"go.mongodb.org/mongo-driver/v2/mongo"` to the imports.
- [ ] **Step 2: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 3: Commit**
@@ -1305,10 +1334,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 7: The HQ portal — create and renew
**Files:**
- Create: `adminsite/app/(customer)/instances/new/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/components/InstanceCard.tsx`, `adminsite/app/(customer)/page.tsx`
**Interfaces:**
- Consumes: `POST /api/instances` (Task 2), `POST /api/instances/:id/renew` (Task 3).
- Produces: `api.createInstance(name)`, `api.renewInstance(id)`.
@@ -1319,15 +1350,16 @@ Design rules from `CLAUDE.md` that bind this task: `adminsite` uses only `var(--
In `adminsite/lib/api.ts`:
- Change `InstanceStatus` to include `"deleted"`:
```ts
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
```
```ts
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
```
- Add `notices_sent?: string[];` to the `Instance` interface.
- Add to the `api` object, after `link`:
```ts
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
```
```ts
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
```
- [ ] **Step 2: Write the create form**
@@ -1363,8 +1395,7 @@ export function CreateForm() {
await qc.invalidateQueries({ queryKey: ["account"] });
router.push("/");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
const slug = slugify(name);
@@ -1388,10 +1419,7 @@ export function CreateForm() {
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 one does not
change the other afterwards.
</p>
<p className="text-[0.82rem] text-ink-2">You sign in to it with this same email address and password. Changing one does not change the other afterwards.</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
@@ -1422,9 +1450,8 @@ export default function NewInstancePage() {
<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.
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 />
@@ -1438,36 +1465,28 @@ export default function NewInstancePage() {
In `adminsite/app/(customer)/page.tsx`, replace the "No instances yet" block's contents with a version that offers the action. Keep the existing wrapper classes; change the inner markup to:
```tsx
<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.
</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>
<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.
</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>
```
And below the instances grid, when the account has instances but no Free cloud one, add:
```tsx
{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>
)}
{
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>
);
}
```
- [ ] **Step 5: Renew action and deletion countdown on the card**
@@ -1476,61 +1495,62 @@ In `adminsite/components/InstanceCard.tsx`:
- Make it a client component: add `"use client";` as the first line, and import `useMutation`, `useQueryClient` from `@tanstack/react-query` and `api` from `@/lib/api`.
- The progress bar currently divides by 365, which renders a 30-day Free licence as a 8% sliver. Make the denominator depend on the tier:
```tsx
const termDays = instance.tier === "free" ? 30 : 365;
```
and use `(days / termDays) * 100` in the width calculation.
```tsx
const termDays = instance.tier === "free" ? 30 : 365;
```
and use `(days / termDays) * 100` in the width calculation.
- Add, after the `state === "expired"` paragraph, a deletion countdown driven by props rather than colour:
```tsx
{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>
)}
```
Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised.
```tsx
{
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>
);
}
```
Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised.
- Add a Renew button for Free instances inside the window:
```tsx
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;
```
and render it beside the existing link when `canRenew`:
```tsx
{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>
)}
```
```tsx
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;
```
and render it beside the existing link when `canRenew`:
```tsx
{
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>
);
}
```
`daysRemaining` already exists in `adminsite/lib/format.ts`; check its exact signature before use.
- [ ] **Step 6: Confirm it builds**
Run:
```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful Next.js build. Type errors here are real — fix them rather than loosening types.
- [ ] **Step 7: Confirm no hex colours were introduced**
Run:
```sh
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/ adminsite/app/ --include=*.tsx
```
Expected: no matches. `CLAUDE.md` requires `adminsite` components to reference `var(--…)` tokens only.
- [ ] **Step 8: Commit**
@@ -1554,11 +1574,13 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 8: The marketing form creates an account
**Files:**
- Create: `site/components/AccountForm.tsx`
- Delete: `site/components/InstanceForm.tsx`
- Modify: `site/lib/submit.ts`, `site/app/start/page.tsx`
**Interfaces:**
- Consumes: `POST /auth/signup` on admin (already exists; unchanged by this phase).
- Produces: `submitAccountSignup(fields)` in `site/lib/submit.ts`.
@@ -1581,12 +1603,7 @@ Replace the whole `submitSignup` function with:
* signup are owned by different services, and an implied shared host is how they
* silently end up pointing at the wrong one.
*/
export async function submitAccountSignup(fields: {
name: string;
email: string;
password: string;
website: string;
}): Promise<SubmitResult> {
export async function submitAccountSignup(fields: { name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
if (!ADMIN_API) {
return {
state: "error",
@@ -1607,12 +1624,12 @@ Create `site/components/AccountForm.tsx` by adapting `site/components/InstanceFo
- Delete the `slug` state, the `slugify` helper and the `<span className="hostline">` preview entirely.
- Call `submitAccountSignup` with `{ name, email, password, website }`.
- Change the success panel text to:
```
Check your email.
We sent a confirmation link. Open it and your Vantage account is ready — then you
can create your first instance from the portal. The link works once and expires in
24 hours.
```
```
Check your email.
We sent a confirmation link. Open it and your Vantage account is ready — then you
can create your first instance from the portal. The link works once and expires in
24 hours.
```
Then delete `site/components/InstanceForm.tsx`.
@@ -1623,32 +1640,36 @@ In `site/app/start/page.tsx`:
- Import `AccountForm` instead of `InstanceForm` and render it.
- Change the `<h1>` to `Create your account.`
- Change the lede to:
```
Your account is where instances, licences and billing live. Confirm your email and
you can create a free instance straight away — three servers, hosted by us.
```
```
Your account is where instances, licences and billing live. Confirm your email and
you can create a free instance straight away — three servers, hosted by us.
```
- Change the "What happens next" specs to four steps, in this order:
1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before."
2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner."
3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once."
4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds."
1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before."
2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner."
3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once."
4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds."
- Update `metadata.description` to describe an account rather than an instance.
- [ ] **Step 4: Confirm it builds**
Run:
```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/site":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful build.
- [ ] **Step 5: Confirm nothing still imports the deleted component**
Run:
```sh
grep -rn "InstanceForm\|submitSignup" site/
```
Expected: no matches.
- [ ] **Step 6: Commit**
@@ -1670,10 +1691,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 9: Retire sitesvc's signup
**Files:**
- Delete: `sitesvc/internal/api/signup.go`
- Modify: `sitesvc/internal/api/api.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models/`, `sitesvc/cmd/main.go`
**Interfaces:**
- Consumes: nothing.
- Produces: sitesvc serving `POST /api/contact` and nothing else.
@@ -1701,15 +1724,19 @@ In `sitesvc/cmd/main.go`, remove `APP_LOGIN_URL` and any signup-only wiring. Lea
- [ ] **Step 4: Confirm it compiles and the signup surface is gone**
Run:
```sh
sh /tmp/gorun.sh sitesvc go build ./...
```
Expected: no output.
Run:
```sh
grep -rn "site_pending_signups\|CreatePending\|handleSignup\|handleVerify" sitesvc/
```
Expected: no matches.
- [ ] **Step 5: Run `go mod tidy` with GOWORK off**
@@ -1721,6 +1748,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod
-w /src/sitesvc -e GOWORK=off golang:1.26 go mod tidy
sh /tmp/gorun.sh sitesvc go build ./...
```
Expected: build still clean. `GOWORK=off` is mandatory — in workspace mode `tidy` drops `require` lines and the Docker build then fails with "missing go.sum entry".
- [ ] **Step 6: Commit**
@@ -1745,6 +1773,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 10: Configuration, docs and end-to-end verification
**Files:**
- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`
This task proves the phase. With no test suite, this transcript is the only evidence — run it in full.
@@ -1762,6 +1791,7 @@ In `deploy/docker-compose.site.yml`:
```sh
grep -n "FREE_INSTANCE_REAP_AFTER" deploy/docker-compose.yml
```
Expected: no matches.
The two values must match: admin uses it to name the deletion date in emails, the server uses it to decide. `CLAUDE.md` documents them as a pair in Step 3.
@@ -1778,7 +1808,7 @@ In `.gitea/workflows/server-deploy.yml`, wherever the `site` image is built, pas
- In **Environment Variables (server)**, add:
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
- In **Design Decisions**, add:
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
- Update the sentence in **Admin REST API** or **Security** that describes admin's control-plane access as read-only apart from three licence fields, to name `cloudprov` as the second write path.
- [ ] **Step 4: Build every image**
@@ -1788,6 +1818,7 @@ MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:p2 .
MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:p2 .
MSYS_NO_PATHCONV=1 docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:p2 .
```
Expected: three image IDs. A "missing go.sum entry" failure means Step 5 of Task 9 was run in workspace mode.
- [ ] **Step 5: Start scratch infrastructure**
@@ -1804,6 +1835,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
```
Expected: `reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)`. **This is the safety default and must appear.**
- [ ] **Step 6: Start admin with a real signing key**
@@ -1830,6 +1862,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name p2-admin -p 8094:8083 \
sleep 6
curl -s http://localhost:8094/healthz
```
Expected: `{"ok":true}`.
- [ ] **Step 7: Create an account and verify it**
@@ -1856,6 +1889,7 @@ That hash is a well-known bcrypt test vector; if the sign-in below returns 401,
curl -s -X POST http://localhost:8094/auth/login -H 'Content-Type: application/json' \
-c /tmp/p2.jar -d '{"email":"owner@example.com","password":"hunter2hunter2"}'
```
Expected: `{"kind":"customer","email":"owner@example.com"}`.
- [ ] **Step 8: Seed the Free plan and create an instance**
@@ -1867,12 +1901,14 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval \
'printjson(db.plans.find({},{tier:1,deployment:1,_id:0}).toArray())'
```
Expected: includes `{tier:"free", deployment:"cloud"}`. If `plans` is empty, admin's seeding did not run — report it.
```sh
curl -s -X POST http://localhost:8094/api/instances -b /tmp/p2.jar \
-H 'Content-Type: application/json' -d '{"name":"Northgate Systems"}'
```
Expected: `201` with an instance whose `slug` is `northgate-systems`, `deployment` `cloud`, `status` `active`, and a `tier` of `free`.
- [ ] **Step 9: Confirm the whole chain landed**
@@ -1893,6 +1929,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
```
Expected, all of them:
- `owner: owner@example.com role=owner auth_source=hq hq_user_id=cu-test`
- `license_tier: free`, an expiry roughly one month and three days out
- `blob present: true` — this proves injection ran
@@ -1907,6 +1944,7 @@ curl -s -X POST http://localhost:8092/auth/login -H 'Host: northgate-systems.van
-d '{"email":"owner@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8092/auth/me -H 'Host: northgate-systems.vantage.test' -b /tmp/p2i.jar
```
Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the payoff of the whole phase — the HQ password works on the instance.
- [ ] **Step 11: Confirm the Free cap**
@@ -1915,6 +1953,7 @@ Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8094/api/instances \
-b /tmp/p2.jar -H 'Content-Type: application/json' -d '{"name":"Second One"}'
```
Expected: `409`.
```sh
@@ -1922,6 +1961,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \
'print("instances: " + db.instances.countDocuments({}))'
```
Expected: `instances: 1`. The refusal must leave nothing behind.
- [ ] **Step 12: Confirm renewal refuses outside the window**
@@ -1932,6 +1972,7 @@ INST=$(MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-g
'print(db.instances.findOne({slug:"northgate-systems"}).instance_id)')
curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
```
Expected: `400` with "not due yet".
- [ ] **Step 13: Confirm renewal works inside the window**
@@ -1947,6 +1988,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
```
Expected: `200` with a new licence whose `expires_at` is about a month out, and a `reason` of `renewal`.
```sh
@@ -1958,6 +2000,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
const inst = c.instances.findOne({slug:"northgate-systems"});
print("injected expiry: " + inst.license_expiry);'
```
Expected: 2 licences, 1 superseded, and the injected expiry matching the new one.
- [ ] **Step 14: Confirm the reaper stays off, then deletes only what it should**
@@ -1974,6 +2017,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
```
Expected: `reaper: ENABLED — Free instances are deleted 336h0m0s after their licence expires`.
Now seed three instances the reaper must treat differently, plus rows scoped to the doomed one:
@@ -2005,6 +2049,7 @@ MSYS_NO_PATHCONV=1 docker restart p2-server
sleep 10
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i "REAPING\|reaped\|reaper:"
```
Expected: a `REAPING instance doomed (Doomed, slug=doomed)` line naming the expiry and window, then `reaped instance doomed: map[...]` listing the deleted counts, then `reaper: checked 1, purged 1`.
**`checked` must be 1.** If it is higher, something ineligible was selected — stop and report it. This is the one bug in this phase that destroys customer data.
@@ -2027,6 +2072,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
```
Expected, every line:
- all four `doomed` counts are `0`
- all four survivor counts are `1`
@@ -2041,6 +2087,7 @@ MSYS_NO_PATHCONV=1 docker restart p2-server
sleep 10
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | tail -20 | grep -i "reaper\|panic\|error"
```
Expected: `reaper: ENABLED …` and nothing else — no `REAPING`, no panic, no error.
- [ ] **Step 15c: Confirm the collection list is exhaustive**
@@ -2099,7 +2146,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
- `deploy/docker-compose.yml` does not mention `FREE_INSTANCE_REAP_AFTER`.
- `grep -rn "InstanceForm\|submitSignup" site/` and `grep -rn "site_pending_signups" sitesvc/` both return nothing.
**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice *selection* run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat.
**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice _selection_ run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat.
## Deployment order
@@ -12,13 +12,13 @@
- **No automated Go tests.** Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step below is a command with expected output. This matches plans 0a through 4.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper from earlier plans:
```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" \
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" \
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".
- **`shared/` is consumed through `replace` directives** in `server`, `admin` and `sitesvc`. A change to `shared/` reaches all three on their next build; there is no version to bump.
@@ -38,17 +38,17 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
**Modified:**
| Path | Change |
|---|---|
| `shared/indexes/indexes.go` | compound `(instance_id, email)` unique index; idempotent drop of `email_1` |
| `shared/models/user.go` | `HQUserID` field, `AuthLocal`/`AuthOIDC`/`AuthHQ` constants |
| `server/internal/services/users.go` | `GetUserByEmail` deleted, `GetUserInInstanceByEmail` added |
| `server/internal/auth/local.go` | `resolveLoginInstance`, scoped sign-in |
| `server/internal/auth/oidc.go` | scoped lookup, cross-instance guard deleted |
| `admin/internal/auth/cloud.go` | **deleted** |
| `admin/internal/api/routes.go` | `/auth/login` points at `HandleCustomerLogin`; new staff route |
| `admin/internal/api/staff.go` | `staffCreateAccountUser` |
| `CLAUDE.md` | the index security-property paragraph, and the auth section |
| Path | Change |
| ----------------------------------- | -------------------------------------------------------------------------- |
| `shared/indexes/indexes.go` | compound `(instance_id, email)` unique index; idempotent drop of `email_1` |
| `shared/models/user.go` | `HQUserID` field, `AuthLocal`/`AuthOIDC`/`AuthHQ` constants |
| `server/internal/services/users.go` | `GetUserByEmail` deleted, `GetUserInInstanceByEmail` added |
| `server/internal/auth/local.go` | `resolveLoginInstance`, scoped sign-in |
| `server/internal/auth/oidc.go` | scoped lookup, cross-instance guard deleted |
| `admin/internal/auth/cloud.go` | **deleted** |
| `admin/internal/api/routes.go` | `/auth/login` points at `HandleCustomerLogin`; new staff route |
| `admin/internal/api/staff.go` | `staffCreateAccountUser` |
| `CLAUDE.md` | the index security-property paragraph, and the auth section |
**Created:** none.
@@ -57,9 +57,11 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
### Task 1: Compound index and the drop
**Files:**
- Modify: `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: nothing new.
- Produces: `indexes.EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error` — unchanged signature, new behaviour. Called at boot by `server`, `sitesvc` and `admin`.
@@ -172,17 +174,21 @@ func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string)
- [ ] **Step 2: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh shared go build ./...
```
Expected: no output.
- [ ] **Step 3: Confirm the legacy index is not declared anywhere else**
Run:
```sh
grep -rn '"email"' --include=*.go shared/ server/ sitesvc/ admin/ | grep -i index
```
Expected: no matches. If sitesvc or the server declares its own `users.email` index, it would recreate what Task 1 drops.
- [ ] **Step 4: Commit**
@@ -206,15 +212,17 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 2: `hq` fields on the user document
**Files:**
- Modify: `shared/models/user.go`
- Modify: `server/internal/models/user.go`
**Interfaces:**
- Consumes: nothing.
- Produces:
- `shared/models.AuthLocal = "local"`, `AuthOIDC = "oidc"`, `AuthHQ = "hq"`
- `shared/models.User.HQUserID string` — bson `hq_user_id,omitempty`
- the same three constants re-exported from `server/internal/models`, which is a thin alias file over `shared/models` and is what server code imports
- `shared/models.AuthLocal = "local"`, `AuthOIDC = "oidc"`, `AuthHQ = "hq"`
- `shared/models.User.HQUserID string` — bson `hq_user_id,omitempty`
- the same three constants re-exported from `server/internal/models`, which is a thin alias file over `shared/models` and is what server code imports
Nothing writes `AuthHQ` or `HQUserID` in this phase. They land now so phases 2 and 3 do not have to change the shared module and rebuild every service again.
@@ -261,7 +269,7 @@ type User struct {
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type User = shared.User
@@ -283,10 +291,12 @@ func ValidRole(role string) bool { return shared.ValidRole(role) }
- [ ] **Step 3: Confirm both compile**
Run:
```sh
sh /tmp/gorun.sh shared go build ./...
sh /tmp/gorun.sh server go build ./...
```
Expected: no output from either.
- [ ] **Step 4: Commit**
@@ -306,9 +316,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 3: Scoped lookup in the user service
**Files:**
- Modify: `server/internal/services/users.go:65-75`
**Interfaces:**
- Consumes: `shared/indexes` from Task 1.
- Produces: `services.GetUserInInstanceByEmail(instanceID, email string) (*models.User, error)`.
- Removes: `services.GetUserByEmail`. Tasks 4 and 5 fix its two callers; the build will be red between this task and Task 5, which is expected and is why they are adjacent.
@@ -343,15 +355,19 @@ func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
- [ ] **Step 2: Confirm the unscoped helper is gone and the build is red for the expected reason**
Run:
```sh
grep -rn "GetUserByEmail" --include=*.go .
```
Expected: exactly two matches, both call sites — `server/internal/auth/local.go` and `server/internal/auth/oidc.go`. No definition.
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: FAIL with `undefined: services.GetUserByEmail` at those two call sites. Any other error means something else was broken.
- [ ] **Step 3: Do not commit yet**
@@ -363,9 +379,11 @@ The build is red. Commit at the end of Task 5, when both callers are fixed. A co
### Task 4: Scoped local login
**Files:**
- Modify: `server/internal/auth/local.go:25-49`
**Interfaces:**
- Consumes: `services.GetUserInInstanceByEmail` from Task 3, `services.CountInstances` and `services.FirstInstance` from `server/internal/services/instances.go:57` and `:63`, `auth.InstanceFromHost` from `server/internal/auth/instancehost.go:53`.
- Produces: `resolveLoginInstance(c *gin.Context) (string, error)`, unexported, used only by this file.
@@ -426,9 +444,11 @@ The `SaveSession` call below it is unchanged: it already reads `u.InstanceID`.
- [ ] **Step 2: Confirm only the OIDC caller is left broken**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: FAIL with `undefined: services.GetUserByEmail` at `internal/auth/oidc.go:130` only.
---
@@ -436,9 +456,11 @@ Expected: FAIL with `undefined: services.GetUserByEmail` at `internal/auth/oidc.
### Task 5: Scoped OIDC callback
**Files:**
- Modify: `server/internal/auth/oidc.go:129-141`
**Interfaces:**
- Consumes: `services.GetUserInInstanceByEmail` from Task 3.
- Produces: nothing new.
@@ -485,7 +507,7 @@ with:
`services.CreateUser`'s signature is `CreateUser(instanceID, email, password, role, authSource string)` — the argument order above matches it, with the two string literals the old code passed replaced by the constants Task 2 added.
`oidc.go` already imports `github.com/mrhid6/vantage/server/internal/models`; confirm it before relying on the constants:
`oidc.go` already imports `gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models`; confirm it before relying on the constants:
```sh
grep -n "server/internal/models" server/internal/auth/oidc.go
@@ -496,23 +518,29 @@ If that returns nothing, add the import rather than reverting to string literals
- [ ] **Step 2: Confirm the build is green**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: no output.
- [ ] **Step 3: Confirm no unscoped email lookup survives anywhere in the server**
Run:
```sh
grep -rn "GetUserByEmail" --include=*.go .
```
Expected: no matches at all.
Run:
```sh
grep -rn 'FindOne(ctx, bson.M{"email"' --include=*.go server/
```
Expected: no matches.
**Coverage note.** The spec's phase-1 test 6 exercises this path end to end, which needs a working identity provider and is not reproducible in the container harness Task 7 uses. It is verified here by inspection and by the greps in Step 3 instead: the lookup is scoped by `instanceID`, which comes from `ConsumeStateInstance` and not from user input, and the deleted guard was the only other consumer of the unscoped helper. The first real OIDC sign-in after deployment is the confirming evidence — check that an existing SSO user still lands in their own instance before considering this closed.
@@ -541,11 +569,13 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 6: Remove admin's unscoped control-plane login
**Files:**
- Delete: `admin/internal/auth/cloud.go`
- Modify: `admin/internal/api/routes.go:30`, `admin/internal/api/routes.go:50-52`
- Modify: `admin/internal/api/staff.go`
**Interfaces:**
- Consumes: `auth.CreateCustomerUser(ctx, accountID, email, password string) error` from `admin/internal/auth/customer.go:32`.
- Produces: `POST /api/staff/accounts/:id/users`.
@@ -635,23 +665,29 @@ grep -n '"strings"' admin/internal/api/staff.go
- [ ] **Step 5: Confirm the build is green and nothing still references the deleted handler**
Run:
```sh
grep -rn "HandleCloudLogin" --include=*.go .
```
Expected: no matches.
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output. If `sharedmodels` is now an unused import in some file, remove that import line.
- [ ] **Step 6: Confirm admin has no unscoped control-plane user lookup left**
Run:
```sh
grep -rn 'db.Control("users")' --include=*.go admin/
```
Expected: no matches.
- [ ] **Step 7: Commit**
@@ -677,9 +713,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 7: Documentation and end-to-end verification
**Files:**
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: everything above.
- Produces: nothing.
@@ -691,7 +729,7 @@ In the **Auth and Orgs** section, replace the paragraph beginning "Unique indexe
```markdown
Unique indexes are a **security property**, not an optimisation. `users` is
unique on `(instance_id, email)` — one address is one user *within* an instance,
unique on `(instance_id, email)` — one address is one user _within_ an instance,
and the same address may hold a user in several instances, because an account's
people are projected into each instance they are granted. This is sufficient only
because **every lookup by email is scoped by instance**; there is deliberately no
@@ -718,6 +756,7 @@ In the **MongoDB Collections** notes, add:
MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:test .
MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:test .
```
Expected: two image IDs. A "missing go.sum entry" failure here means `go mod tidy` was run in workspace mode.
- [ ] **Step 3: Start a scratch Mongo and Redis, and seed the OLD index**
@@ -732,6 +771,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'db.users.createIndex({email:1},{unique:true}); db.getCollection("users").getIndexes().map(i=>i.name)'
```
Expected: output includes `email_1`. This reproduces a database that predates the change.
- [ ] **Step 4: Boot the server and confirm the swap**
@@ -747,6 +787,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'db.getCollection("users").getIndexes().map(i=>({name:i.name,key:i.key,unique:i.unique}))'
```
Expected: `instance_email_unique` present with key `{instance_id:1, email:1}` and `unique:true`; **no `email_1`**.
- [ ] **Step 5: Confirm a second boot is a no-op**
@@ -756,6 +797,7 @@ MSYS_NO_PATHCONV=1 docker restart vantage-idx-server
sleep 5
MSYS_NO_PATHCONV=1 docker logs vantage-idx-server 2>&1 | grep -i "index\|fatal" | tail -5
```
Expected: no index error and no fatal. The drop must tolerate the index already being gone.
- [ ] **Step 6: Bootstrap instance A and capture its user's password hash**
@@ -765,6 +807,7 @@ curl -s -X POST http://localhost:8091/auth/bootstrap \
-H 'Content-Type: application/json' \
-d '{"instance_name":"Alpha","email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: JSON with `instance_id` and `"slug":"alpha"`.
```sh
@@ -772,6 +815,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'const u=db.users.findOne({email:"shared@example.com"}); print(u.user_id); print(u.password_hash)'
```
Expected: a UUID and a bcrypt hash. Keep both.
- [ ] **Step 7: Create instance B with the SAME address — the case that was impossible before**
@@ -795,6 +839,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
print("users with that address: " + db.users.countDocuments({email:"shared@example.com"}));
'
```
Expected: `users with that address: 2`. Under the old global index this insert would have failed with E11000 — that failure is exactly what this phase removes.
- [ ] **Step 8: Confirm the compound index still refuses a duplicate WITHIN one instance**
@@ -810,6 +855,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
} catch (e) { print("refused as expected: " + (e.code === 11000)); }
'
```
Expected: `refused as expected: true`. A `FAIL` line means the compound index is missing or not unique.
- [ ] **Step 9: Confirm each host signs in to its own instance — the whole point of the phase**
@@ -820,6 +866,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: alpha.vantage.test' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8091/auth/me -H 'Host: alpha.vantage.test' -b /tmp/alpha.jar
```
Expected: `{"ok":true}`, then a body whose `instance` is **Alpha**.
```sh
@@ -828,6 +875,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: beta.vantage.test' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8091/auth/me -H 'Host: beta.vantage.test' -b /tmp/beta.jar
```
Expected: `{"ok":true}`, then a body whose `instance` is **Beta**, with a different `instance_id` from the Alpha response.
Two sign-ins, one address, one password, two different tenants. If both responses name the same instance, the lookup is not scoped.
@@ -839,6 +887,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/logi
-H 'Host: vantage.test' -H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: `400`. Then read the message:
```sh
@@ -846,6 +895,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
-H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: an error naming both the instance count and the host. A `200` here would mean an arbitrary tenant was chosen.
- [ ] **Step 11: Confirm a wrong password still fails, on the right host**
@@ -855,6 +905,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/logi
-H 'Host: alpha.vantage.test' -H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"wrongwrongwrong"}'
```
Expected: `401`.
- [ ] **Step 12: Confirm a single-instance deployment still signs in on a bare host**
@@ -864,6 +915,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'const b=db.instances.findOne({slug:"beta"}); db.users.deleteMany({instance_id:b.instance_id}); db.instances.deleteOne({slug:"beta"}); print(db.instances.countDocuments({}))'
```
Expected: `1`.
```sh
@@ -871,6 +923,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
-H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: `{"ok":true}`. This is the self-hosted path, and it must keep working.
- [ ] **Step 13: Confirm admin boots and its login route still works**
@@ -887,6 +940,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-admin -p 8093:8083 \
sleep 5
curl -s http://localhost:8093/healthz
```
Expected: `{"ok":true}`. A boot failure here most likely means an unused-import error that `go build` caught but the image build did not, or a missing env var.
```sh
@@ -894,6 +948,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8093/auth/logi
-H 'Content-Type: application/json' \
-d '{"email":"nobody@example.com","password":"hunter2hunter2"}'
```
Expected: `401`, not `500`. This proves `/auth/login` is wired to a live handler after `HandleCloudLogin` was deleted.
- [ ] **Step 14: Tear the scratch environment down**
@@ -12,13 +12,13 @@
- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
```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" \
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" \
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".
- **Admin's control-plane writes stay confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry`. **Do not widen `inject`** — the password reconciliation pass added by this phase lives in its own package, `hqsync`, precisely so `inject` stays licence-only.
@@ -47,59 +47,61 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
**Created:**
| Path | Responsibility |
|---|---|
| `admin/internal/models/members.go` | `InstanceMember`, account-role constants |
| `admin/internal/db/backfill.go` | one-shot boot backfill: account roles, and members for phase-2 instances |
| `admin/internal/api/people.go` | account people: list, invite, role, delete, password |
| `admin/internal/api/members.go` | instance members: list, grant, role, revoke |
| `admin/internal/hqsync/hqsync.go` | the 15-minute password repair pass |
| `adminsite/app/(customer)/users/page.tsx` | the account's people |
| `adminsite/app/(customer)/users/InvitePanel.tsx` | invite form + people table |
| `adminsite/app/(customer)/settings/page.tsx` | change password |
| `adminsite/components/MembersPanel.tsx` | who is on one instance |
| `adminsite/app/accept-invite/page.tsx` | an invitee sets their own password |
| Path | Responsibility |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| `admin/internal/models/members.go` | `InstanceMember`, account-role constants |
| `admin/internal/db/backfill.go` | one-shot boot backfill: account roles, and members for phase-2 instances |
| `admin/internal/api/people.go` | account people: list, invite, role, delete, password |
| `admin/internal/api/members.go` | instance members: list, grant, role, revoke |
| `admin/internal/hqsync/hqsync.go` | the 15-minute password repair pass |
| `adminsite/app/(customer)/users/page.tsx` | the account's people |
| `adminsite/app/(customer)/users/InvitePanel.tsx` | invite form + people table |
| `adminsite/app/(customer)/settings/page.tsx` | change password |
| `adminsite/components/MembersPanel.tsx` | who is on one instance |
| `adminsite/app/accept-invite/page.tsx` | an invitee sets their own password |
**Modified:**
| Path | Change |
|---|---|
| `admin/internal/models/models.go` | `CustomerUser.AccountRole`, `HQSyncFailedAt` |
| `admin/internal/db/db.go` | `instance_members` indexes |
| `admin/internal/auth/customer.go` | `CreateInvitedUser`, `HandleAcceptInvite`, verify peek, role on signup |
| `admin/internal/auth/middleware.go` | `RequireAccountRole`, `CurrentUser` |
| `admin/internal/cloudprov/cloudprov.go` | `GrantUser`, `RevokeUser`, `SetMemberRole`, `CountOtherOwners`, `SetPasswordHash`, `ProjectedUsers` |
| `admin/internal/api/customer.go` | `createInstance` writes the owner's `instance_members` row |
| `admin/internal/api/routes.go` | the ten new customer routes |
| `admin/internal/mail/mail.go` | `SendInvite` |
| `admin/cmd/main.go` | run the backfill, start `hqsync` |
| `server/internal/services/users.go` | `ErrHQManaged` on role change and delete |
| `server/internal/api/instance.go` | map `ErrHQManaged` to 409 |
| `web/lib/api.ts` | `auth_source: "hq"`, `hq_user_id` on `InstanceUser` |
| `web/app/(app)/settings/instance/page.tsx` | read-only treatment for `hq` rows |
| `web/Dockerfile`, `.gitea/workflows/server-deploy.yml` | `NEXT_PUBLIC_HQ_URL` |
| `adminsite/lib/api.ts` | member/people/password calls, `AccountRole` |
| `adminsite/app/(customer)/layout.tsx` | People and Settings nav |
| `adminsite/app/(customer)/instances/[id]/page.tsx` | mount `MembersPanel` |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the password copy is now a lie; fix it |
| `adminsite/app/verify/page.tsx` | route an invite token to `/accept-invite` |
| `CLAUDE.md` | membership model, the new routes, `NEXT_PUBLIC_HQ_URL` |
| Path | Change |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `admin/internal/models/models.go` | `CustomerUser.AccountRole`, `HQSyncFailedAt` |
| `admin/internal/db/db.go` | `instance_members` indexes |
| `admin/internal/auth/customer.go` | `CreateInvitedUser`, `HandleAcceptInvite`, verify peek, role on signup |
| `admin/internal/auth/middleware.go` | `RequireAccountRole`, `CurrentUser` |
| `admin/internal/cloudprov/cloudprov.go` | `GrantUser`, `RevokeUser`, `SetMemberRole`, `CountOtherOwners`, `SetPasswordHash`, `ProjectedUsers` |
| `admin/internal/api/customer.go` | `createInstance` writes the owner's `instance_members` row |
| `admin/internal/api/routes.go` | the ten new customer routes |
| `admin/internal/mail/mail.go` | `SendInvite` |
| `admin/cmd/main.go` | run the backfill, start `hqsync` |
| `server/internal/services/users.go` | `ErrHQManaged` on role change and delete |
| `server/internal/api/instance.go` | map `ErrHQManaged` to 409 |
| `web/lib/api.ts` | `auth_source: "hq"`, `hq_user_id` on `InstanceUser` |
| `web/app/(app)/settings/instance/page.tsx` | read-only treatment for `hq` rows |
| `web/Dockerfile`, `.gitea/workflows/server-deploy.yml` | `NEXT_PUBLIC_HQ_URL` |
| `adminsite/lib/api.ts` | member/people/password calls, `AccountRole` |
| `adminsite/app/(customer)/layout.tsx` | People and Settings nav |
| `adminsite/app/(customer)/instances/[id]/page.tsx` | mount `MembersPanel` |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the password copy is now a lie; fix it |
| `adminsite/app/verify/page.tsx` | route an invite token to `/accept-invite` |
| `CLAUDE.md` | membership model, the new routes, `NEXT_PUBLIC_HQ_URL` |
---
### Task 1: The membership model, its indexes, and the phase-2 backfill
**Files:**
- Create: `admin/internal/models/members.go`, `admin/internal/db/backfill.go`
- Modify: `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`
**Interfaces:**
- Consumes: `db.Admin`, `db.Control`, `shared/models.RoleOwner`, `shared/license.DeploymentCloud`.
- Produces:
- `models.AccountRoleOwner|AccountRoleAdmin|AccountRoleMember string`, `models.ValidAccountRole(string) bool`, `models.AccountRoleAtLeastAdmin(string) bool`
- `models.InstanceMember` struct
- `models.CustomerUser.AccountRole string`, `models.CustomerUser.HQSyncFailedAt *time.Time`
- `db.Backfill(ctx context.Context) error`
- `models.AccountRoleOwner|AccountRoleAdmin|AccountRoleMember string`, `models.ValidAccountRole(string) bool`, `models.AccountRoleAtLeastAdmin(string) bool`
- `models.InstanceMember` struct
- `models.CustomerUser.AccountRole string`, `models.CustomerUser.HQSyncFailedAt *time.Time`
- `db.Backfill(ctx context.Context) error`
- [ ] **Step 1: Add the account-role constants and the member document**
@@ -235,9 +237,9 @@ import (
"time"
"github.com/google/uuid"
"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/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"
)
@@ -371,17 +373,19 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 2: `cloudprov` learns to project, revoke and repair
**Files:**
- Modify: `admin/internal/cloudprov/cloudprov.go`
**Interfaces:**
- Consumes: `db.Control`, `db.ControlDB`, `shared/provision.CreateUserWithHash`, `shared/models`.
- Produces:
- `cloudprov.GrantUser(ctx, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error)`
- `cloudprov.RevokeUser(ctx, instanceID, hqUserID string) error`
- `cloudprov.SetMemberRole(ctx, instanceID, hqUserID, role string) error`
- `cloudprov.CountOtherOwners(ctx, instanceID, exceptHQUserID string) (int64, error)`
- `cloudprov.SetPasswordHash(ctx, hqUserID, hash string) (int64, error)`
- `cloudprov.ProjectedUsers(ctx, hqUserID string) ([]sharedmodels.User, error)`
- `cloudprov.GrantUser(ctx, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error)`
- `cloudprov.RevokeUser(ctx, instanceID, hqUserID string) error`
- `cloudprov.SetMemberRole(ctx, instanceID, hqUserID, role string) error`
- `cloudprov.CountOtherOwners(ctx, instanceID, exceptHQUserID string) (int64, error)`
- `cloudprov.SetPasswordHash(ctx, hqUserID, hash string) (int64, error)`
- `cloudprov.ProjectedUsers(ctx, hqUserID string) ([]sharedmodels.User, error)`
- [ ] **Step 1: Append the projection functions**
@@ -497,10 +501,12 @@ Expected: no output.
Run: `grep -n 'db.Control("' admin/internal/cloudprov/cloudprov.go admin/internal/inject/inject.go | grep -o 'db.Control("[a-z_]*")' | sort -u`
Expected exactly two lines:
```
db.Control("instances")
db.Control("users")
```
If a third collection appears, stop — that is the design change the package comment forbids.
- [ ] **Step 4: Commit**
@@ -522,22 +528,24 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 3: The account's people — invite, accept, role, remove
**Files:**
- Create: `admin/internal/api/people.go`
- Modify: `admin/internal/auth/customer.go`, `admin/internal/auth/middleware.go`, `admin/internal/mail/mail.go`, `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `models.AccountRole*`, `models.InstanceMember`, `cloudprov.RevokeUser`, `cloudprov.CountOtherOwners`, `auth.CreateCustomerUser`.
- Produces:
- `auth.CurrentUser(c *gin.Context) *models.CustomerUser`
- `auth.RequireAccountRole(roles ...string) gin.HandlerFunc`
- `auth.CreateInvitedUser(ctx context.Context, accountID, email, accountRole string) error`
- `auth.HandleAcceptInvite(c *gin.Context)`
- `mail.SendInvite(to, accountName string) error`
- routes: `GET,POST /api/account/users`, `PUT /api/account/users/:id/role`, `DELETE /api/account/users/:id`, `POST /auth/accept-invite`
- `auth.CurrentUser(c *gin.Context) *models.CustomerUser`
- `auth.RequireAccountRole(roles ...string) gin.HandlerFunc`
- `auth.CreateInvitedUser(ctx context.Context, accountID, email, accountRole string) error`
- `auth.HandleAcceptInvite(c *gin.Context)`
- `mail.SendInvite(to, accountName string) error`
- routes: `GET,POST /api/account/users`, `PUT /api/account/users/:id/role`, `DELETE /api/account/users/:id`, `POST /auth/accept-invite`
- [ ] **Step 1: Add `CurrentUser` and the role guard**
In `admin/internal/auth/middleware.go`, add the import block entries `"github.com/mrhid6/vantage/admin/internal/db"`, `"github.com/mrhid6/vantage/admin/internal/models"` and `"go.mongodb.org/mongo-driver/v2/bson"`, then append:
In `admin/internal/auth/middleware.go`, add the import block entries `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` and `"go.mongodb.org/mongo-driver/v2/bson"`, then append:
```go
const ctxCustomerUser = "admin_customer_user"
@@ -616,6 +624,7 @@ and
```
Update the two existing callers:
- in `HandleSignup`: `CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner)`
- in `admin/internal/api/staff.go`, `staffCreateAccountUser`: `auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner)` — staff attaching a legacy customer are attaching the person who runs that account.
@@ -784,12 +793,12 @@ import (
"strings"
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1046,7 +1055,7 @@ Add `"fmt"` to the imports.
- [ ] **Step 7: Mount the routes**
In `admin/internal/api/routes.go`, add `"github.com/mrhid6/vantage/admin/internal/models"` to the imports, add the unauthenticated invite route after `r.POST("/auth/signup", auth.HandleSignup)`:
In `admin/internal/api/routes.go`, add `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` to the imports, add the unauthenticated invite route after `r.POST("/auth/signup", auth.HandleSignup)`:
```go
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
@@ -1118,10 +1127,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 4: Instance members — grant, role, revoke
**Files:**
- Create: `admin/internal/api/members.go`
- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `ownedInstance`, `cloudprov.GrantUser/RevokeUser/SetMemberRole/CountOtherOwners`, `models.InstanceMember`.
- Produces: routes `GET,POST /api/instances/:id/members`, `PUT /api/instances/:id/members/:uid/role`, `DELETE /api/instances/:id/members/:uid`. `:uid` is the **customer_users.user_id**, not the control-plane user_id — the portal never has to know the projected ID.
@@ -1150,7 +1161,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
}
```
Add `"github.com/google/uuid"` and `sharedmodels "github.com/mrhid6/vantage/shared/models"` to that file's imports.
Add `"github.com/google/uuid"` and `sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"` to that file's imports.
- [ ] **Step 2: Write the member handlers**
@@ -1166,14 +1177,14 @@ 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/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"github.com/mrhid6/vantage/shared/provision"
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/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"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"errors"
)
@@ -1442,9 +1453,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 5: One password, every instance
**Files:**
- Modify: `admin/internal/api/people.go`, `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `cloudprov.SetPasswordHash`, `auth.BcryptCost`.
- Produces: route `PUT /api/account/password`, handler `changeAccountPassword`.
@@ -1565,10 +1578,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 6: `hqsync` — the fifteen-minute repair
**Files:**
- Create: `admin/internal/hqsync/hqsync.go`
- Modify: `admin/cmd/main.go`
**Interfaces:**
- Consumes: `cloudprov.ProjectedUsers`, `cloudprov.SetPasswordHash`, `db.Admin`.
- Produces: `hqsync.Reconcile(ctx) (checked, repaired int, err error)`, `hqsync.Start(ctx context.Context)`.
@@ -1592,9 +1607,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"
)
@@ -1697,7 +1712,7 @@ func runOnce(ctx context.Context) {
- [ ] **Step 2: Start it at boot**
In `admin/cmd/main.go`, add the import `"github.com/mrhid6/vantage/admin/internal/hqsync"` and, immediately after `inject.StartReconciler(reconcileCtx)`:
In `admin/cmd/main.go`, add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"` and, immediately after `inject.StartReconciler(reconcileCtx)`:
```go
hqsync.Start(reconcileCtx)
@@ -1734,9 +1749,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 7: The control plane refuses to edit what HQ owns
**Files:**
- Modify: `server/internal/services/users.go`, `server/internal/api/instance.go`
**Interfaces:**
- Produces: `services.ErrHQManaged error`; `UpdateUserRole` and `DeleteUser` return it for `auth_source == "hq"`; the API maps it to 409.
- [ ] **Step 1: Add the error and the two guards**
@@ -1807,10 +1824,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 8: The HQ portal — people, members, password
**Files:**
- Create: `adminsite/app/(customer)/users/page.tsx`, `adminsite/app/(customer)/users/InvitePanel.tsx`, `adminsite/app/(customer)/settings/page.tsx`, `adminsite/app/accept-invite/page.tsx`, `adminsite/components/MembersPanel.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(customer)/instances/[id]/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`, `adminsite/app/verify/page.tsx`
**Interfaces:**
- Consumes: every route from Tasks 35.
- Produces: `AccountRole`, `InstanceRole`, `AccountUser`, `InstanceMember` types; `api.accountUsers`, `api.invite`, `api.setAccountRole`, `api.removeAccountUser`, `api.changePassword`, `api.acceptInvite`, `api.members`, `api.grantMember`, `api.setMemberRole`, `api.revokeMember`.
@@ -1819,10 +1838,9 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
In `adminsite/lib/api.ts`, add after the `post` helper:
```ts
const put = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
const put = <T>(path: string, payload?: unknown) => req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
const del = <T>(path: string) => req<T>(path, { method: "DELETE" });
```
Add to the types section:
@@ -1951,8 +1969,7 @@ export function InvitePanel() {
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const invite = useMutation({
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
@@ -1982,11 +1999,7 @@ export function InvitePanel() {
return (
<div className="grid gap-6">
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
{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>
@@ -2025,26 +2038,17 @@ export function InvitePanel() {
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
<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-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.`,
)
)
remove.mutate(u.user_id);
if (confirm(`Remove ${u.email}? They lose access to every instance on this account.`)) remove.mutate(u.user_id);
}}
>
Remove
@@ -2076,14 +2080,8 @@ export function InvitePanel() {
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"
>
<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}
@@ -2091,10 +2089,7 @@ export function InvitePanel() {
))}
</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>
<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>
@@ -2115,10 +2110,7 @@ export default function UsersPage() {
<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>
<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>
<InvitePanel />
</div>
@@ -2159,8 +2151,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const grant = useMutation({
mutationFn: () => api.grantMember(instanceId, selected, role),
@@ -2172,8 +2163,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
onError: fail,
});
const changeRole = useMutation({
mutationFn: (v: { uid: string; role: InstanceRole }) =>
api.setMemberRole(instanceId, v.uid, v.role),
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
onSuccess: refresh,
onError: fail,
});
@@ -2187,29 +2177,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const canManage = myRole === "owner" || myRole === "admin";
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter(
(p) => !granted.has(p.user_id) && p.verified_at,
);
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
return (
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
<div className="grid gap-1">
<h2 className="text-xl">Who can sign in</h2>
<p className="text-[0.82rem] text-ink-2">
Each person here has a real user inside this instance and signs in with their
Vantage HQ password.
</p>
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
<ul className="grid gap-2">
{(members.data ?? []).map((m) => (
<li
key={m.member_id}
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
>
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
<span>{m.email}</span>
<span className="flex items-center gap-3">
{canManage ? (
@@ -2237,8 +2219,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`))
revoke.mutate(m.customer_user_id);
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
}}
>
Remove
@@ -2247,9 +2228,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</li>
)}
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
</ul>
{canManage && (
@@ -2262,14 +2241,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
}}
>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Add someone
</span>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
<option value="">Choose a person…</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
@@ -2279,14 +2252,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Role here
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as InstanceRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
@@ -2302,8 +2269,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
{canManage && pending > 0 && (
<p className="text-[0.82rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
yet and cannot be added until they do.
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
</p>
)}
</section>
@@ -2316,14 +2282,13 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
In `adminsite/app/(customer)/instances/[id]/page.tsx`, add the import `import { MembersPanel } from "@/components/MembersPanel";` and, immediately before the closing `</div>` of the returned tree:
```tsx
{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>
)}
{
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>
);
}
```
- [ ] **Step 6: The settings page**
@@ -2350,24 +2315,16 @@ export default function SettingsPage() {
onSuccess: (res) => {
setCurrent("");
setNext("");
setDone(
res.propagation_pending
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
: "Password changed everywhere.",
);
setDone(res.propagation_pending ? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes." : "Password changed everywhere.");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
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>
<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>
<form
@@ -2379,14 +2336,7 @@ export default function SettingsPage() {
change.mutate();
}}
>
<Field
label="Current password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
<Field label="Current password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
<Field
label="New password"
type="password"
@@ -2432,8 +2382,7 @@ function AcceptForm() {
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
@@ -2458,10 +2407,7 @@ function AcceptForm() {
}}
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<p className="text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to. Nobody who invited you can see it.</p>
<Field
label="New password"
type="password"
@@ -2496,10 +2442,10 @@ export default function AcceptInvitePage() {
Read `adminsite/app/verify/page.tsx`, find where it renders success from `api.verify(token)`, and add a branch before it: when the response has `needs_password`, redirect with
```tsx
if (data?.needs_password) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
return null;
}
if (data?.needs_password) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
return null;
}
```
using `useRouter` from `next/navigation`. This exists because an invitation and a verification link are the same shape, and someone will paste one into the other.
@@ -2520,19 +2466,18 @@ In `adminsite/app/(customer)/layout.tsx`, add two links inside the nav after the
In `adminsite/app/(customer)/instances/new/CreateForm.tsx`, phase 2's copy is now false — the password does propagate. Replace that paragraph with:
```tsx
<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>
<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>
```
- [ ] **Step 10: Build the site**
Run:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful build listing `/users`, `/settings` and `/accept-invite` among the routes.
- [ ] **Step 11: Confirm no hex colours crept in**
@@ -2561,9 +2506,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 9: `web/` shows what it does not own
**Files:**
- Modify: `web/lib/api.ts`, `web/app/(app)/settings/instance/page.tsx`, `web/Dockerfile`, `.gitea/workflows/server-deploy.yml`
**Interfaces:**
- Consumes: `InstanceUser.auth_source === "hq"` from the existing `/instance/users` response.
- Produces: `NEXT_PUBLIC_HQ_URL` at build time; locked rows in `MembersCard`.
@@ -2591,65 +2538,58 @@ export interface InstanceUser {
In `web/app/(app)/settings/instance/page.tsx`, inside `MembersCard`, add above the `return`:
```tsx
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
```
Then, in the row map, replace
```tsx
const locked = isSelf || (u.role === "owner" && !isOwner);
const locked = isSelf || (u.role === "owner" && !isOwner);
```
with
```tsx
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
```
Replace the sign-in cell with:
```tsx
<Td>
<Badge variant="neutral">
{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}
</Badge>
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
```
And replace the actions cell with:
```tsx
<Td className="text-right">
{managedByHQ ? (
hqUrl ? (
<a
href={hqUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-text-secondary underline"
>
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
<Td className="text-right">
{managedByHQ ? (
hqUrl ? (
<a href={hqUrl} target="_blank" rel="noreferrer" className="text-xs text-text-secondary underline">
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
```
- [ ] **Step 3: Wire the build variable**
@@ -2666,16 +2606,18 @@ Empty default on purpose: a self-hosted install has no HQ portal, and the label
In `.gitea/workflows/server-deploy.yml`, in the web image step, add the build arg:
```yaml
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
```
- [ ] **Step 4: Build**
Run:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/web":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful build.
- [ ] **Step 5: Commit**
@@ -2697,6 +2639,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 10: Documentation and the end-to-end proof
**Files:**
- Modify: `CLAUDE.md`
This task proves the phase. With no test suite, this transcript is the only evidence — run it in full. Numbers in brackets are the spec's phase-3 test list.
@@ -2708,6 +2651,7 @@ cd deploy
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --build admin adminsite server web
docker compose logs -f admin | head -40
```
Expected in the log: `connected: admin=… control=…`, then any `backfill:` lines, then `admin listening on :8083`. A `backfill: FATAL` stops the phase — read it before continuing.
- [ ] **Step 2: Confirm the backfill did its two jobs**
@@ -2718,6 +2662,7 @@ docker compose exec -T mongo mongosh --quiet vantage_admin --eval '
print("members: " + db.instance_members.countDocuments({}));
print("cloud instances: " + db.admin_instances.countDocuments({deployment:"cloud",status:{$ne:"deleted"}}));'
```
Expected: `no role: 0`, and `members` equal to the cloud-instance count (or lower only where the log said an instance had no hq-sourced owner).
Restart admin and run the same query. Expected: identical numbers — the backfill is idempotent.
@@ -2730,6 +2675,7 @@ Sign in as an account owner in the portal, invite `tester@example.com` as a memb
curl -sk -b cookies.txt -X POST https://vantage-hq.hostxtra.co.uk/api/instances/$INSTANCE/members \
-H 'Content-Type: application/json' -d '{"user_id":"'$TESTER_USER_ID'","role":"member"}'
```
Expected: HTTP 409, `{"error":"they have not accepted their invitation yet"}`, and `db.users.countDocuments({instance_id:"…",email:"tester@example.com"})` still `0`.
- [ ] **Step 4: Accept, grant, and sign in to the instance [24]**
@@ -2741,6 +2687,7 @@ curl -sk -X POST https://<slug>.vantage.hostxtra.co.uk/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"tester@example.com","password":"<the password they chose>"}'
```
Expected: HTTP 200. This is the payoff — the HQ password authenticates against the instance with no call to admin.
- [ ] **Step 5: Two instances, two roles [25]**
@@ -2751,6 +2698,7 @@ Create a second Free instance is refused (one per account), so use a second acco
docker compose exec -T mongo mongosh --quiet vantage --eval '
db.users.find({hq_user_id:"'$TESTER_USER_ID'"},{instance_id:1,role:1,_id:0}).forEach(printjson)'
```
Expected: two rows, different `instance_id`, roles `member` and `admin`.
- [ ] **Step 6: Revoke removes the login but only there [26]**
@@ -2769,6 +2717,7 @@ Against a linked self-hosted instance:
curl -sk -b cookies.txt -X POST https://vantage-hq.hostxtra.co.uk/api/instances/$SELFHOSTED/members \
-H 'Content-Type: application/json' -d '{"user_id":"'$TESTER_USER_ID'","role":"member"}'
```
Expected: HTTP 400 with the "manages its own users" message, and `db.instance_members.countDocuments({instance_id:"$SELFHOSTED"})` is `0`.
- [ ] **Step 9: A member can do none of it [29]**
@@ -2788,6 +2737,7 @@ docker compose exec -T mongo mongosh --quiet vantage --eval '
docker compose restart admin
docker compose logs admin | grep hqsync
```
Expected: `hqsync: repaired 1 projected user(s) for tester@example.com` on the boot pass, and the hash matches `customer_users` again.
- [ ] **Step 11: The instance API refuses, not just the UI [31]**
@@ -2799,6 +2749,7 @@ curl -sk -b app_cookies.txt -X PUT https://<slug>.vantage.hostxtra.co.uk/api/ins
-H 'Content-Type: application/json' -d '{"role":"owner"}'
curl -sk -b app_cookies.txt -X DELETE https://<slug>.vantage.hostxtra.co.uk/api/instance/users/$HQ_CONTROL_USER_ID
```
Expected: HTTP 409 from both, with the "managed in Vantage HQ" message. **This is the check that matters most** — the UI lock is decoration; this is the boundary.
- [ ] **Step 12: Update `CLAUDE.md`**
@@ -2830,7 +2781,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no
runtime dependency on admin**. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.
`instance_members` in admin's database is only admin's *index* of those
`instance_members` in admin's database is only admin's _index_ of those
projections; the control-plane row is the access. That is why a failed
`instance_members` insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.
@@ -2865,7 +2816,7 @@ and add `POST /auth/accept-invite` to the unauthenticated block.
In **MongoDB Collections**, note admin's `instance_members` alongside the existing admin-side collections description, and in the CI **Secrets / variables** table add:
```markdown
| `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. |
| `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. |
```
- [ ] **Step 13: Commit**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,928 @@
# Control plane mobile responsiveness — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `web/` (the Vantage control plane UI) usable on a phone — the sidebar becomes a hamburger-driven offcanvas below 1024px, tables become card stacks below 640px, and every fixed desktop layout collapses.
**Architecture:** A new client component `AppShell` owns the responsive chrome so `app/(app)/layout.tsx` stays a server component. `Sidebar.tsx` splits into a shared `SidebarContent` plus two containers (permanent aside, offcanvas drawer) so the nav exists in exactly one copy. The table card-stack lives in the `ui/Table.tsx` primitives via Tailwind `max-sm:` variants, so pages keep one markup tree and opt in with a `label` prop per cell.
**Tech Stack:** Next.js 16 (App Router), React 18, Tailwind 3.4, `clsx`. **No new dependencies.**
## Global Constraints
- **Scope is `web/` only.** Do not touch `site/`, `adminsite/`, `server/`, `admin/` or any Go code.
- **No hex colours anywhere.** Tailwind maps `var(--…)` tokens only. Use `bg-surface`, `border-border`, `text-text-secondary` etc. A literal `#` in a class is a defect. (`bg-black/60` is the one existing exception, already used by `Modal.tsx` for its backdrop — reuse it, do not introduce others.)
- **Breakpoints:** sidebar collapses below `lg` (1024px). Tables card-stack below `sm` (640px). Do not invent other breakpoints.
- **No new dependencies.** No headless-ui, no framer-motion.
- **Presentation only.** No API, route, query-key or data-shape changes.
- **Radius:** `rounded`, `rounded-lg`, `rounded-md` and `rounded-xl` all resolve to 46px via `tailwind.config.ts`. Prefer `rounded` in new code.
- Use `dvh`, not `vh`, for any new viewport-height value — mobile browser chrome makes `vh` overshoot.
- Indentation follows the file you are editing. `web/` is mixed: some files use 4 spaces (`Sidebar.tsx`, `keys/page.tsx`), others 2 (`servers/page.tsx`, `ui/*`). Match the file, do not reformat it.
- **There is no test framework in this repo.** No jest, no vitest, no playwright. Verification is `npx next lint`, `npx next build`, and targeted `grep` audits. Do not add a test framework.
- Run all commands from `d:\Development\Websites\vantage\web`.
---
### Task 1: Responsive table primitives
The card stack goes in the primitives, not the pages. Six pages render tables; giving each one a second markup tree would double the markup and drift on the first edit.
**Files:**
- Modify: `web/components/ui/Table.tsx` (whole file)
**Interfaces:**
- Consumes: nothing.
- Produces: `Td` gains an optional prop `label?: string`. Below `sm`, a `Td` with a `label` renders `<span>{label}</span>` before its children; a `Td` without one renders children alone, right-aligned. `Table`, `Thead`, `Tbody`, `Tr`, `Th` keep their existing signatures. Task 4 consumes `label`.
- [ ] **Step 1: Rewrite `web/components/ui/Table.tsx`**
Replace the entire file with:
```tsx
import { clsx } from "clsx";
import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
/*
* Below sm the table stops being a table: the head is hidden, each row becomes
* a bordered card and each cell becomes a label/value pair. That lives here
* rather than in the six pages that render tables — two markup trees per page
* would drift apart on the first edit, and every one of those trees would mean
* the same thing.
*
* The mobile label uses Th's exact keyed-label idiom (mono, small, widely
* tracked, dimmed) because a key beside a value on a phone is the same device
* as a column head above it on a desktop.
*/
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table
className={clsx("w-full border-collapse text-sm max-sm:block", className)}
{...props}
>
{children}
</table>
</div>
);
}
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
{children}
</thead>
);
}
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody
className={clsx(
"divide-y divide-border",
"max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3",
className
)}
{...props}
>
{children}
</tbody>
);
}
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={clsx(
"transition-colors hover:bg-surface-2/50",
"max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3",
className
)}
{...props}
>
{children}
</tr>
);
}
export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={clsx(
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
// A column head is a key, not prose.
// text-secondary, not tertiary: a column head is how you navigate the
// table, and tertiary lands under 4.5:1 at this size.
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
className
)}
{...props}
>
{children}
</th>
);
}
interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> {
/**
* The column head this cell belongs to, shown beside the value below sm
* where the real head is hidden. Omit on a trailing action cell — an action
* needs no key, and the button then sits alone on its own row in the card.
*/
label?: string;
}
export function Td({ className, label, children, ...props }: TdProps) {
return (
<td
className={clsx(
"px-4 py-3 text-text-primary",
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
// Exactly one justify class — clsx picks it. Emitting both and relying
// on string order would not work: Tailwind's output order decides which
// of two same-property utilities wins, not the order in this array.
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
className
)}
{...props}
>
{label && (
<span className="hidden font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">
{label}
</span>
)}
{children}
</td>
);
}
```
- [ ] **Step 2: Verify it compiles and lints**
```bash
npx tsc --noEmit
npx next lint
```
Expected: both clean. `tsc` may take ~30s. If `tsc --noEmit` errors on pre-existing issues unrelated to `Table.tsx`, note them and move on — only new errors matter.
- [ ] **Step 3: Commit**
```bash
git add web/components/ui/Table.tsx
git commit -m "feat(web): card-stack tables below sm"
```
---
### Task 2: Offcanvas sidebar
**Files:**
- Modify: `web/components/Sidebar.tsx` (whole file)
- Create: `web/components/AppShell.tsx`
- Modify: `web/app/(app)/layout.tsx` (whole file)
**Interfaces:**
- Consumes: `useAuth()` from `@/components/AuthProvider` returning `{ user, instance, isAdmin }`; `auth.logout()` from `@/lib/api`; `Logo` from `@/components/Logo`.
- Produces:
- `Sidebar.tsx` exports `SidebarContent({ onNavigate }: { onNavigate?: () => void })`, `Sidebar()` (permanent aside) and `SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void })`.
- `AppShell.tsx` exports `AppShell({ children }: { children: React.ReactNode })`.
- No later task depends on these names.
- [ ] **Step 1: Rewrite `web/components/Sidebar.tsx`**
Keep every icon component and the `navItems` array **exactly as they are** — do not retype the SVG path data, it is long and easy to corrupt. Change only from `export function Sidebar()` (line 135) to the end of the file, replacing it with the following. The file uses 4-space indentation.
```tsx
/** Shared by the permanent aside and the offcanvas drawer — one copy of the nav. */
export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { user, instance, isAdmin } = useAuth();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
return best === null || item.href.length > best.length ? item.href : best;
}, null);
async function handleLogout() {
try {
await auth.logout();
} catch {}
window.location.href = "/login";
}
return (
<>
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-5">
<Logo className="h-8 w-8 text-logo" />
<div className="min-w-0">
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
{instance && (
<span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="shrink-0 border-t border-border px-4 py-3">
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">Vantage v1.0</p>
{user && (
<button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
Logout
</button>
)}
</div>
</div>
</>
);
}
/** The permanent sidebar. Below lg the drawer takes over. */
export function Sidebar() {
return (
<aside className="hidden h-screen w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
<SidebarContent />
</aside>
);
}
/**
* The offcanvas below lg. Always mounted so the slide runs in both directions;
* closed it is inert (invisible + pointer-events-none) rather than unmounted.
*/
export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.focus();
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
return (
<div
className={clsx(
"fixed inset-0 z-50 lg:hidden",
open ? "visible" : "invisible pointer-events-none",
)}
>
<div
aria-hidden="true"
onClick={onClose}
className={clsx(
"absolute inset-0 bg-black/60 transition-opacity duration-200",
open ? "opacity-100" : "opacity-0",
)}
/>
<div
ref={panelRef}
id="app-sidebar-drawer"
role="dialog"
aria-modal="true"
aria-label="Navigation"
tabIndex={-1}
className={clsx(
"absolute inset-y-0 left-0 flex w-72 max-w-[85%] flex-col border-r border-border bg-surface outline-none transition-transform duration-200 ease-out",
open ? "translate-x-0" : "-translate-x-full",
)}
>
<SidebarContent onNavigate={onClose} />
</div>
</div>
);
}
```
Then update the import line at the top of the file (currently line 4) so `useEffect` and `useRef` are available:
```tsx
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
```
- [ ] **Step 2: Create `web/components/AppShell.tsx`**
```tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { LicenseBanner } from "@/components/LicenseBanner";
import { Logo } from "@/components/Logo";
import { Sidebar, SidebarDrawer } from "@/components/Sidebar";
import { useAuth } from "@/components/AuthProvider";
function MenuIcon() {
return (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
/**
* Owns the responsive chrome so app/(app)/layout.tsx can stay a server
* component. Above lg this is the layout it always was; below lg the sidebar
* becomes an offcanvas behind the top bar's hamburger.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const buttonRef = useRef<HTMLButtonElement>(null);
const { instance } = useAuth();
// A drawer that survives navigation would cover the page you just asked for.
useEffect(() => {
setOpen(false);
}, [pathname]);
function close() {
setOpen(false);
buttonRef.current?.focus();
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<SidebarDrawer open={open} onClose={close} />
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-surface px-3 lg:hidden">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(true)}
aria-label="Open navigation"
aria-expanded={open}
aria-controls="app-sidebar-drawer"
className="-ml-1 rounded p-2 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
>
<MenuIcon />
</button>
<Logo className="h-7 w-7 shrink-0 text-logo" />
<div className="min-w-0">
<span className="block text-sm font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
{instance && (
<span className="block truncate font-mono text-[0.62rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
</div>
</header>
<main className="flex min-w-0 flex-1 flex-col">
<LicenseBanner />
{children}
</main>
</div>
</div>
);
}
```
- [ ] **Step 3: Rewrite `web/app/(app)/layout.tsx`**
```tsx
import { AuthProvider } from "@/components/AuthProvider";
import { AppShell } from "@/components/AppShell";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<AppShell>{children}</AppShell>
</AuthProvider>
);
}
```
`LicenseBanner` and `Sidebar` are no longer imported here — `AppShell` renders both.
- [ ] **Step 4: Verify**
```bash
npx tsc --noEmit
npx next lint
npx next build
```
Expected: all three succeed. `next build` is the one that matters — it catches a client component imported into a server component boundary.
- [ ] **Step 5: Sanity-check the scroll container**
Read `web/app/(app)/servers/[id]/console/page.tsx` around line 153 and 168. It uses `h-full`, which now resolves against `<main class="flex min-w-0 flex-1 flex-col">` rather than the old `<main class="flex-1 overflow-y-auto">`. Confirm the console page still has a height to fill; if `h-full` no longer resolves, change those two wrappers to `flex-1` instead. Task 7 revisits this file, so a note is acceptable here if you prefer to fix it there — but write the note down.
- [ ] **Step 6: Commit**
```bash
git add web/components/Sidebar.tsx web/components/AppShell.tsx "web/app/(app)/layout.tsx"
git commit -m "feat(web): offcanvas sidebar with hamburger below lg"
```
---
### Task 3: Page padding and header rows
**Files:**
- Modify: all 21 files under `web/app` and `web/components` containing `p-8`
- Modify: the title-plus-action header rows listed below
**Interfaces:**
- Consumes: nothing. Produces: nothing. Pure class edits.
- [ ] **Step 1: List every occurrence**
```bash
cd web && grep -rn "p-8" app components
```
Expected: 30 occurrences across 21 files.
- [ ] **Step 2: Replace each page-level `p-8` with `p-4 sm:p-6 lg:p-8`**
Apply to every occurrence **except** these two, which Task 6 and Task 7 handle and which need different values:
- `app/(app)/workflows/[id]/page.tsx:331` (the canvas `<main>`) — leave for Task 6.
- `app/(app)/servers/[id]/console/page.tsx:168` — leave for Task 7.
The inline loading states (`<div className="p-8 text-text-secondary">Loading…</div>`) get the same treatment: `className="p-4 text-text-secondary sm:p-6 lg:p-8"`.
Do this file by file with `Edit`. A blind `sed` would also hit `p-8` inside strings or unrelated contexts — check each match.
- [ ] **Step 3: Make title-plus-action header rows stack**
In each of these, change `className="mb-6 flex items-center justify-between"` to
`className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"`:
- `app/(app)/servers/page.tsx:75`
- `app/(app)/keys/page.tsx:112`
- `app/(app)/monitors/page.tsx:35`
- `app/(app)/workflows/page.tsx:32`
- `app/(app)/secrets/page.tsx:105`
- `app/(app)/secrets/[group]/page.tsx:251`
- `app/(app)/settings/notifications/page.tsx:153`
Leave `flex items-center justify-between` rows that are *inside* a card header or a table cell — those hold two small items and are fine at 390px. Only the page-top title/action rows change.
- [ ] **Step 4: Verify no unprefixed `p-8` survives**
```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```
Expected: exactly two lines — the two deferred to Tasks 6 and 7.
- [ ] **Step 5: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add web/app web/components
git commit -m "feat(web): responsive page padding and stacking page headers"
```
---
### Task 4: Label every table cell
**Files:**
- Modify: `web/app/(app)/servers/page.tsx:116-145`
- Modify: `web/app/(app)/keys/page.tsx:149-169`
- Modify: `web/app/(app)/monitors/page.tsx:79-98`
- Modify: `web/app/(app)/secrets/page.tsx:142-157`
- Modify: `web/app/(app)/secrets/[group]/page.tsx:141-150`
- Modify: `web/app/(app)/workflows/page.tsx:73-86`
- Modify: `web/app/(app)/workflows/[id]/runs/page.tsx:54-68`
- Modify: `web/app/(app)/audit/page.tsx:80-93`
- Modify: `web/app/(app)/keys/[id]/page.tsx:388-420`
- Modify: `web/app/(app)/servers/[id]/page.tsx:272-280` and `:588-605`
- Modify: `web/app/(app)/monitors/[id]/page.tsx:183-195`
- Modify: `web/components/settings/MembersCard.tsx:118-145`
**Interfaces:**
- Consumes: `Td`'s `label?: string` prop from Task 1.
- Produces: nothing.
- [ ] **Step 1: Add `label` to each `Td`, matching its `Th`**
For every table, the Nth `<Td>` in a `<Tr>` takes the text of the Nth `<Th>`. Where the `Th` is empty (`<Th />` — the trailing action column), the matching `Td` gets **no** `label`.
The mapping, `Th` order per file:
| File | Column labels, in order |
| --- | --- |
| `servers/page.tsx` | Hostname · IP Address · OS · Status · Last Seen · *(none)* |
| `keys/page.tsx` | Label · Fingerprint · Source · Assignments · Created · *(none)* |
| `monitors/page.tsx` | Name · Type · Target · Status · Latency · Last check |
| `secrets/page.tsx` | Group · Keys · Last Updated · *(none)* |
| `secrets/[group]/page.tsx` | Key · Value · Updated · *(none)* |
| `workflows/page.tsx` | Name · Targets · Steps · *(none)* |
| `workflows/[id]/runs/page.tsx` | Run · Status · Started · By · Servers |
| `audit/page.tsx` | Time · Event · Actor · Details |
| `keys/[id]/page.tsx` | Server · IP Address · Status · Assigned · Revoked · *(none)* |
| `servers/[id]/page.tsx` (updates table) | Package · Current · Available |
| `servers/[id]/page.tsx` (keys table) | Label · Fingerprint · Source · Status · Assigned · *(none)* |
| `monitors/[id]/page.tsx` | Started · Resolved · Cause |
| `MembersCard.tsx` | Email · Role · Sign-in · Last login · Actions |
Worked example — `servers/page.tsx` lines 116145 become:
```tsx
<Td label="Hostname">
<span className="font-medium text-text-primary">
{server.hostname}
</span>
</Td>
<Td label="IP Address">
<span className="font-mono text-text-secondary">
{server.ip_address}
</span>
</Td>
<Td label="OS">
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td label="Status">
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View →
</Button>
</Link>
</Td>
```
Note the last `Td` is unchanged — no `label`, so the "View →" button sits alone on its own row at the bottom of the card.
Second worked example — `MembersCard.tsx` line 142143, where `Td` already carries a `className`. Both props coexist:
```tsx
<Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td label="Actions" className="text-right">
```
`MembersCard`'s last column has a real `Th` ("Actions"), so unlike the others it **does** take a label.
- [ ] **Step 2: Verify no `Td` was missed**
```bash
cd web && grep -rn "<Td" app components | grep -v "label="
```
Expected: only the trailing action cells listed as *(none)* above — 7 of them (`servers`, `keys`, `secrets`, `secrets/[group]`, `workflows`, `keys/[id]`, `servers/[id]` keys table). Any other bare `<Td` is a miss.
- [ ] **Step 3: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 4: Commit**
```bash
git add web/app web/components
git commit -m "feat(web): label table cells for the mobile card stack"
```
---
### Task 5: Modal bottom sheet and shared-component grids
**Files:**
- Modify: `web/components/ui/Modal.tsx:28-31`
- Modify: `web/components/monitors/MonitorForm.tsx:76,98,123,144`
- Modify: `web/components/workflows/StepPickerModal.tsx:132,168`
- Modify: `web/components/ui/Card.tsx:27`
**Interfaces:**
- Consumes: nothing. Produces: nothing.
- [ ] **Step 1: Make `Modal` a bottom sheet below `sm`**
In `web/components/ui/Modal.tsx`, replace lines 2834:
```tsx
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
>
```
The `max-w-*` gains an `sm:` prefix so the sheet is full-width on a phone. `dvh` rather than `vh` because mobile browser chrome makes `vh` overshoot.
- [ ] **Step 2: Collapse the grids in `MonitorForm.tsx`**
- Line 76: `grid grid-cols-4 gap-2``grid grid-cols-2 gap-2 sm:grid-cols-4`
- Lines 98, 123, 144: `grid grid-cols-2 gap-4``grid grid-cols-1 gap-4 sm:grid-cols-2`
- [ ] **Step 3: Collapse the grids in `StepPickerModal.tsx`**
Lines 132 and 168: `grid grid-cols-2 gap-2.5``grid grid-cols-1 gap-2.5 sm:grid-cols-2`
- [ ] **Step 4: Let `CardHeader` wrap**
`web/components/ui/Card.tsx` line 27: `"mb-4 flex items-center justify-between"``"mb-4 flex flex-wrap items-center justify-between gap-2"`. Card headers hold a title and an action; at 390px they need to be allowed to wrap rather than crush the title.
- [ ] **Step 5: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add web/components/ui/Modal.tsx web/components/ui/Card.tsx web/components/monitors/MonitorForm.tsx web/components/workflows/StepPickerModal.tsx
git commit -m "feat(web): bottom-sheet modals and collapsing component grids"
```
---
### Task 6: Workflow builder
**Files:**
- Modify: `web/app/(app)/workflows/[id]/page.tsx:305-324` (header), `:329` (grid), `:331` (canvas), `:340` (column), `:372` (node), `:403` (inspector)
**Interfaces:**
- Consumes: nothing. Produces: nothing.
Below `lg` the fixed-height two-column grid is dropped entirely: single column, natural page flow. The `100dvh` arithmetic only makes sense at `lg`, where there is no mobile top bar above it.
- [ ] **Step 1: Let the header wrap (line 305)**
```tsx
<div className="flex flex-wrap items-center gap-3 border-b border-border bg-surface px-4 py-3">
```
and on line 312 change `className="ml-auto flex items-center gap-2"` to
`className="ml-auto flex flex-wrap items-center gap-2"`.
- [ ] **Step 2: Make the shell single-column below lg (line 329)**
```tsx
<div className="flex flex-1 flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]">
```
`h-[calc(100vh-53px)]` becomes `lg:h-[calc(100dvh-53px)]``lg:` because the mobile top bar changes the arithmetic, and `dvh` because `vh` overshoots on mobile.
- [ ] **Step 3: Canvas padding (line 331)**
```tsx
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-4 sm:p-6 lg:p-8">
```
- [ ] **Step 4: Let the node column and nodes be fluid (lines 340 and 372)**
Line 340:
```tsx
<div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
```
Line 372 — the node itself. The wrapping `<div key={wfIdx} className="w-full">` on line 349 already constrains it, so the node just fills:
```tsx
className={`w-full cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`}
```
- [ ] **Step 5: Turn the inspector into a bottom panel below lg (line 403)**
```tsx
<aside
className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${
selected === null || !selectedRef ? "hidden" : "block border-t max-lg:max-h-[60dvh]"
}`}
>
```
Below `lg` the inspector is hidden until a step is selected — an empty "Select a step to configure it" panel is noise on a phone — and when shown it sits under the canvas with a top border and a capped height. Above `lg` it is the left-bordered right rail it always was, always visible.
- [ ] **Step 6: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 7: Commit**
```bash
git add "web/app/(app)/workflows/[id]/page.tsx"
git commit -m "feat(web): single-column workflow builder below lg"
```
---
### Task 7: Remaining fixed layouts
**Files:**
- Modify: `web/app/(app)/servers/[id]/page.tsx:164,495`
- Modify: `web/app/(app)/secrets/page.tsx:53`
- Modify: `web/app/(app)/workflows/[id]/runs/[runId]/page.tsx:~250`
- Modify: `web/app/(app)/servers/[id]/console/page.tsx:168` and its header rows
**Interfaces:**
- Consumes: nothing. Produces: nothing.
- [ ] **Step 1: `servers/[id]/page.tsx` line 164 — inventory grid**
`className="grid grid-cols-3 gap-2"``className="grid grid-cols-2 gap-2 sm:grid-cols-3"`
- [ ] **Step 2: `servers/[id]/page.tsx` line 495 — install one-liner**
`className="relative flex-1 min-w-64 rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm"` → replace `min-w-64` with `min-w-0 overflow-x-auto`.
`min-w-64` is 256px of floor on a flex child; combined with a sibling copy button it pushes the row past a 390px viewport and scrolls the whole page sideways. `min-w-0` lets the box shrink and scroll its own content instead. Also check the parent flex row a few lines above and give it `flex-wrap` if the copy button ends up crushed.
- [ ] **Step 3: `secrets/page.tsx` line 53**
`className="grid grid-cols-2 gap-3"``className="grid grid-cols-1 gap-3 sm:grid-cols-2"`
- [ ] **Step 4: `workflows/[id]/runs/[runId]/page.tsx` — the step matrix**
Read the file around lines 240290. The matrix `<table>` has a `<th className="min-w-[240px] …">`. It is a genuine two-dimensional matrix (steps × servers) and must keep scrolling horizontally rather than stacking — stacking would destroy the information.
Confirm the `<table>` sits inside a wrapper with `overflow-x-auto`. If it does not, wrap it:
```tsx
<div className="overflow-x-auto">
<table …>
</table>
</div>
```
If a wrapper already exists, leave it alone and note that in the commit body.
- [ ] **Step 5: `servers/[id]/console/page.tsx`**
Line 168: `className="flex h-full flex-col p-8"``className="flex h-full min-h-0 flex-1 flex-col p-4 sm:p-6 lg:p-8"`.
`flex-1` is added because Task 2 changed the parent `<main>` from `flex-1 overflow-y-auto` to `flex min-w-0 flex-1 flex-col`, so `h-full` alone may no longer resolve to anything. If Task 2 Step 5 recorded a note about this file, resolve it here.
Line 161's error state also has a bare `p-8` — Task 3 should already have handled it. Confirm it reads `p-4 sm:p-6 lg:p-8`.
Then read the connected-state toolbar below line 220 and add `flex-wrap` to any `flex items-center` row that holds three or more controls, so the console's chrome wraps instead of overflowing.
- [ ] **Step 6: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 7: Commit**
```bash
git add web/app
git commit -m "feat(web): collapse remaining fixed layouts on small screens"
```
---
### Task 8: Final audit
**Files:** none modified unless the audit finds a miss.
- [ ] **Step 1: No unprefixed `p-8` remains**
```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```
Expected: no output.
- [ ] **Step 2: No unprefixed multi-column grid remains**
```bash
cd web && grep -rnoE '(class|className)="[^"]*(^|[" ])grid-cols-[2-9]' app components
```
Every hit must be a `grid-cols-2` that is genuinely fine at 390px (two short items side by side). Check each one and note the justification. Anything holding form controls or long text must gain a `grid-cols-1 sm:` prefix.
- [ ] **Step 3: No fixed pixel width escapes a breakpoint prefix**
```bash
cd web && grep -rnoE '(^|[" ])(w|min-w|max-w)-\[[0-9]{3,}px\]' app components
```
Expected: only `lg:`-prefixed hits, plus `max-w-[340px]` and `max-w-[1180px]` and `max-w-[300px]`, which are all *maximums* and shrink freely. A bare `w-[NNNpx]` or `min-w-[NNNpx]` without a prefix is a defect — except `min-w-[240px]` in the run-detail matrix, which is deliberate (Task 7 Step 4).
- [ ] **Step 4: No hex colours were introduced**
```bash
cd web && git diff main --stat && git diff main -- app components | grep -nE '^\+.*#[0-9a-fA-F]{3,8}\b'
```
Expected: no output from the grep. Tailwind in this app maps `var(--…)` tokens only.
- [ ] **Step 5: Full build and lint**
```bash
npx next lint
npx next build
```
Expected: both succeed with no new warnings.
- [ ] **Step 6: Read the diff end to end**
```bash
git diff main -- web/
```
Check for: an accidentally deleted SVG path, a `Td` whose `label` does not match its `Th`, indentation reformatted in a file that used the other convention.
- [ ] **Step 7: Commit any fixes**
```bash
git add web
git commit -m "fix(web): mobile audit corrections"
```
If the audit found nothing, skip this step — do not create an empty commit.
---
## Self-review notes
**Spec coverage:** shell → Task 2; tables → Tasks 1 and 4; padding and headers → Task 3; modal → Task 5; workflow builder → Task 6; remaining fixed layouts → Task 7; verification → Task 8 plus a verify step in every task.
**Known limitation:** there is no test framework and no running backend in this environment, so no task can prove a page *looks* right — only that it compiles, lints, and contains no pattern known to break at 390px. The first person to open this on a phone should expect to find something. That is a property of the verification approach chosen in the spec, not a gap in the plan.
@@ -60,7 +60,7 @@ rather than two.
```
vantage/
├── go.work # NEW: server, sitesvc, shared (NOT agent)
├── shared/ # NEW module: github.com/mrhid6/vantage/shared
├── shared/ # NEW module: gitea.hostxtra.co.uk/mrhid6/vantage/shared
│ ├── go.mod
│ ├── models/
│ │ ├── org.go # Org
@@ -93,8 +93,8 @@ use (
Each consumer's `go.mod` also carries an explicit replace:
```
require github.com/mrhid6/vantage/shared v0.0.0
replace github.com/mrhid6/vantage/shared => ../shared
require gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
```
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
@@ -111,11 +111,11 @@ keeps sitesvc small.
**`shared/models`** — the three documents written by more than one service:
| Type | From | Written by |
|---|---|---|
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
| Type | From | Written by |
| ------------------------------------- | ------------------------------------ | ---------------------------------- |
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
`Settings` moves now rather than later because spec 3's admin service reads it,
and moving it later would mean a second round of import churn across both
@@ -272,12 +272,12 @@ No database migration. No downtime.
## Risks
| Risk | Mitigation |
|---|---|
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| Risk | Mitigation |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
## Follow-on
@@ -0,0 +1,177 @@
# Control plane (`web/`) — mobile responsive design
Date: 2026-07-27
Scope: `web/` only. `site/` and `adminsite/` are untouched.
## Problem
`web/` was built for a desktop console and has no mobile handling at all.
- `Sidebar` is a fixed `w-60 h-screen` aside rendered unconditionally by
`app/(app)/layout.tsx`. On a 390px phone it eats 62% of the width.
- Every page opens with `p-8` — 64px of horizontal padding on a screen that has
390px to give.
- Six list pages render 46 column tables. They scroll horizontally, so nothing
overflows the page, but reading a row means swiping.
- The workflow builder is a hard `grid-cols-[1fr_320px]` with `w-[340px]` nodes.
At 390px the inspector alone exceeds the viewport.
- Several grids are unprefixed (`grid-cols-3`, `grid-cols-2`, `grid-cols-4`) and
never collapse.
Next's App Router injects `width=device-width, initial-scale=1` by default, so
the breakpoints *do* fire. This is a layout problem, not a viewport one.
## Decisions
| Decision | Choice | Why |
| --- | --- | --- |
| Sidebar collapse breakpoint | `lg` (< 1024px) | Content is dense — tables plus `lg:grid-cols-3` side rails. Reclaiming 240px helps tablets as much as phones, and `lg` is already where the app's own two-and-three column layouts switch. |
| Table treatment on phones | Card stack below `sm` | Horizontal swiping to read a hostname's status is the single worst thing about the current app on a phone. |
| Workflow builder / console | Best-effort responsive | Usable, not redesigned. No blocking notice — a cramped console beats no console. |
| Verification | Static audit + `next build` + `next lint` | The app is auth-gated behind Mongo, Redis and the Go server; none run in this environment. |
## Design
### 1. The shell
A new client component `web/components/AppShell.tsx` owns the responsive chrome
so `app/(app)/layout.tsx` stays a server component:
```
AppShell (client, holds `open` state)
├── <aside class="hidden lg:flex"> ← permanent sidebar, unchanged look
├── mobile top bar (lg:hidden, sticky, h-14)
│ hamburger · Logo · "Vantage" · instance name
├── offcanvas (lg:hidden, fixed inset-0 z-50)
│ backdrop (bg-black/60) + w-72 panel, translate-x transition
└── <main class="flex-1 overflow-y-auto"> ← LicenseBanner + children
```
`Sidebar.tsx` splits into:
- `SidebarContent` — the nav list, user block and logout. **One copy**, rendered
by both the permanent aside and the offcanvas panel. It takes an optional
`onNavigate` callback so the offcanvas can close on link click.
- `Sidebar` — the permanent `hidden lg:flex` aside.
- `SidebarDrawer` — the offcanvas.
`navItems` and the `activeHref` reduction move to module scope so both
containers share them. The active-item accent bar, the instance name in the
header and the user/logout footer all appear in both, unchanged.
Offcanvas behaviour:
- Closes on route change (`usePathname` effect), on Escape, on backdrop click
and on any nav link click.
- Locks `document.body.style.overflow` while open, restores on close.
- `aria-expanded` / `aria-controls` on the hamburger; `role="dialog"` and
`aria-modal="true"` on the panel; `aria-label` on the button.
- Focus moves into the panel on open and returns to the hamburger on close.
- The panel is always mounted so the slide transition runs in both directions;
it carries `pointer-events-none invisible` when closed rather than being
unmounted.
The top bar is `sticky top-0 z-40` inside the scroll container so it stays
reachable on long pages.
### 2. Tables become card stacks without duplicating markup
The responsive mode lives in the primitives (`web/components/ui/Table.tsx`),
not in each page. Writing two parallel trees per page — a `<table>` for desktop
and a `<div>` stack for mobile — would double six pages of markup and drift
apart on the first edit.
`Td` gains an optional `label`. Below `sm` the table flips to block layout:
| Element | Added classes (below `sm`) |
| --- | --- |
| `Table` | `max-sm:block` |
| `Thead` | `max-sm:hidden` |
| `Tbody` | `max-sm:block max-sm:divide-y-0 max-sm:space-y-3 max-sm:p-3` |
| `Tr` | `max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3` |
| `Td` | `max-sm:flex max-sm:items-start max-sm:justify-between max-sm:gap-4 max-sm:px-0 max-sm:py-1.5` |
When `label` is present, `Td` renders it in a `sm:hidden` span using the exact
mono keyed-label idiom `Th` already uses — `font-mono text-[0.68rem] uppercase
tracking-[0.13em] text-text-secondary`. The key/value pairing on a phone is the
same visual device as the column head on a desktop, because it means the same
thing.
A `Td` with no `label` (the trailing action cell) renders its child alone,
right-aligned in the card.
Pages change only by adding `label="Hostname"` to their cells. Affected:
`servers`, `keys`, `monitors`, `secrets`, `secrets/[group]`, `workflows`,
`workflows/[id]/runs`, `audit`, `keys/[id]`, `servers/[id]` (two tables),
`monitors/[id]`, and `components/settings/MembersCard.tsx`.
### 3. Page padding and headers
- `p-8``p-4 sm:p-6 lg:p-8`, everywhere it opens a page or a page-level
error/loading state — 30 occurrences across 21 files.
- Title-plus-action header rows: `flex items-center justify-between`
`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between`. The
action button then sits under the title on a phone rather than squeezing it.
### 4. Modal becomes a bottom sheet under `sm`
`Modal.tsx`: `items-center``items-end sm:items-center`, wrapper `p-4`
`p-0 sm:p-4`, panel gets `rounded-b-none sm:rounded` and `max-h-[85dvh]`
(`dvh`, not `vh` — mobile browser chrome makes `vh` overshoot). Sheets are what
phones expect for a modal, and it costs four classes.
### 5. Workflow builder
Below `lg` the fixed-height two-column grid is dropped entirely: single column,
natural page flow, canvas scrolls with the page.
- `grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]`
`flex flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]`.
The viewport-height calculation is `lg:`-only, which matters because the
mobile top bar changes the arithmetic and `100vh` is wrong on mobile anyway.
- Node width `w-[340px]``w-full lg:w-[340px]`; the column wrapper
`w-[340px]``w-full max-w-[340px]`.
- Canvas padding `p-8``p-4 sm:p-6 lg:p-8`.
- The inspector `<aside>` becomes a collapsible bottom panel below `lg`: it
keeps its place in the flex column, gains a top border instead of a left one,
and is hidden until a step is selected (on a phone an empty "Select a step to
configure it" panel is noise).
- The builder's own header row wraps: the action cluster moves to a second line
under `sm`.
### 6. Remaining fixed layouts
| File | Change |
| --- | --- |
| `servers/[id]/page.tsx:164` | `grid-cols-3``grid-cols-2 sm:grid-cols-3` |
| `servers/[id]/page.tsx:495` | install one-liner `min-w-64``min-w-0` so it scrolls internally instead of widening the page |
| `secrets/page.tsx:53` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `monitors/MonitorForm.tsx:76` | `grid-cols-4``grid-cols-2 sm:grid-cols-4` |
| `monitors/MonitorForm.tsx:98,123,144` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `workflows/StepPickerModal.tsx:132,168` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `workflows/[id]/runs/[runId]/page.tsx:254` | matrix table wrapped in `overflow-x-auto`; it is a genuine matrix and stays scrollable |
| `workflows/[id]/runs/[runId]/page.tsx:343` | `p-8``p-4 sm:p-6 lg:p-8` |
| `servers/[id]/console/page.tsx:168` | `p-8``p-4 sm:p-6 lg:p-8`; header/toolbar rows wrap |
The run-detail matrix and the console canvas are the two places that keep
horizontal scrolling. Both are genuinely two-dimensional; stacking them would
destroy the information.
## Non-goals
- No changes to `site/` or `adminsite/`.
- No redesign of the console for touch input (no on-screen keyboard work).
- No new dependencies. Tailwind's `max-sm:` variant and `translate-x` are
enough; no headless-UI or animation library.
- No changes to any API, route or data shape. This is presentation only.
## Verification
1. **Audit** — after the edits, `grep` must return no unprefixed `p-8`,
no unprefixed `grid-cols-[2-9]`, and no `w-[3` fixed node widths outside a
`lg:` prefix in `web/app` and `web/components`.
2. `npx next lint` passes with no new warnings.
3. `npx next build` succeeds.
Screenshot verification is out of scope: the app is auth-gated behind Mongo,
Redis and the Go server, none of which run in this environment.
+1 -1
View File
@@ -10,7 +10,7 @@ Build in this order. Specs 0a5 were designed 2026-07-24; spec 6 on 2026-07-26
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | ready to start, **but revised by 7** — its "signup migration off sitesvc" section is superseded by 6, and its single-price-per-subscription assumption by 7 |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-27-paddle-billing.md) | **shipped (code)** — client, webhooks, checkout, entitlement update and portal built and compiled against spec-7's catalogue/entitlements; signup-migration dropped (done by 6). Live sandbox catalog + end-to-end pass is the operator's step. Old [2026-07-26 plan](../plans/2026-07-26-paddle-billing.md) superseded. |
| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start |
| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | [plan](../plans/2026-07-26-metered-licensing.md) | **shipped** — staff can configure and issue any of the six plans; no customer can buy one until 5 lands |
+1
View File
@@ -2,6 +2,7 @@ go 1.26
use (
./admin
./agent
./server
./shared
./sitesvc
+1 -1
View File
@@ -2,7 +2,7 @@ syntax = "proto3";
package vantage.v1;
option go_package = "github.com/mrhid6/vantage/server/internal/grpc/pb";
option go_package = "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb";
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
+8 -8
View File
@@ -6,24 +6,24 @@ import (
"os"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/api"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/db"
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
"github.com/mrhid6/vantage/server/internal/monitorsched"
"github.com/mrhid6/vantage/server/internal/services"
)
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
dbName := getEnv("MONGO_DB", "vantage")
if os.Getenv("GRPC_HOST") == "" {
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
}
if err := db.Connect(mongoURI, dbName); err != nil {
// DB name comes from the MONGO_URI path; "vantage" is the fallback.
if err := db.Connect(mongoURI, "vantage"); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Println("connected to MongoDB")
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/server
module gitea.hostxtra.co.uk/mrhid6/vantage/server
go 1.26
@@ -38,7 +38,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mrhid6/vantage/shared v0.0.0
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -58,4 +58,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
+3 -3
View File
@@ -3,10 +3,10 @@ package api
import (
"net/http"
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -7,9 +7,9 @@ import (
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"os"
"strconv"
"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"
"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"
)
func actorFromCtx(c *gin.Context) string {
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"errors"
"net/http"
"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"
"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"
)
func listInstanceUsers(c *gin.Context) {
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"time"
"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"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"regexp"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
)
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+3 -3
View File
@@ -10,10 +10,10 @@ import (
"strings"
"time"
"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"
"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"
)
func registerWorkflowRoutes(g *gin.RouterGroup) {
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
type cachedInstance struct {

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