Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
104f21d0a9 | ||
|
|
b86d9ddd86 | ||
|
|
9cf04a6940 | ||
|
|
cb90ed12fb | ||
|
|
a940390791 | ||
|
|
4e90e8619f | ||
|
|
372a8c5ddf | ||
|
|
909ddb884e | ||
|
|
1836237f82 | ||
|
|
983655d2a1 | ||
|
|
78a0a610be | ||
|
|
5f35b57268 | ||
|
|
cf318470b8 | ||
|
|
d703bbc4e8 | ||
|
|
17bcf4b5b9 | ||
|
|
da3afca7fa | ||
|
|
88f49a96ae | ||
|
|
d15ab78bd5 | ||
|
|
8eb14c1502 |
@@ -43,6 +43,7 @@ jobs:
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f site/Dockerfile site/
|
||||
docker push "$IMAGE"
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"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/lifecycle"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
@@ -30,6 +31,7 @@ func main() {
|
||||
}
|
||||
|
||||
licensing.SetSigningKey(cfg.SigningKey)
|
||||
api.SetAppLoginURL(cfg.AppLoginURL)
|
||||
|
||||
mail.Init(mail.Config{
|
||||
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
|
||||
@@ -71,6 +73,9 @@ func main() {
|
||||
defer stopReconcile()
|
||||
inject.StartReconciler(reconcileCtx)
|
||||
|
||||
lifecycle.SetPortalURL(cfg.PublicURL)
|
||||
lifecycle.Start(reconcileCtx, cfg.ReapAfter)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: api.Routes(cfg),
|
||||
|
||||
@@ -3,16 +3,23 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/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"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -187,6 +194,201 @@ func listSubscriptions(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
// createInstance provisions a Free cloud instance for the calling account.
|
||||
//
|
||||
// The ordering matters and each step unwinds the previous one. Licence issuance
|
||||
// and email are deliberately NOT allowed to fail the request: the instance
|
||||
// exists and the customer can sign in, they see the licence banner, and staff
|
||||
// can issue by hand. Rolling back an instance the customer can already see would
|
||||
// be worse than shipping it unlicensed.
|
||||
func createInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
// Pre-check the Free rule so we never create an instance we then cannot
|
||||
// licence. licensing.Issue enforces it too; this is the friendly refusal.
|
||||
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
|
||||
"account_id": s.AccountID,
|
||||
"tier": license.TierFree,
|
||||
"status": bson.M{"$ne": models.StatusCancelled},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this account already has a Free instance"})
|
||||
return
|
||||
}
|
||||
|
||||
var cu models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
|
||||
return
|
||||
}
|
||||
|
||||
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
|
||||
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"})
|
||||
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
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
|
||||
|
||||
// Past this point nothing fails the request.
|
||||
lic, err := licensing.Issue(ctx, licensing.IssueInput{
|
||||
InstanceID: inst.InstanceID,
|
||||
Tier: license.TierFree,
|
||||
Term: "monthly",
|
||||
Reason: models.ReasonNew,
|
||||
IssuedBy: "self-serve",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusCreated, rec)
|
||||
return
|
||||
}
|
||||
inject.Deliver(ctx, lic)
|
||||
|
||||
if mail.Enabled() {
|
||||
if err := mail.SendInstanceReady(s.Email, inst.Name,
|
||||
loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
|
||||
log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
rec.Tier = lic.Tier
|
||||
rec.CurrentLicense = lic.LicenseID
|
||||
c.JSON(http.StatusCreated, rec)
|
||||
}
|
||||
|
||||
// renewInstance extends a Free licence by another term.
|
||||
//
|
||||
// Renewal is manual on purpose: it is the entire reclaim signal. An instance
|
||||
// nobody renews is an instance nobody is using, and that is what makes the
|
||||
// reaper safe to run at all.
|
||||
func renewInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Tier != license.TierFree {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "only Free instances renew here; paid plans renew through billing"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var current models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
|
||||
current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
|
||||
return
|
||||
}
|
||||
|
||||
lic, err := licensing.Issue(ctx, licensing.IssueInput{
|
||||
InstanceID: inst.InstanceID,
|
||||
Tier: license.TierFree,
|
||||
Term: "monthly",
|
||||
Reason: models.ReasonRenewal,
|
||||
IssuedBy: "self-serve",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
inject.Deliver(ctx, lic)
|
||||
|
||||
// Clear the notice log so the next term starts the sequence again. Issue has
|
||||
// already set status back to active.
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
|
||||
log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, IP: c.ClientIP()})
|
||||
|
||||
if mail.Enabled() {
|
||||
if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
|
||||
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
|
||||
// yields an empty string, and the email simply omits the link.
|
||||
func loginURLFor(slug string) string {
|
||||
if appLoginURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
|
||||
}
|
||||
|
||||
// appLoginURL is set once at boot from config.
|
||||
var appLoginURL string
|
||||
|
||||
// SetAppLoginURL is called from main.
|
||||
func SetAppLoginURL(v string) { appLoginURL = v }
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
|
||||
@@ -27,7 +27,11 @@ func Routes(cfg config.Config) http.Handler {
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
|
||||
r.POST("/auth/staff/login", auth.HandleStaffLogin)
|
||||
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
|
||||
// Every customer authenticates against admin's own customer_users. There is
|
||||
// deliberately no path that looks a customer up in the control plane by
|
||||
// email alone: HQ sign-in names no instance, so such a lookup could not be
|
||||
// scoped, and users.email is no longer globally unique.
|
||||
r.POST("/auth/login", auth.HandleCustomerLogin)
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/verify", auth.HandleVerify)
|
||||
r.GET("/auth/me", getMe)
|
||||
@@ -37,8 +41,10 @@ func Routes(cfg config.Config) http.Handler {
|
||||
cust.Use(auth.RequireCustomer())
|
||||
{
|
||||
cust.GET("/account", getAccount)
|
||||
cust.POST("/instances", createInstance)
|
||||
cust.POST("/instances/link", linkInstance)
|
||||
cust.POST("/instances/:id/relink", relinkInstance)
|
||||
cust.POST("/instances/:id/renew", renewInstance)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/subscriptions", listSubscriptions)
|
||||
@@ -50,6 +56,7 @@ func Routes(cfg config.Config) http.Handler {
|
||||
staff.GET("/accounts", staffListAccounts)
|
||||
staff.POST("/accounts", staffCreateAccount)
|
||||
staff.GET("/accounts/:id", staffGetAccount)
|
||||
staff.POST("/accounts/:id/users", staffCreateAccountUser)
|
||||
staff.GET("/instances", staffListInstances)
|
||||
staff.POST("/instances", staffCreateInstance)
|
||||
staff.GET("/instances/:id", staffGetInstance)
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -448,3 +449,40 @@ func staffInjectionHealth(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
|
||||
}
|
||||
|
||||
// staffCreateAccountUser gives an account an HQ login.
|
||||
//
|
||||
// This is how a legacy cloud customer — one whose instance predates HQ accounts
|
||||
// — gets into the portal, alongside the manual instance attach the spec README
|
||||
// describes. It reuses CreateCustomerUser, so the row is unverified until the
|
||||
// emailed link is opened and is rolled back if that email cannot be sent.
|
||||
func staffCreateAccountUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "email and a password of at least 12 characters are required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
accountID := c.Param("id")
|
||||
|
||||
if n, err := db.Admin("accounts").CountDocuments(ctx,
|
||||
bson.M{"account_id": accountID}); err != nil || n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's
|
||||
// users collection, with the credentials they already have.
|
||||
//
|
||||
// Two consequences worth stating plainly, because they are real and were
|
||||
// accepted deliberately:
|
||||
//
|
||||
// 1. A cloud user's control-plane password now also unlocks billing. Any
|
||||
// password change or compromise has a wider blast radius than before.
|
||||
// 2. Only control-plane role "owner" may sign in here. admin and member are
|
||||
// refused — billing is an owner concern.
|
||||
//
|
||||
// Mitigations: rate limits, an identical error for every failure, and an audit
|
||||
// entry for every attempt.
|
||||
func HandleCloudLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
reject := func(reason string) {
|
||||
audit.Write(ctx, adminmodels.AuditEntry{
|
||||
Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
}
|
||||
|
||||
// A self-hosted customer_users row wins over a control-plane user with the
|
||||
// same address. Documented so the behaviour is chosen rather than emergent.
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
HandleCustomerLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
var u sharedmodels.User
|
||||
if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
reject("unknown email")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
reject("bad password")
|
||||
return
|
||||
}
|
||||
if u.Role != sharedmodels.RoleOwner {
|
||||
reject("role " + u.Role + " is not permitted")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the admin-side account that owns this user's instance.
|
||||
var inst adminmodels.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil {
|
||||
reject("no account for instance " + u.InstanceID)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(ctx, Session{
|
||||
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(ctx, adminmodels.AuditEntry{
|
||||
Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package cloudprov provisions cloud instances in the control plane.
|
||||
//
|
||||
// This is admin's second and final write path into the control-plane database,
|
||||
// alongside inject. It writes `instances` and `users` and nothing else. A third
|
||||
// write target, or a write to any other collection from here, is a design change
|
||||
// and not a refactor — see the spec's "Admin's control-plane write boundary".
|
||||
//
|
||||
// Every function here is called from a customer request, so each one leaves the
|
||||
// control plane in a consistent state or not at all: the caller unwinds in
|
||||
// reverse order on failure, and RollbackInstance refuses to delete an instance
|
||||
// that has users.
|
||||
package cloudprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// CreateInstance creates a control-plane instance and its owner.
|
||||
//
|
||||
// The owner's password hash is COPIED from the HQ account rather than shared.
|
||||
// Changing the password on either side does not propagate, and they diverge from
|
||||
// that moment — accepted deliberately, because propagating a hash across two
|
||||
// services' databases is a worse problem than two passwords that started equal.
|
||||
//
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
|
||||
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
|
||||
if err != nil {
|
||||
if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
|
||||
return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// hq_user_id is what phase 3 uses to find every row projected from one HQ
|
||||
// user when its password changes. Set at creation so the owner is not a
|
||||
// special case later.
|
||||
if _, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"user_id": u.UserID},
|
||||
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
|
||||
return nil, fmt.Errorf("set hq_user_id: %w", err)
|
||||
}
|
||||
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes one control-plane user. Used only to unwind a failed
|
||||
// provision.
|
||||
func DeleteUser(ctx context.Context, instanceID, userID string) error {
|
||||
_, err := db.Control("users").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
|
||||
// RollbackInstance deletes an instance that has no users.
|
||||
func RollbackInstance(ctx context.Context, instanceID string) error {
|
||||
return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
|
||||
}
|
||||
|
||||
// OwnerUserID returns the control-plane user_id of an instance's owner, so a
|
||||
// caller can unwind a partial provision without re-deriving it.
|
||||
func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
|
||||
var u sharedmodels.User
|
||||
err := db.Control("users").FindOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
}).Decode(&u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.UserID, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -22,9 +23,11 @@ type Config struct {
|
||||
RedisPassword string
|
||||
SigningKey string
|
||||
PublicURL string
|
||||
AppLoginURL string
|
||||
AllowedOrigins []string
|
||||
TrustProxy bool
|
||||
Addr string
|
||||
ReapAfter time.Duration
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
@@ -63,6 +66,7 @@ func Load() (Config, error) {
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
SigningKey: os.Getenv("LICENSE_SIGNING_KEY"),
|
||||
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
|
||||
AppLoginURL: os.Getenv("APP_LOGIN_URL"),
|
||||
TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
|
||||
Addr: ":" + envOr("PORT", "8083"),
|
||||
|
||||
@@ -106,6 +110,18 @@ func Load() (Config, error) {
|
||||
c.AllowedOrigins = append(c.AllowedOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can
|
||||
// name the real deletion date. An unparseable value is refused rather than
|
||||
// silently treated as "off": a typo here would quietly stop every deletion
|
||||
// warning while the control plane still deletes.
|
||||
if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err)
|
||||
}
|
||||
c.ReapAfter = d
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -1,9 +1,11 @@
|
||||
// Package db holds admin's two MongoDB connections.
|
||||
//
|
||||
// Admin() is its own database and it owns every collection there. Control() is
|
||||
// the control plane's database, and admin's access to it is deliberately narrow:
|
||||
// it reads `instances` and `users`, and writes exactly three licence fields on
|
||||
// `instances`. Nothing here should ever grow a write path to another collection.
|
||||
// the control plane's database. Admin's access to it is narrow and lives in
|
||||
// exactly two packages: inject writes three licence fields on `instances`, and
|
||||
// cloudprov creates and rolls back `instances` and `users` when a customer
|
||||
// provisions a cloud instance. Nothing else may write there, and a third write
|
||||
// path is a design change rather than a refactor.
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -57,6 +59,13 @@ func Connect(ctx context.Context, cfg config.Config) error {
|
||||
func Admin(name string) *mongo.Collection { return adminDB.Collection(name) }
|
||||
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }
|
||||
|
||||
// ControlDB exposes the control-plane database itself, because shared/provision
|
||||
// takes a database rather than a collection.
|
||||
//
|
||||
// It is used by cloudprov and nothing else. Reach for Control(name) unless you
|
||||
// are calling into shared/provision.
|
||||
func ControlDB() *mongo.Database { return controlDB }
|
||||
|
||||
func Ctx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package inject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// ReconcileInterval is how often every cloud instance is compared against what
|
||||
@@ -103,6 +105,19 @@ func Reconcile(ctx context.Context) (checked, repaired int, err error) {
|
||||
var remote sharedmodels.Instance
|
||||
if err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
|
||||
// The control plane's reaper deletes lapsed Free instances. Record
|
||||
// that here rather than re-logging it every fifteen minutes forever,
|
||||
// and so the lifecycle sweep stops emailing about it.
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
if _, uErr := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil {
|
||||
log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr)
|
||||
} else {
|
||||
log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package lifecycle marks lapsed Free instances and sends the renewal notices.
|
||||
//
|
||||
// It sends; it never deletes. Deletion belongs to the control plane, which is
|
||||
// the only service that knows what an instance is made of. The two are kept
|
||||
// apart on purpose: a bug here sends a wrong email, a bug there loses data.
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Interval is how often the sweep runs. Hourly is far finer than the daily
|
||||
// granularity of the notices, which means a notice goes out within an hour of
|
||||
// becoming due rather than up to a day late.
|
||||
const Interval = time.Hour
|
||||
|
||||
// Notice keys, recorded on the instance so a restart cannot re-send one.
|
||||
const (
|
||||
noticeExpiring = "expiring"
|
||||
noticeExpired = "expired"
|
||||
noticeDelete7 = "delete_7"
|
||||
noticeDelete1 = "delete_1"
|
||||
)
|
||||
|
||||
// portalURL is the customer portal address used in notice emails.
|
||||
var portalURL string
|
||||
|
||||
// SetPortalURL is called once at boot.
|
||||
func SetPortalURL(v string) { portalURL = v }
|
||||
|
||||
// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails
|
||||
// can name the real deletion date. Zero means the reaper is off, and the
|
||||
// deletion notices are then suppressed — promising a deletion that will never
|
||||
// happen would be a lie, and a scarier one than saying nothing.
|
||||
var reapAfter time.Duration
|
||||
|
||||
// Run performs one sweep: mark lapsed instances, then send whatever notices are
|
||||
// due. Errors on one instance never stop the others.
|
||||
func Run(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentCloud,
|
||||
"tier": license.TierFree,
|
||||
"status": bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
continue // no licence yet; nothing to expire
|
||||
}
|
||||
|
||||
// Flip active -> lapsed once the licence is past its expiry.
|
||||
if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive {
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil {
|
||||
log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
|
||||
if due == "" {
|
||||
continue
|
||||
}
|
||||
if err := sendNotice(ctx, inst, lic, due); err != nil {
|
||||
log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
|
||||
log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dueNotice returns the most urgent unsent notice, or "".
|
||||
//
|
||||
// Most urgent first, so an instance that was missed for a week — because admin
|
||||
// was down — sends the one that matters now rather than working through a
|
||||
// backlog of stale warnings.
|
||||
func dueNotice(now, expires time.Time, sent []string) string {
|
||||
deleteOn := expires.Add(reapAfter)
|
||||
|
||||
if reapAfter > 0 {
|
||||
if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) {
|
||||
return noticeDelete1
|
||||
}
|
||||
if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) {
|
||||
return noticeDelete7
|
||||
}
|
||||
}
|
||||
if now.After(expires) && !slices.Contains(sent, noticeExpired) {
|
||||
return noticeExpired
|
||||
}
|
||||
if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) {
|
||||
return noticeExpiring
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error {
|
||||
if !mail.Enabled() {
|
||||
return nil
|
||||
}
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil {
|
||||
return err
|
||||
}
|
||||
to := acct.BillingEmail
|
||||
deleteOn := lic.ExpiresAt.Add(reapAfter)
|
||||
|
||||
switch key {
|
||||
case noticeExpiring:
|
||||
return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
|
||||
case noticeExpired:
|
||||
return mail.SendExpired(to, inst.Name, portalURL, deleteOn)
|
||||
case noticeDelete7:
|
||||
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
|
||||
case noticeDelete1:
|
||||
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs the sweep on a ticker until ctx is cancelled.
|
||||
//
|
||||
// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they
|
||||
// disagree, the emails name a date the reaper does not honour — so they are
|
||||
// documented as a pair in CLAUDE.md and set together in the compose file.
|
||||
func Start(ctx context.Context, reapAfterDur time.Duration) {
|
||||
reapAfter = reapAfterDur
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
t := time.NewTicker(Interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := Run(runCtx); err != nil {
|
||||
log.Printf("lifecycle: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -53,3 +54,64 @@ func SendLicense(to, instanceName, blob string) error {
|
||||
"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 one does not change the other.\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))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ const (
|
||||
StatusActive = "active"
|
||||
StatusLapsed = "lapsed"
|
||||
StatusCancelled = "cancelled"
|
||||
|
||||
// StatusDeleted marks an instance the control plane has reaped. The row is
|
||||
// kept because the licence history references it and support questions
|
||||
// outlive the instance.
|
||||
StatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Account statuses.
|
||||
@@ -48,6 +53,13 @@ const MaxRelinksPerTerm = 3
|
||||
// paying customer's instance goes read-only.
|
||||
const GracePeriod = 3 * 24 * time.Hour
|
||||
|
||||
// RenewWindow is how long before expiry a Free instance may be renewed.
|
||||
//
|
||||
// Renewal stays available after expiry too, right up until the reaper takes the
|
||||
// instance, so the same button rescues a lapsed instance instead of needing a
|
||||
// second mechanism.
|
||||
const RenewWindow = 7 * 24 * time.Hour
|
||||
|
||||
type Account struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
@@ -75,7 +87,12 @@ type Instance struct {
|
||||
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
|
||||
RelinkCount int `bson:"relink_count" json:"relink_count"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
// NoticesSent holds the lifecycle notice keys already emailed for the
|
||||
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
|
||||
// clears it, so the next term starts the sequence again. It is what stops a
|
||||
// restart re-sending a notice.
|
||||
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// License is append-only. A renewal writes a new row and sets SupersededBy on
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"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 one does not
|
||||
change the other afterwards.
|
||||
</p>
|
||||
|
||||
<Button type="submit" disabled={create.isPending || !name.trim()}>
|
||||
{create.isPending ? "Creating…" : "Create instance"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CreateForm } from "./CreateForm";
|
||||
|
||||
export const metadata: Metadata = { title: "New instance" };
|
||||
|
||||
export default function NewInstancePage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">Create a free instance</h1>
|
||||
<p className="max-w-prose text-ink-2">
|
||||
An instance owns its servers, keys, workflows, monitors and secrets. Nothing
|
||||
inside it is visible to any other instance. Free covers three servers, and the
|
||||
licence runs for a month at a time — we email you before it needs renewing.
|
||||
</p>
|
||||
</header>
|
||||
<CreateForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +58,16 @@ export default function OverviewPage() {
|
||||
<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">
|
||||
There are two ways to run Vantage. Buy a cloud instance and we host it, and
|
||||
your licence is applied automatically. Or buy a self-hosted licence, install
|
||||
Vantage on your own server, and link it here to get your licence file.
|
||||
Create a free cloud instance and we host it, with your licence applied
|
||||
automatically. Or 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>
|
||||
) : (
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -74,6 +80,18 @@ export default function OverviewPage() {
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import type { Instance, License } from "@/lib/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
|
||||
@@ -11,10 +14,28 @@ const STRIPE = {
|
||||
none: "before:bg-accent",
|
||||
} as const;
|
||||
|
||||
export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) {
|
||||
export function InstanceCard({
|
||||
instance,
|
||||
license,
|
||||
reapAfterDays,
|
||||
}: {
|
||||
instance: Instance;
|
||||
license?: License;
|
||||
reapAfterDays?: number;
|
||||
}) {
|
||||
const state = licenceState(license?.expires_at, Boolean(license));
|
||||
const days = license ? daysRemaining(license.expires_at) : 0;
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const termDays = instance.tier === "free" ? 30 : 365;
|
||||
const deleteInDays =
|
||||
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
|
||||
|
||||
const qc = useQueryClient();
|
||||
const renew = useMutation({
|
||||
mutationFn: () => api.renewInstance(instance.instance_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
|
||||
});
|
||||
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -42,6 +63,14 @@ export function InstanceCard({ instance, license }: { instance: Instance; licens
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === "expired" && deleteInDays !== null && (
|
||||
<p className="text-[0.82rem] font-semibold text-expired">
|
||||
{deleteInDays <= 0
|
||||
? "Scheduled for deletion."
|
||||
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === "none" && (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
You have paid for this but it is not attached to an install yet, so no licence
|
||||
@@ -55,35 +84,48 @@ export function InstanceCard({ instance, license }: { instance: Instance; licens
|
||||
<div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
|
||||
<div
|
||||
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
|
||||
style={{ width: `${Math.max(2, Math.min(100, (days / 365) * 100))}%` }}
|
||||
style={{ width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "none" ? (
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
) : cloud && instance.slug ? (
|
||||
<a
|
||||
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Open {instance.slug}.vantage.hostxtra.co.uk
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={`/instances/${instance.instance_id}`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
{state === "expired" ? "Renew and download" : "Licence and download"}
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{state === "none" ? (
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
) : cloud && instance.slug ? (
|
||||
<a
|
||||
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Open {instance.slug}.vantage.hostxtra.co.uk
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={`/instances/${instance.instance_id}`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
{state === "expired" ? "Renew and download" : "Licence and download"}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canRenew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => renew.mutate()}
|
||||
disabled={renew.isPending}
|
||||
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
|
||||
>
|
||||
{renew.isPending ? "Renewing…" : "Renew"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const post = <T,>(path: string, payload?: unknown) =>
|
||||
|
||||
export type Deployment = "cloud" | "self_hosted";
|
||||
export type Tier = "free" | "professional" | "self_hosted";
|
||||
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled";
|
||||
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
|
||||
|
||||
export interface Session {
|
||||
kind: "staff" | "customer";
|
||||
@@ -91,6 +91,7 @@ export interface Instance {
|
||||
current_license?: string;
|
||||
relink_count: number;
|
||||
inject_failed_at?: string | null;
|
||||
notices_sent?: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -187,6 +188,8 @@ export const api = {
|
||||
account: () => req<AccountResponse>("/api/account"),
|
||||
link: (instance_id: string, name: string) =>
|
||||
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`, {}),
|
||||
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`),
|
||||
|
||||
@@ -73,21 +73,20 @@ vantage/
|
||||
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
|
||||
│ ├── assets/ # image sources, not served
|
||||
│ └── Dockerfile # same shape as web/: standalone, node, 3000
|
||||
├── sitesvc/ # public forms: contact mail + signup
|
||||
├── sitesvc/ # public form: contact mail only
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── api/ # contact, signup, verify
|
||||
│ ├── api/ # contact
|
||||
│ ├── mail/ # SMTP
|
||||
│ ├── models/ # mirrors server org/user + pending signup
|
||||
│ ├── provision/ # slug rules mirrored from the control plane
|
||||
│ └── store/ # mongo: pending signups, org/user creation
|
||||
│ └── store/ # Mongo connect helper
|
||||
├── admin/ # licensing authority: the only signer
|
||||
│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP
|
||||
│ ├── cmd/adminctl/ # staff-add; deliberately has no HTTP surface
|
||||
│ └── internal/
|
||||
│ ├── api/ # customer + staff handlers, route table
|
||||
│ ├── auth/ # staff, cloud-owner and self-hosted sessions
|
||||
│ ├── inject/ # the ONE write path into the control plane
|
||||
│ ├── auth/ # staff, HQ customer and cloud-owner sessions
|
||||
│ ├── inject/ # licence write path into the control plane
|
||||
│ ├── cloudprov/ # instance write path: creates instances + owners
|
||||
│ ├── licensing/ # Issue, LinkInstance, Relink
|
||||
│ ├── mail/ # verification and licence delivery
|
||||
│ └── models/ # accounts, instances, licences, plans
|
||||
@@ -146,19 +145,19 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
|
||||
|
||||
### Marketing site and sitesvc
|
||||
|
||||
`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
|
||||
`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
|
||||
|
||||
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable and listed in admin's `ADMIN_ORIGIN`. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
|
||||
|
||||
`sitesvc/` (port `8082`) owns both flows end to end:
|
||||
`sitesvc/` (port `8082`) now owns only the contact flow:
|
||||
|
||||
| Form | Endpoint | Effect |
|
||||
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. |
|
||||
| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to the org's sign-in page (`APP_LOGIN_URL` with `{slug}` filled in). |
|
||||
| Form | Endpoint | Effect |
|
||||
| ------- | --------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
|
||||
All three are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below.
|
||||
|
||||
`site`, `sitesvc` and `admin` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
|
||||
```bash
|
||||
# self-hosted install — no marketing site, no sitesvc
|
||||
@@ -170,22 +169,17 @@ docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
|
||||
|
||||
### Signup and verification
|
||||
|
||||
**Nothing is written to `orgs` or `users` until the emailed link is opened.** A signup lands in sitesvc's own `site_pending_signups` collection holding the org name, the address, and the password already bcrypt-hashed at cost 12. The consequence is worth stating: an address nobody controls can never occupy an email, hold an organisation slug, or produce an account that can sign in. It also means the control plane's login path needs no concept of "unverified".
|
||||
Signup is **account-first**: it creates an HQ account and an unverified `customer_user` in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (`POST /api/instances`, see Admin REST API) does an org, or rather an `instance`, come to exist — provisioned by `cloudprov`, with the owner's password hash copied from the HQ user rather than shared. `site_pending_signups` is gone; sitesvc no longer has a signup flow at all.
|
||||
|
||||
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
|
||||
- `Verify` deletes the pending record **atomically before provisioning** (`FindOneAndDelete`), so a double-clicked link cannot create two organisations — the second delete matches nothing.
|
||||
- Links expire after 24 hours, and a **TTL index** lets Mongo drop abandoned signups so password hashes do not linger.
|
||||
- Re-submitting the form for the same address replaces the previous pending record, so only the newest link works.
|
||||
- If the owner insert fails after the org is created, the org is rolled back rather than stranded holding a slug. The rollback refuses to touch an org that has users.
|
||||
- Rate limited to 3 signups per client IP per hour, plus a honeypot field.
|
||||
- Links expire after 24 hours (`VerifyWindow`).
|
||||
- An unverified sign-in gets a distinct "check your email" error rather than the generic auth failure, because the address is already known to be theirs.
|
||||
- If sending the verification email fails, the freshly inserted `customer_user` (and account, on first signup) is rolled back rather than left stranded holding the unique index on email.
|
||||
- Rate limited per client IP, plus a honeypot field.
|
||||
|
||||
### The one piece of duplicated logic
|
||||
### Shared provisioning
|
||||
|
||||
`sitesvc/internal/provision` and `sitesvc/internal/models` mirror the control plane's slug rules, reserved names, bcrypt cost and document shapes. They are duplicated rather than imported because sitesvc is a separate module that deliberately does not depend on the server.
|
||||
|
||||
**Nothing enforces the match automatically.** If the control plane's `Slugify`, `reservedSlugs`, `CreateOrg` or `CreateUser` change, update `sitesvc/internal/provision` in the same commit — a divergence would provision tenants under rules the app does not agree with.
|
||||
|
||||
sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at boot so it does not depend on the server having started first. Creating an existing index is a no-op.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +192,14 @@ sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at
|
||||
- **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
|
||||
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
|
||||
|
||||
Unique indexes on user email and org slug are a **security property**, not an optimisation: `GetUserByEmail` does an unscoped `FindOne`, so duplicates would let the OIDC cross-org guard compare against an arbitrary user. Same for duplicate settings docs and duplicate ESO token hashes.
|
||||
Unique indexes are a **security property**, not an optimisation. `users` is
|
||||
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
|
||||
unscoped lookup anywhere, and adding one would let the login path return an
|
||||
arbitrary one of several matching users. Instance slug, settings instance and ESO
|
||||
token hash remain globally unique.
|
||||
|
||||
---
|
||||
|
||||
@@ -282,6 +283,8 @@ Customer-session (`/api`), every instance resolved through `ownedInstance`:
|
||||
|
||||
```
|
||||
GET /account # account, instances, max_relinks
|
||||
POST /instances # create a cloud instance (Free tier, capped at one per account)
|
||||
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
GET /subscriptions
|
||||
@@ -305,8 +308,6 @@ GET /health/injection
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it.
|
||||
|
||||
Notes that are not obvious from the structs:
|
||||
|
||||
- `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending` → `active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min).
|
||||
@@ -315,6 +316,7 @@ Notes that are not obvious from the structs:
|
||||
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
|
||||
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `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.
|
||||
|
||||
### Migrations
|
||||
|
||||
@@ -397,15 +399,14 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
|
||||
|
||||
**sitesvc** (`deploy/docker-compose.site.yml` only):
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
|
||||
| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it |
|
||||
| `APP_LOGIN_URL` | no | template for the org sign-in URL a verified owner is redirected to. `{slug}` is replaced with the new org's slug (each org has its own subdomain), e.g. `https://{slug}.vantage.hostxtra.co.uk/login`. A value without `{slug}` is used verbatim; empty means a plain confirmation page. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping |
|
||||
| `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
|
||||
@@ -426,7 +427,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
- AES-256-GCM at rest for private keys, key passphrases, vault secrets, OIDC client secrets, RDP/VNC credentials.
|
||||
- Console session tokens are one-time; RDP credentials are consumed on tunnel open.
|
||||
- ESO read token stored as a SHA-256 hash and rotatable.
|
||||
- Unique indexes on user email, org slug, settings org, and ESO token hash are load-bearing for tenant isolation.
|
||||
- Unique indexes on `(instance_id, email)`, instance slug, settings instance and the ESO token hash are load-bearing for tenant isolation. So is the absence of any unscoped lookup by email.
|
||||
- `authorized_keys` written `0600`, owned by root. The agent runs as root because it must.
|
||||
- Every mutating API path writes an audit event.
|
||||
|
||||
@@ -527,3 +528,4 @@ git push origin main # server + web deploy
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
|
||||
- **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.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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
|
||||
@@ -15,7 +20,6 @@ services:
|
||||
PORT: "8082"
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
|
||||
SITE_ORIGIN: ${SITE_ORIGIN:-}
|
||||
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
@@ -45,6 +49,8 @@ services:
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,935 @@
|
||||
# Cloud Instance Creation — Phase 1: Identity
|
||||
|
||||
> **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:** Replace the control plane's global unique index on `users.email` with a per-instance one, and scope every lookup that relied on the global index, so one address can belong to several instances.
|
||||
|
||||
**Architecture:** The index change is safe only because the two unscoped `FindOne({email})` lookups are scoped in the same binary that performs the swap. The new compound index is created **before** the old one is dropped, so a failure at any point leaves a working constraint in place. The unscoped helper is deleted rather than left unused, and admin's one unscoped control-plane lookup — which has no instance to scope by — is removed entirely.
|
||||
|
||||
**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, `shared/indexes`, `shared/models`, `shared/provision`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **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 "$@"
|
||||
```
|
||||
- **`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.
|
||||
- **All three service images must ship together.** An older image booting after this change would recreate `email_1`. `.gitea/workflows/server-deploy.yml` rebuilds every image on every push to `main`, so this is automatic — the hazard is only a partial manual rollout on the host.
|
||||
- **This migration is one-way.** Once two users share an address across instances, `email_1` cannot be recreated. There is no rollback; fixes go forward.
|
||||
- Nothing in this phase projects users, creates instances, or adds UI. Those are phases 2 and 3.
|
||||
|
||||
## Context this plan inherits
|
||||
|
||||
`CLAUDE.md` currently states that the unique index on user email is "a security property, not an optimisation", because `GetUserByEmail` does an unscoped `FindOne`. That statement is true today and stops being true in Task 1. Task 7 updates it in the same series of commits, and the replacement property is stronger: a scoped query cannot be ambiguous, whereas an index merely prevents the ambiguity from arising.
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), phase 1.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**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 |
|
||||
|
||||
**Created:** none.
|
||||
|
||||
---
|
||||
|
||||
### 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`.
|
||||
|
||||
- [ ] **Step 1: Replace the body of `EnsureCoreIndexes` and add the drop helper**
|
||||
|
||||
Replace the whole file with:
|
||||
|
||||
```go
|
||||
// Package indexes declares the MongoDB indexes more than one Vantage service
|
||||
// depends on.
|
||||
package indexes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// legacyUserEmailIndex is the global unique index on users.email that this
|
||||
// package used to declare. It is dropped on sight.
|
||||
const legacyUserEmailIndex = "email_1"
|
||||
|
||||
// indexNotFound is MongoDB's IndexNotFound error code. Two services booting at
|
||||
// once can both decide to drop the legacy index; the loser must not treat that
|
||||
// as a failure.
|
||||
const indexNotFound = 27
|
||||
|
||||
// EnsureCoreIndexes declares the unique indexes on users and instances.
|
||||
//
|
||||
// users is unique on (instance_id, email), NOT on email alone. One address is
|
||||
// one user WITHIN an instance; the same address may hold a user in several
|
||||
// instances, because an account's people are projected into each instance they
|
||||
// are granted access to.
|
||||
//
|
||||
// This is a security property, not an optimisation, and it is only sufficient
|
||||
// because every lookup by email is scoped by instance. There is deliberately no
|
||||
// unscoped lookup by email anywhere in the codebase: an unscoped FindOne would
|
||||
// return an arbitrary one of several matching users, which on the login path
|
||||
// means signing someone into a tenant that is not theirs. If you are about to
|
||||
// add one, you are about to reintroduce that bug.
|
||||
//
|
||||
// Creating an index that already exists with the same specification is a no-op,
|
||||
// so this is safe to call at every boot from every service.
|
||||
func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
|
||||
// Create the replacement BEFORE dropping the legacy index. A failure here
|
||||
// leaves the old constraint in place, which is safe; a failure after the
|
||||
// drop would leave the collection unconstrained, which is not.
|
||||
if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetName("instance_email_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("users.instance_id+email index: %w", err)
|
||||
}
|
||||
|
||||
if err := dropIndexIfExists(ctx, db.Collection("users"), legacyUserEmailIndex); err != nil {
|
||||
return fmt.Errorf("drop users.%s: %w", legacyUserEmailIndex, err)
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "slug", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("instances.slug index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropIndexIfExists drops name, treating "it was not there" as success whether
|
||||
// that is discovered by listing or by racing another service to the drop.
|
||||
func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string) error {
|
||||
cur, err := col.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var existing []struct {
|
||||
Name string `bson:"name"`
|
||||
}
|
||||
if err := cur.All(ctx, &existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, i := range existing {
|
||||
if i.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = col.Indexes().DropOne(ctx, name)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var srvErr mongo.ServerError
|
||||
if errors.As(err, &srvErr) && srvErr.HasErrorCode(indexNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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**
|
||||
|
||||
```bash
|
||||
git add shared/indexes/indexes.go
|
||||
git commit -m "feat(shared): unique users index is (instance_id, email)
|
||||
|
||||
One address is one user within an instance, not globally, so an account's
|
||||
people can be projected into every instance they are granted.
|
||||
|
||||
The replacement index is created before email_1 is dropped, so a failure
|
||||
at any point leaves a working constraint. The drop is idempotent and
|
||||
tolerates two services racing it.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 1: Add the constants and the field**
|
||||
|
||||
In `shared/models/user.go`, after the `ValidRole` function, add:
|
||||
|
||||
```go
|
||||
// Auth sources. A user's auth_source says who owns the row.
|
||||
const (
|
||||
AuthLocal = "local"
|
||||
AuthOIDC = "oidc"
|
||||
// AuthHQ marks a user projected from a Vantage HQ account. Its role,
|
||||
// password and existence are owned by HQ, and the instance API refuses to
|
||||
// change any of them locally — a role editable in two places is a role with
|
||||
// two answers.
|
||||
AuthHQ = "hq"
|
||||
)
|
||||
```
|
||||
|
||||
And in the `User` struct, add `HQUserID` immediately after `AuthSource`:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
AuthSource string `bson:"auth_source" json:"auth_source"`
|
||||
// HQUserID is the customer_users.user_id this row was projected from,
|
||||
// absent on locally-created users.
|
||||
HQUserID string `bson:"hq_user_id,omitempty" json:"hq_user_id,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Re-export the constants from the server's alias file**
|
||||
|
||||
`server/internal/models/user.go` is a thin alias over `shared/models`, and server code imports that rather than the shared package directly. Add the auth sources alongside the roles it already re-exports:
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
type User = shared.User
|
||||
|
||||
const (
|
||||
RoleOwner = shared.RoleOwner
|
||||
RoleAdmin = shared.RoleAdmin
|
||||
RoleMember = shared.RoleMember
|
||||
)
|
||||
|
||||
const (
|
||||
AuthLocal = shared.AuthLocal
|
||||
AuthOIDC = shared.AuthOIDC
|
||||
AuthHQ = shared.AuthHQ
|
||||
)
|
||||
|
||||
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**
|
||||
|
||||
```bash
|
||||
git add shared/models/user.go server/internal/models/user.go
|
||||
git commit -m "feat(shared): auth_source constants and hq_user_id on User
|
||||
|
||||
Nothing writes them yet. They land now so phases 2 and 3 do not require a
|
||||
second rebuild of every service that consumes the shared module.
|
||||
|
||||
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.
|
||||
|
||||
- [ ] **Step 1: Replace `GetUserByEmail`**
|
||||
|
||||
In `server/internal/services/users.go`, delete the whole `GetUserByEmail` function and put this in its place:
|
||||
|
||||
```go
|
||||
// GetUserInInstanceByEmail finds a user by address WITHIN one instance.
|
||||
//
|
||||
// There is deliberately no unscoped lookup by email. users is unique on
|
||||
// (instance_id, email), not on email alone, so an unscoped FindOne would return
|
||||
// an arbitrary one of several matching users — which on the login path means
|
||||
// signing someone into a tenant that is not theirs.
|
||||
func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var u models.User
|
||||
err := db.Col("users").FindOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"email": email,
|
||||
}).Decode(&u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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**
|
||||
|
||||
The build is red. Commit at the end of Task 5, when both callers are fixed. A commit that does not build is a commit nobody can bisect through.
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
**Behaviour change worth knowing:** signing in at the bare apex host stops working when more than one instance exists. Cloud sign-in is always on `<slug>.vantage.<tld>` — `APP_LOGIN_URL` fills `{slug}` in, so every link already points there — and self-hosted has exactly one instance, so both supported paths keep working. A bookmark to the apex login page on a multi-instance deployment will now get a 400 that names the cause.
|
||||
|
||||
- [ ] **Step 1: Add `resolveLoginInstance` and rewrite `HandleLocalLogin`**
|
||||
|
||||
In `server/internal/auth/local.go`, add `"fmt"` to the imports if it is not already there, then add above `HandleLocalLogin`:
|
||||
|
||||
```go
|
||||
// resolveLoginInstance decides which instance a sign-in attempt belongs to.
|
||||
//
|
||||
// Cloud always answers from the host: every instance has its own subdomain, and
|
||||
// APP_LOGIN_URL fills the slug in, so every sign-in link already points at one.
|
||||
// Self-hosted has no subdomain and exactly one instance, because a licence
|
||||
// binds one instance UUID.
|
||||
//
|
||||
// Anything else is refused rather than guessed. Picking an instance on someone's
|
||||
// behalf is how you sign them into the wrong tenant.
|
||||
func resolveLoginInstance(c *gin.Context) (string, error) {
|
||||
if inst, ok := InstanceFromHost(c); ok {
|
||||
return inst.InstanceID, nil
|
||||
}
|
||||
n, err := services.CountInstances()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n != 1 {
|
||||
return "", fmt.Errorf(
|
||||
"cannot tell which instance this sign-in is for: %d instances exist and the host %q names none of them; sign in at your instance's own address",
|
||||
n, c.Request.Host)
|
||||
}
|
||||
inst, err := services.FirstInstance()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return inst.InstanceID, nil
|
||||
}
|
||||
```
|
||||
|
||||
Then replace the body of `HandleLocalLogin` between the JSON bind and `SaveSession` with:
|
||||
|
||||
```go
|
||||
instanceID, err := resolveLoginInstance(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserInInstanceByEmail(instanceID, body.Email)
|
||||
if err != nil || !services.VerifyPassword(u, body.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Scoped OIDC callback
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/auth/oidc.go:129-141`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `services.GetUserInInstanceByEmail` from Task 3.
|
||||
- Produces: nothing new.
|
||||
|
||||
The cross-instance guard is deleted because it becomes unreachable: the lookup is now scoped to `instanceID`, so a user belonging to another instance is simply not found, and the OIDC callback provisions a new member — which is correct. OIDC is configured per instance, so only that instance's identity provider can reach this code with that instance's state.
|
||||
|
||||
- [ ] **Step 1: Replace the lookup and delete the guard**
|
||||
|
||||
In `server/internal/auth/oidc.go`, replace:
|
||||
|
||||
```go
|
||||
email := strings.ToLower(claims.Email)
|
||||
u, err := services.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
|
||||
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
|
||||
return
|
||||
}
|
||||
} else if u.InstanceID != instanceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
email := strings.ToLower(claims.Email)
|
||||
|
||||
// Scoped to the instance the callback state names, so an address that also
|
||||
// exists in another instance is invisible here. That scoping replaces the
|
||||
// cross-instance guard this code used to need: there is no longer a way for
|
||||
// the lookup to return a user belonging to somebody else.
|
||||
u, err := services.GetUserInInstanceByEmail(instanceID, email)
|
||||
if err != nil {
|
||||
u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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:
|
||||
|
||||
```sh
|
||||
grep -n "server/internal/models" server/internal/auth/oidc.go
|
||||
```
|
||||
|
||||
If that returns nothing, add the import rather than reverting to string literals — Task 2 exists so these two values have one spelling.
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **Step 4: Commit Tasks 3, 4 and 5 together**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/users.go server/internal/auth/local.go server/internal/auth/oidc.go
|
||||
git commit -m "feat(server): scope every user lookup by instance
|
||||
|
||||
users is unique on (instance_id, email) now, so an unscoped FindOne could
|
||||
return an arbitrary one of several matching users. On the login path that
|
||||
means signing someone into a tenant that is not theirs.
|
||||
|
||||
GetUserByEmail is deleted rather than left unused. Local sign-in resolves
|
||||
its instance from the host, falling back to the single instance a
|
||||
self-hosted deployment has, and refuses to guess otherwise. The OIDC
|
||||
cross-instance guard goes: a scoped lookup cannot return another
|
||||
instance's user, which is a stronger guarantee than the check it replaces.
|
||||
|
||||
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`.
|
||||
|
||||
`HandleCloudLogin` authenticates against control-plane `users` with an unscoped `FindOne({email})`, and unlike the server's two lookups there is no instance in context to scope it by — HQ sign-in is not per-instance. It already falls through to `HandleCustomerLogin` whenever a `customer_users` row exists, which after phase 2 is every customer. Legacy cloud customers get an HQ login from staff, which is what the new endpoint is for; staff already attach those instances by hand per the spec README.
|
||||
|
||||
- [ ] **Step 1: Delete the file**
|
||||
|
||||
```sh
|
||||
git rm admin/internal/auth/cloud.go
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Point `/auth/login` at the customer handler**
|
||||
|
||||
In `admin/internal/api/routes.go`, replace:
|
||||
|
||||
```go
|
||||
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```go
|
||||
// Every customer authenticates against admin's own customer_users. There is
|
||||
// deliberately no path that looks a customer up in the control plane by
|
||||
// email alone: HQ sign-in names no instance, so such a lookup could not be
|
||||
// scoped, and users.email is no longer globally unique.
|
||||
r.POST("/auth/login", auth.HandleCustomerLogin)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the staff route**
|
||||
|
||||
In `admin/internal/api/routes.go`, inside the `staff` group, immediately after the `staff.GET("/accounts/:id", staffGetAccount)` line, add:
|
||||
|
||||
```go
|
||||
staff.POST("/accounts/:id/users", staffCreateAccountUser)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the handler**
|
||||
|
||||
At the end of `admin/internal/api/staff.go`, add:
|
||||
|
||||
```go
|
||||
// staffCreateAccountUser gives an account an HQ login.
|
||||
//
|
||||
// This is how a legacy cloud customer — one whose instance predates HQ accounts
|
||||
// — gets into the portal, alongside the manual instance attach the spec README
|
||||
// describes. It reuses CreateCustomerUser, so the row is unverified until the
|
||||
// emailed link is opened and is rolled back if that email cannot be sent.
|
||||
func staffCreateAccountUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "email and a password of at least 12 characters are required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
accountID := c.Param("id")
|
||||
|
||||
if n, err := db.Admin("accounts").CountDocuments(ctx,
|
||||
bson.M{"account_id": accountID}); err != nil || n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `strings` is imported in `staff.go`; add it if not:
|
||||
|
||||
```sh
|
||||
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**
|
||||
|
||||
```bash
|
||||
git add -A admin/
|
||||
git commit -m "feat(admin): drop the unscoped control-plane login branch
|
||||
|
||||
HQ sign-in names no instance, so a lookup of control-plane users by email
|
||||
alone cannot be scoped — and users.email is no longer globally unique, so
|
||||
it would return an arbitrary match. Every customer authenticates against
|
||||
customer_users instead.
|
||||
|
||||
Legacy cloud customers get an HQ login from staff via the new
|
||||
POST /api/staff/accounts/:id/users, alongside the manual instance attach
|
||||
the spec README already describes.
|
||||
|
||||
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.
|
||||
|
||||
This is the task that proves the change. With no test suite, this transcript is the only evidence, so run it in full rather than skimming it.
|
||||
|
||||
- [ ] **Step 1: Update `CLAUDE.md`**
|
||||
|
||||
In the **Auth and Orgs** section, replace the paragraph beginning "Unique indexes on user email and org slug are a **security property**" with:
|
||||
|
||||
```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,
|
||||
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
|
||||
unscoped lookup anywhere, and adding one would let the login path return an
|
||||
arbitrary one of several matching users. Instance slug, settings instance and ESO
|
||||
token hash remain globally unique.
|
||||
```
|
||||
|
||||
In the **Security** section, replace the "Unique indexes on user email, org slug…" bullet with:
|
||||
|
||||
```markdown
|
||||
- Unique indexes on `(instance_id, email)`, instance slug, settings instance and the ESO token hash are load-bearing for tenant isolation. So is the absence of any unscoped lookup by email.
|
||||
```
|
||||
|
||||
In the **MongoDB Collections** notes, add:
|
||||
|
||||
```markdown
|
||||
- `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.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build both images**
|
||||
|
||||
```sh
|
||||
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**
|
||||
|
||||
Redis is not optional here: the server stores sessions in it, so every sign-in below fails without it.
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-redis -p 6389:6379 redis:7
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-mongo -p 27023:27017 mongo:7
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
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**
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-server -p 8091:8080 \
|
||||
-e MONGO_URI=mongodb://host.docker.internal:27023 -e MONGO_DB=vantage_idx \
|
||||
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6389 \
|
||||
-e GITEA_HOST=example.invalid \
|
||||
--add-host host.docker.internal:host-gateway vantage-server:test
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
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**
|
||||
|
||||
```sh
|
||||
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**
|
||||
|
||||
```sh
|
||||
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
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
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**
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval '
|
||||
const a = db.users.findOne({email:"shared@example.com"});
|
||||
const bId = UUID().toString().replace(/[{}]/g,"");
|
||||
db.instances.insertOne({instance_id:bId, name:"Beta", slug:"beta", created_at:new Date()});
|
||||
db.users.insertOne({
|
||||
user_id: UUID().toString().replace(/[{}]/g,""),
|
||||
instance_id: bId,
|
||||
email: "shared@example.com",
|
||||
password_hash: a.password_hash,
|
||||
role: "owner",
|
||||
auth_source: "local",
|
||||
created_at: new Date()
|
||||
});
|
||||
print("beta instance " + bId);
|
||||
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**
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval '
|
||||
const a = db.users.findOne({email:"shared@example.com"});
|
||||
try {
|
||||
db.users.insertOne({user_id:"dup", instance_id:a.instance_id,
|
||||
email:"shared@example.com", role:"member", auth_source:"local", created_at:new Date()});
|
||||
print("FAIL: duplicate accepted");
|
||||
} 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**
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://localhost:8091/auth/login -H 'Host: alpha.vantage.test' \
|
||||
-H 'Content-Type: application/json' -c /tmp/alpha.jar \
|
||||
-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
|
||||
curl -s -X POST http://localhost:8091/auth/login -H 'Host: beta.vantage.test' \
|
||||
-H 'Content-Type: application/json' -c /tmp/beta.jar \
|
||||
-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.
|
||||
|
||||
- [ ] **Step 10: Confirm the apex host refuses rather than guesses**
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/login \
|
||||
-H 'Host: vantage.test' -H 'Content-Type: application/json' \
|
||||
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
|
||||
```
|
||||
Expected: `400`. Then read the message:
|
||||
|
||||
```sh
|
||||
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**
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/login \
|
||||
-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**
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
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
|
||||
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**
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-admin -p 8093:8083 \
|
||||
-e ADMIN_MONGO_URI=mongodb://host.docker.internal:27023/vantage_idx_admin \
|
||||
-e CONTROL_MONGO_URI=mongodb://host.docker.internal:27023/vantage_idx \
|
||||
-e REDIS_ADDR=host.docker.internal:6389 \
|
||||
-e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" \
|
||||
-e PUBLIC_URL=http://localhost:8093 -e ADMIN_ORIGIN=http://localhost:3004 \
|
||||
--add-host host.docker.internal:host-gateway vantage-admin:test
|
||||
|
||||
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
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8093/auth/login \
|
||||
-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**
|
||||
|
||||
```sh
|
||||
MSYS_NO_PATHCONV=1 docker rm -f vantage-idx-server vantage-idx-admin vantage-idx-mongo vantage-idx-redis
|
||||
```
|
||||
|
||||
- [ ] **Step 15: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: users is unique per instance, not globally
|
||||
|
||||
The old index was load-bearing because two lookups were unscoped. Both
|
||||
are scoped now and the unscoped helper is gone, so the property that
|
||||
matters is the absence of any unscoped lookup by email. Says so, and
|
||||
documents auth_source hq.
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done when
|
||||
|
||||
- `instance_email_unique` exists on `users`, `email_1` does not, and a second boot is a no-op.
|
||||
- Two users share one address across two instances, and each signs in to their own.
|
||||
- A duplicate address within one instance is still refused.
|
||||
- The apex host refuses to guess when several instances exist, and still works when only one does.
|
||||
- `grep -rn "GetUserByEmail"` and `grep -rn "HandleCloudLogin"` both return nothing.
|
||||
- `admin` boots and `/auth/login` answers `401` rather than `500`.
|
||||
- `CLAUDE.md` no longer claims `users.email` is globally unique.
|
||||
|
||||
**Not proven by this plan:** the OIDC sign-in path, which needs a real identity provider. Verify it manually on the first SSO sign-in after deployment — an existing SSO user must still land in their own instance.
|
||||
|
||||
## Not in this phase
|
||||
|
||||
`POST /api/instances`, the Free lifecycle, renewal, the notices, the reaper, the sitesvc cutover, account roles, invitations, instance membership, password propagation, and every UI change. Phases 2 and 3 get their own plans once this one lands.
|
||||
@@ -0,0 +1,498 @@
|
||||
# Cloud instance creation and account membership — design
|
||||
|
||||
Spec 6. Designed 2026-07-26. Depends on specs 0a, 0b, 1, 2 and 3, all shipped.
|
||||
|
||||
## The problem
|
||||
|
||||
`https://vantage.hostxtra.co.uk/start` provisions a control-plane instance the
|
||||
moment a customer opens the verification email. It creates nothing on the admin
|
||||
side: no `accounts` row, no `admin_instances` row, no licence. Every cloud
|
||||
customer who signs up today therefore lands on an unlicensed instance that spec 2
|
||||
degrades to read-only, and staff must attach it by hand afterwards.
|
||||
|
||||
The fix is not to bolt licence issuance onto the existing verification handler.
|
||||
It is to separate the two things that flow has conflated — **having an account**
|
||||
and **having an instance** — so that the account exists first and the instance is
|
||||
something the customer asks for.
|
||||
|
||||
Once accounts are real, a second thing follows: an account has *people* in it,
|
||||
and those people need access to the account's instances. That is what forces the
|
||||
`users.email` change below, and it is the largest single item in this spec.
|
||||
|
||||
## The new flow
|
||||
|
||||
```
|
||||
site/start form ──POST──▶ admin /auth/signup
|
||||
account name, email, password
|
||||
→ accounts row + unverified customer_users row (account_role owner)
|
||||
→ verification email; nothing written to the control plane
|
||||
|
||||
verification link ──▶ admin /auth/verify
|
||||
→ customer_users.verified_at set
|
||||
→ customer signs in at vantage-hq.hostxtra.co.uk
|
||||
|
||||
HQ portal, "Create instance" ──POST──▶ admin /api/instances
|
||||
→ control-plane instances + users (creator becomes owner)
|
||||
→ admin_instances row + instance_members row
|
||||
→ Free licence issued, injected, emailed as "your instance is ready"
|
||||
|
||||
HQ portal, "Invite" and "Add to instance"
|
||||
→ more customer_users on the account
|
||||
→ each grant projects a control-plane users row into that cloud instance
|
||||
```
|
||||
|
||||
Signup itself needs almost no new code: `auth.HandleSignup` already creates an
|
||||
account, an unverified `customer_users` row and a verification email, and was
|
||||
written for self-hosted customers. It turns out to be exactly the account-first
|
||||
signup cloud needs.
|
||||
|
||||
This supersedes the "Signup migration off sitesvc" section of spec 5
|
||||
(`2026-07-24-paddle-billing-design.md`). That section moved the *existing*
|
||||
signup-provisions-an-instance flow to admin unchanged; this changes its shape.
|
||||
Spec 5's Paddle work is unaffected and layers on top: the £0 Free subscription
|
||||
and the Paddle customer are created where this spec issues the Free licence.
|
||||
|
||||
Paddle is explicitly **out of scope here**. Accounts created by this spec have an
|
||||
empty `PaddleCustomerID`, which spec 5's account model already permits.
|
||||
|
||||
## Phasing
|
||||
|
||||
Three phases, each shippable, in this order. The plan should not interleave them
|
||||
— phase 1 changes an index that everything else then depends on.
|
||||
|
||||
1. **Identity** — drop the global email index, scope the two unscoped lookups,
|
||||
add the `hq` fields to the user document, and remove admin's unscoped
|
||||
control-plane login branch. No new UI, and nothing is projected yet.
|
||||
2. **Instance creation and Free lifecycle** — `POST /api/instances`, renewal,
|
||||
notices, the reaper, the sitesvc cutover.
|
||||
3. **Membership** — account roles, invitations, per-instance grants, password
|
||||
propagation.
|
||||
|
||||
## Phase 1 — identity
|
||||
|
||||
### Dropping the global email index
|
||||
|
||||
`users.email` currently carries a unique index **across the whole control
|
||||
plane**. `CLAUDE.md` names it a security property, and it is one today. It is
|
||||
also what makes "an account's people belong to several instances" impossible:
|
||||
one address can own exactly one user document anywhere.
|
||||
|
||||
It is replaced by a unique compound index on `(instance_id, email)`, which is the
|
||||
constraint that was actually wanted: one address is one user *within an
|
||||
instance*.
|
||||
|
||||
The global index is only load-bearing because two lookups are unscoped. Both are
|
||||
scoped instead, and the scoped lookups are a strictly stronger guarantee than the
|
||||
index was — an index prevents the ambiguity, whereas a scoped query cannot be
|
||||
ambiguous in the first place.
|
||||
|
||||
| Caller | Today | After |
|
||||
|---|---|---|
|
||||
| `auth.HandleLocalLogin` | `GetUserByEmail(email)` | resolve the instance, then `GetUserInInstanceByEmail` |
|
||||
| `auth.HandleOIDCCallback` | `GetUserByEmail(email)`, then a cross-instance guard | `GetUserInInstanceByEmail(instanceID, …)`; the guard is deleted as unreachable |
|
||||
|
||||
`services.GetUserByEmail` is **deleted**, not merely left unused. Leaving an
|
||||
unscoped helper in place is how this bug comes back.
|
||||
|
||||
Resolving the instance for local login:
|
||||
|
||||
1. `InstanceFromHost` — always succeeds on cloud, where every instance has its
|
||||
own subdomain.
|
||||
2. Otherwise, if exactly one instance exists, use it. This is the self-hosted
|
||||
case, which is single-instance by construction because a licence binds one
|
||||
instance UUID.
|
||||
3. Otherwise refuse with a message naming the cause, rather than guessing.
|
||||
|
||||
### The index migration
|
||||
|
||||
In `shared/indexes.EnsureCoreIndexes`, in this order:
|
||||
|
||||
1. Create the unique compound index on `(instance_id, email)`. Fatal on failure.
|
||||
2. Drop `email_1` if present, ignoring `IndexNotFound` so it is idempotent.
|
||||
|
||||
Creating before dropping means a failure at step 2 leaves both indexes in place,
|
||||
which is safe. A failure at step 1 leaves the old index alone, which is also
|
||||
safe.
|
||||
|
||||
`EnsureCoreIndexes` is called at boot by server, sitesvc and admin, so **all
|
||||
three images must ship together**. `server-deploy.yml` rebuilds every image on
|
||||
every push to `main`, so this happens by default; the risk is only a partial
|
||||
manual rollout on the host.
|
||||
|
||||
**This migration is one-way.** Once two users share an address across instances,
|
||||
`email_1` cannot be recreated. Rolling the server back past this change would
|
||||
leave unscoped lookups running against data that can now be ambiguous. The
|
||||
rollback plan is forward-only: fix and redeploy.
|
||||
|
||||
`sitesvc.EmailTaken` also does an unscoped count over `users`. It disappears with
|
||||
sitesvc's signup in phase 2.
|
||||
|
||||
`admin.HandleCloudLogin`'s control-plane branch does an unscoped
|
||||
`FindOne({email})` too, and unlike the other two there is no instance in context
|
||||
to scope it by — HQ login is not per-instance. **That branch is deleted.** Every
|
||||
customer created by this spec has a `customer_users` row, which already wins in
|
||||
the existing precedence. Legacy cloud customers are handled by staff, who already
|
||||
attach their instances by hand per the spec README, and who gain
|
||||
`POST /api/staff/accounts/:id/users` to create their HQ login.
|
||||
|
||||
### The control-plane user document
|
||||
|
||||
`shared/models.User` gains:
|
||||
|
||||
- `hq_user_id` — the `customer_users.user_id` this row was projected from, absent
|
||||
on locally-created users.
|
||||
- `auth_source: "hq"` as a third value alongside `local` and `oidc`.
|
||||
|
||||
An `hq`-sourced user is **managed in HQ, not in the instance**. The control plane
|
||||
refuses to change its role, delete it, or change its password through
|
||||
`/api/instance/users`, answering with "managed in Vantage HQ". `web/` renders
|
||||
those rows read-only with the same label. Locally-created users are unaffected
|
||||
and stay fully editable in the instance — a cloud instance can hold both kinds.
|
||||
|
||||
This gives one owner per fact. A role that is editable in two places is a role
|
||||
with two answers.
|
||||
|
||||
## Phase 2 — instance creation
|
||||
|
||||
`POST /api/instances`, customer session, `account_role` owner or admin,
|
||||
body `{ "name": "..." }`.
|
||||
|
||||
In order, each step undoing the previous on failure:
|
||||
|
||||
1. Refuse if the account already holds a non-cancelled Free instance — a
|
||||
pre-check of the same rule `licensing.checkFreeLimit` enforces, so we never
|
||||
create an instance we then cannot licence. `409`.
|
||||
2. Read the caller's `customer_users` row for its bcrypt hash.
|
||||
3. `provision.CreateInstance` — control-plane instance and slug.
|
||||
4. `provision.CreateUserWithHash(…, RoleOwner, "hq")` with that hash and
|
||||
`hq_user_id`. On failure, `provision.RollbackInstance`.
|
||||
5. Insert `admin_instances`, then `instance_members` for the creator. On failure,
|
||||
delete the control-plane user, then roll back the instance.
|
||||
6. `licensing.Issue{Tier: free, Term: "monthly", Reason: ReasonNew, IssuedBy:
|
||||
"self-serve"}`, then `inject.Deliver`.
|
||||
7. Email the creator: instance URL, sign-in address, licence expiry date.
|
||||
|
||||
Steps 6 and 7 do **not** fail the request. A licence that was not issued is
|
||||
recoverable — the instance exists, the customer can sign in, they see spec 2's
|
||||
licence banner, and staff can issue by hand. Failing the whole creation and
|
||||
rolling back an instance the customer can already see would be worse. This
|
||||
matches the rule spec 5 states for the same pair of failures: both outcomes
|
||||
resolve toward "the customer gets in".
|
||||
|
||||
### Admin's control-plane write boundary
|
||||
|
||||
`inject`'s package doc says plainly that a second write target into the control
|
||||
plane "is a design change and not a refactor". This is that design change, and it
|
||||
is made explicitly rather than by widening `inject`.
|
||||
|
||||
Provisioning and membership projection live in a **new package,
|
||||
`admin/internal/cloudprov`**. `inject` is left untouched, still writing exactly
|
||||
three licence fields on `instances`. `db` gains a `ControlDB() *mongo.Database`
|
||||
accessor, because `shared/provision` takes a database rather than a collection.
|
||||
|
||||
`cloudprov` writes exactly three things: instance documents (create and roll
|
||||
back), user documents (create, delete, update role and password hash), and
|
||||
nothing else. `CLAUDE.md`'s description of the boundary is updated in the same
|
||||
commit, because it currently claims admin's control-plane access is read-only
|
||||
apart from three licence fields, and that stops being true here.
|
||||
|
||||
## Phase 2 — Free lifecycle
|
||||
|
||||
A Free licence runs for one month plus the existing three-day `GracePeriod`,
|
||||
using the `"monthly"` term `licensing.Issue` already implements. No new term
|
||||
value. One Free instance per account, unchanged.
|
||||
|
||||
### Renewal
|
||||
|
||||
`POST /api/instances/:id/renew`, through `ownedInstance`, account owner or admin.
|
||||
|
||||
- Tier must be Free. Paid tiers renew through billing, not here.
|
||||
- Allowed once `now > expires_at - 7d`, and at any point after that up to
|
||||
deletion — so the same button rescues a lapsed instance rather than needing a
|
||||
second mechanism.
|
||||
- Reissues Free with `Reason: ReasonRenewal`, injects, emails the new date.
|
||||
|
||||
Renewal is deliberately manual. It is the entire reclaim signal: an instance
|
||||
nobody renews is an instance nobody is using.
|
||||
|
||||
### Status and notices
|
||||
|
||||
`admin_instances.status` gains `deleted`. A sweep in admin flips `active` to
|
||||
`lapsed` when the current licence's `expires_at` passes, and the existing
|
||||
15-minute reconciler — which already logs "no control-plane instance X" — flips
|
||||
those to `deleted` and clears their `instance_members` rows instead of only
|
||||
logging.
|
||||
|
||||
Four emails to the account's owners and admins, driven by `expires_at`:
|
||||
|
||||
| When | Says |
|
||||
|---|---|
|
||||
| 7 days before expiry | Renew, one click, here is the link |
|
||||
| on expiry | Read-only now; deleted in 14 days unless renewed |
|
||||
| 7 days before deletion | Deleted in 7 days |
|
||||
| 1 day before deletion | Deleted tomorrow |
|
||||
|
||||
Each send is recorded on the `admin_instances` document, so a restart or a double
|
||||
tick cannot re-send one. Renewal clears the record, so the next term starts the
|
||||
sequence again.
|
||||
|
||||
## Phase 2 — deletion
|
||||
|
||||
Deletion is the only irreversible path in the system, so it is owned by the
|
||||
service that knows what an instance is made of.
|
||||
|
||||
**The reaper runs in the control plane, not in admin.** Admin already injects
|
||||
`license_tier` and `license_expiry` onto the instance document, so the server
|
||||
drives off data it holds locally, and the list of collections carrying
|
||||
`instance_id` stays in the codebase that defines them. Mirroring that list into
|
||||
admin would be exactly the class of duplication `CLAUDE.md` already warns about
|
||||
for slug rules and design tokens — except a divergence here deletes the wrong
|
||||
rows or leaves orphans behind.
|
||||
|
||||
The sweep, in `server/internal/services`:
|
||||
|
||||
- Eligible when `license_tier == "free"` **and** `license_expiry` is present
|
||||
**and** `license_expiry` is more than the configured window in the past.
|
||||
- Purges the instance document, its users, and every `instance_id`-scoped
|
||||
document across the collections listed in `CLAUDE.md`. Workflow run logs on
|
||||
disk go with them.
|
||||
- Fail-safe by construction. An instance whose licence issuance failed has no
|
||||
`license_tier` and is never eligible. A paid instance is never eligible. An
|
||||
instance admin has not reached yet keeps whatever expiry was last injected, and
|
||||
admin's reconciler keeps that field current.
|
||||
- Every purge writes an audit entry before deleting, and logs the instance ID,
|
||||
slug and document counts.
|
||||
- Admin's reconciler notices the instance has gone and cleans up its own
|
||||
`admin_instances` status and `instance_members` rows.
|
||||
|
||||
### The kill switch
|
||||
|
||||
Gated on `FREE_INSTANCE_REAP_AFTER`, a duration. **Empty disables the sweep
|
||||
entirely**, and empty is the default.
|
||||
|
||||
It is unset in `deploy/docker-compose.yml` and set to `336h` only in
|
||||
`deploy/docker-compose.site.yml`, so a self-hosted deployment can never reap
|
||||
anything — the same containment rule that keeps `LICENSE_SIGNING_KEY` in exactly
|
||||
one service in exactly one compose file.
|
||||
|
||||
## Phase 3 — accounts, people and membership
|
||||
|
||||
### The model
|
||||
|
||||
```
|
||||
Account
|
||||
├── customer_users the people. account_role: owner | admin | member
|
||||
└── admin_instances the deployments
|
||||
└── instance_members which people are on which cloud instance
|
||||
```
|
||||
|
||||
`customer_users` gains `account_role`. Existing rows backfill to `owner` — they
|
||||
are all account creators today. Owners and admins may invite users, create
|
||||
instances, and grant instance access; billing stays owner-only. The vocabulary
|
||||
deliberately matches the control plane's own three roles rather than inventing a
|
||||
second one.
|
||||
|
||||
`instance_members` is new: `{member_id, account_id, instance_id,
|
||||
customer_user_id, role, control_user_id, created_at}`, unique on
|
||||
`(instance_id, customer_user_id)`. `role` is the role the projected
|
||||
control-plane user holds inside the instance.
|
||||
|
||||
### Grants project, they do not federate
|
||||
|
||||
Granting a user access to a cloud instance creates a real control-plane `users`
|
||||
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
|
||||
instance authenticates it exactly as it authenticates any other user, with no
|
||||
runtime dependency on admin. Revoking deletes that row.
|
||||
|
||||
**Self-hosted instances are never projected into.** `POST /api/instances/:id/
|
||||
members` refuses when `deployment != cloud`, with that as the message. For a
|
||||
self-hosted instance the account's users exist to manage the licence, and the
|
||||
instance's own users are managed locally in the customer's own deployment, which
|
||||
we cannot see and have no business writing to.
|
||||
|
||||
Endpoints, all customer-session and all through `ownedInstance` where an instance
|
||||
is named:
|
||||
|
||||
```
|
||||
GET,POST /api/account/users invite; owner|admin
|
||||
PUT /api/account/users/:id/role owner|admin; cannot demote the last owner
|
||||
DELETE /api/account/users/:id owner|admin; revokes every grant first
|
||||
PUT /api/account/password any user; propagates
|
||||
GET,POST /api/instances/:id/members owner|admin
|
||||
PUT /api/instances/:id/members/:uid/role
|
||||
DELETE /api/instances/:id/members/:uid
|
||||
```
|
||||
|
||||
Invitations reuse `auth.CreateCustomerUser`, which already does the
|
||||
unverified-row-plus-verification-email dance and already deletes the row if the
|
||||
email fails to send. A user cannot be granted an instance until verified.
|
||||
|
||||
Revoking the last **owner** of an instance is refused, mirroring the control
|
||||
plane's own `ErrLastOwner`. The check counts control-plane owners for that
|
||||
instance, so it also sees owners created locally inside the instance.
|
||||
|
||||
### Password propagation
|
||||
|
||||
The HQ password is the single source of truth for every `hq`-sourced row.
|
||||
|
||||
`PUT /api/account/password` rehashes at cost 12, updates `customer_users`, then
|
||||
has `cloudprov` write the same hash to every control-plane user carrying that
|
||||
`hq_user_id`. The instance refuses to change an `hq`-sourced user's password
|
||||
locally, so there is no competing writer.
|
||||
|
||||
Propagation is best-effort and retried, on exactly the pattern `inject` already
|
||||
proves: a failure is logged and flagged, and admin's 15-minute reconciler gains a
|
||||
pass that compares each `hq`-sourced row's hash against its `customer_users`
|
||||
source and repairs mismatches. The worst case is a stale password on one instance
|
||||
for up to fifteen minutes, which is recoverable; failing the password change
|
||||
because one of three instances was unreachable is not.
|
||||
|
||||
## Frontend
|
||||
|
||||
### `site/`
|
||||
|
||||
`components/InstanceForm.tsx` becomes `AccountForm.tsx`: account name, email,
|
||||
password, honeypot. It posts to `NEXT_PUBLIC_ADMIN_API_URL/auth/signup` rather
|
||||
than to sitesvc. The live `your-instance.vantage.hostxtra.co.uk` slug preview
|
||||
goes — there is no instance yet at this point, and showing one would be a lie.
|
||||
|
||||
`app/start/page.tsx` copy changes from "Set up your instance" to creating an
|
||||
account, and its "What happens next" panel gains the create-an-instance step
|
||||
between confirming the email and adding a key.
|
||||
|
||||
`ADMIN_API_URL` gains a browser-reachable presence in the `site` image build, and
|
||||
`site`'s origin must be listed in admin's `ADMIN_ORIGIN`. Both are new failure
|
||||
modes with the same footgun `CLAUDE.md` already documents for `SITE_API_URL`.
|
||||
`SITE_API_URL` still serves the contact form.
|
||||
|
||||
### `adminsite/`
|
||||
|
||||
- `(customer)/page.tsx` — the "No instances yet" panel gains a primary **Create a
|
||||
free instance** action. Hidden once the account holds a Free instance, with the
|
||||
reason stated rather than the button silently absent.
|
||||
- `(customer)/instances/new/` — name field and a live slug preview of the
|
||||
resulting `<slug>.vantage.hostxtra.co.uk`.
|
||||
- `(customer)/instances/[id]/` — a members panel: who is on this instance, their
|
||||
role, add and remove. Absent for self-hosted instances, replaced by a line
|
||||
saying users are managed inside the install.
|
||||
- `(customer)/users/` — the account's people, invitations, account roles.
|
||||
- `(customer)/settings/` — change password, with a note that it applies to every
|
||||
instance you belong to.
|
||||
- `components/InstanceCard.tsx` — expiry date, a **Renew** action inside the
|
||||
window, and a deletion countdown when lapsed. Per `CLAUDE.md`'s rule, licence
|
||||
state never reads by colour alone; the countdown is a text label.
|
||||
- `lib/api.ts` — the new calls, `"deleted"` on `InstanceStatus`, and an
|
||||
`AccountRole` type.
|
||||
|
||||
### `web/`
|
||||
|
||||
`settings/instance` gains the read-only treatment for `hq`-sourced users: role
|
||||
shown, controls disabled, labelled "managed in Vantage HQ" with a link to the
|
||||
portal. Everything else is unchanged; spec 2's licence banner already covers a
|
||||
lapsed instance.
|
||||
|
||||
## sitesvc
|
||||
|
||||
Signup, verify, `site_pending_signups`, `EmailTaken` and the provisioning calls
|
||||
are deleted. sitesvc keeps the contact form only, and drops `APP_LOGIN_URL`.
|
||||
|
||||
The staged cutover from spec 5 applies unchanged, and matters for the same
|
||||
reason: an in-flight verification link must not break.
|
||||
|
||||
1. Deploy admin. Its signup already exists; nothing to enable.
|
||||
2. Point `site/start` at admin. Deploy `site`.
|
||||
3. Wait for sitesvc's outstanding pending signups to expire — 24 hours — with its
|
||||
verify endpoint still live. **Do not delete the collection until it is empty.**
|
||||
4. Deploy sitesvc with signup and verify removed.
|
||||
|
||||
A signup that completes through the old path during step 3 produces an instance
|
||||
with no account and no licence, exactly as today. Staff attach those by hand, the
|
||||
same job the README already describes for existing cloud tenants.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Service | Variable | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| admin | `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template, used in the instance-ready email |
|
||||
| server | `FREE_INSTANCE_REAP_AFTER` | no | duration past expiry before a Free instance is purged. **Empty disables the reaper**, and empty is the default. `336h` in `docker-compose.site.yml` only |
|
||||
| site build | `ADMIN_API_URL` | yes | browser-reachable; must be in admin's `ADMIN_ORIGIN` |
|
||||
| sitesvc | `APP_LOGIN_URL` | — | removed |
|
||||
|
||||
## Testing
|
||||
|
||||
Phase 1, identity:
|
||||
|
||||
1. The compound index exists and `email_1` is gone after one boot; a second boot
|
||||
is a no-op.
|
||||
2. Two users with the same address in different instances can both be created and
|
||||
both sign in, each landing in their own instance.
|
||||
3. Two users with the same address in one instance are refused by the index.
|
||||
4. Local login on a cloud subdomain finds only that instance's user; the same
|
||||
address on another instance is not reachable from this host.
|
||||
5. Local login on a bare host with one instance works; with two it refuses with a
|
||||
named cause rather than picking one.
|
||||
6. OIDC provisions into the instance from the callback state, and an address
|
||||
belonging to another instance no longer produces a cross-org error because it
|
||||
is simply not found — it provisions a new member instead, which is correct.
|
||||
7. `GetUserByEmail` no longer exists.
|
||||
|
||||
Phase 2, creation and lifecycle:
|
||||
|
||||
8. Signup writes nothing to `instances` or `users`; only the emailed link makes
|
||||
the account usable.
|
||||
9. Creating an instance produces an instance, an `hq`-sourced owner user, an
|
||||
`admin_instances` row, an `instance_members` row, a Free licence, and an
|
||||
injected `license_blob`.
|
||||
10. The creator can sign in to the new instance with their HQ password.
|
||||
11. A second Free instance on the same account is refused `409` and writes
|
||||
nothing.
|
||||
12. Owner-insert failure rolls the instance back, and rollback refuses an
|
||||
instance that has users.
|
||||
13. Licence issuance failure still leaves a signed-in-able instance and flags for
|
||||
staff.
|
||||
14. Renew outside the window is refused; inside it, it supersedes, injects and
|
||||
moves `expires_at` forward by a month plus grace.
|
||||
15. Renewing a lapsed instance restores it before the reaper takes it.
|
||||
16. Each notice sends once across a restart.
|
||||
|
||||
Phase 2, the reaper — the part that must be got right:
|
||||
|
||||
17. With `FREE_INSTANCE_REAP_AFTER` empty, nothing is ever deleted.
|
||||
18. An instance with no `license_tier` is never eligible, whatever its age.
|
||||
19. A Professional instance past expiry is never eligible.
|
||||
20. A Free instance one hour short of the window is not deleted; one hour past it
|
||||
is.
|
||||
21. A purge leaves no document carrying that `instance_id` in any collection, and
|
||||
writes an audit entry first.
|
||||
22. Purging is idempotent — a second run over a half-deleted instance completes
|
||||
it rather than erroring.
|
||||
|
||||
Phase 3, membership:
|
||||
|
||||
23. An invited user cannot be granted an instance until verified.
|
||||
24. A grant creates a control-plane user that can sign in to that instance with
|
||||
the invitee's HQ password.
|
||||
25. The same user can hold rows in two instances at once, with different roles.
|
||||
26. Revoking deletes the control-plane row, and that user can no longer sign in
|
||||
to that instance while keeping access to the others.
|
||||
27. Revoking or demoting an instance's last owner is refused, including when that
|
||||
owner was created locally inside the instance.
|
||||
28. Granting against a self-hosted instance is refused and writes nothing to the
|
||||
customer's deployment.
|
||||
29. A `member` cannot invite, create instances, or grant access.
|
||||
30. A password change propagates to every linked instance; with one instance's
|
||||
write forced to fail, the reconciler repairs it within one pass.
|
||||
31. An `hq`-sourced user's role, deletion and password are refused inside the
|
||||
instance API, not merely hidden in `web/`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Dropping `email_1` is one-way and weakens a documented security property | Scoped lookups ship in the same binary that drops the index; the unscoped helper is deleted so it cannot be reintroduced; the compound index restores the equivalent guarantee; rollback plan is forward-only and stated |
|
||||
| A partial rollout leaves an old service recreating `email_1` | All three services call `EnsureCoreIndexes`; `server-deploy.yml` rebuilds every image per push; the host rollout command already updates all services together |
|
||||
| Reaper deletes a live instance | Kill switch defaults off; eligibility needs an explicitly-Free tier and a present expiry; unset fields are never eligible; four warning emails precede it |
|
||||
| Admin's widened control-plane write access grows further | Confined to `cloudprov`, which writes instances and users and nothing else; `inject` untouched; `CLAUDE.md` updated to say so |
|
||||
| Password propagation leaves an instance stale | Reconciler pass compares and repairs; worst case is fifteen minutes; the instance refuses local changes so there is no competing writer |
|
||||
| A projected user is edited in both places | `hq`-sourced rows are refused by the instance API, not merely hidden in the UI |
|
||||
| Cutover breaks an in-flight verification link | sitesvc's verify stays live until its collection is empty |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Vantage Licensing Programme — Spec Index
|
||||
|
||||
Seven specs, designed 2026-07-24. Build in this order.
|
||||
Build in this order. Specs 0a–5 were designed 2026-07-24; spec 6 on 2026-07-26.
|
||||
|
||||
| # | Spec | Plan | Status |
|
||||
|---|---|---|---|
|
||||
@@ -10,7 +10,8 @@ Seven specs, designed 2026-07-24. Build in this order.
|
||||
| 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) | — | ready to start |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start; its "signup migration off sitesvc" section is superseded by 6 |
|
||||
| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start |
|
||||
|
||||
Specs 1 and 2 together give working licensing with licences cut by hand with
|
||||
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
|
||||
|
||||
@@ -113,6 +113,8 @@ func main() {
|
||||
|
||||
monitorsched.Start(context.Background())
|
||||
|
||||
services.StartReaper(context.Background())
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
|
||||
@@ -22,6 +22,35 @@ func SetSessionCookie(c *gin.Context, sessionID string) {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveLoginInstance decides which instance a sign-in attempt belongs to.
|
||||
//
|
||||
// Cloud always answers from the host: every instance has its own subdomain, and
|
||||
// APP_LOGIN_URL fills the slug in, so every sign-in link already points at one.
|
||||
// Self-hosted has no subdomain and exactly one instance, because a licence
|
||||
// binds one instance UUID.
|
||||
//
|
||||
// Anything else is refused rather than guessed. Picking an instance on someone's
|
||||
// behalf is how you sign them into the wrong tenant.
|
||||
func resolveLoginInstance(c *gin.Context) (string, error) {
|
||||
if inst, ok := InstanceFromHost(c); ok {
|
||||
return inst.InstanceID, nil
|
||||
}
|
||||
n, err := services.CountInstances()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n != 1 {
|
||||
return "", fmt.Errorf(
|
||||
"cannot tell which instance this sign-in is for: %d instances exist and the host %q names none of them; sign in at your instance's own address",
|
||||
n, c.Request.Host)
|
||||
}
|
||||
inst, err := services.FirstInstance()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return inst.InstanceID, nil
|
||||
}
|
||||
|
||||
func HandleLocalLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
@@ -31,7 +60,12 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserByEmail(body.Email)
|
||||
instanceID, err := resolveLoginInstance(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserInInstanceByEmail(instanceID, body.Email)
|
||||
if err != nil || !services.VerifyPassword(u, body.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -127,17 +128,18 @@ func HandleOIDCCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
email := strings.ToLower(claims.Email)
|
||||
u, err := services.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
|
||||
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
|
||||
// Scoped to the instance the callback state names, so an address that also
|
||||
// exists in another instance is invisible here. That scoping replaces the
|
||||
// cross-instance guard this code used to need: there is no longer a way for
|
||||
// the lookup to return a user belonging to somebody else.
|
||||
u, err := services.GetUserInInstanceByEmail(instanceID, email)
|
||||
if err != nil {
|
||||
u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
|
||||
return
|
||||
}
|
||||
} else if u.InstanceID != instanceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := SaveSession(ctx, &Session{
|
||||
|
||||
@@ -10,4 +10,10 @@ const (
|
||||
RoleMember = shared.RoleMember
|
||||
)
|
||||
|
||||
const (
|
||||
AuthLocal = shared.AuthLocal
|
||||
AuthOIDC = shared.AuthOIDC
|
||||
AuthHQ = shared.AuthHQ
|
||||
)
|
||||
|
||||
func ValidRole(role string) bool { return shared.ValidRole(role) }
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ReapInterval is how often eligibility is re-checked. Deletion is measured in
|
||||
// days, so an hour is ample and keeps the query cheap.
|
||||
const ReapInterval = time.Hour
|
||||
|
||||
// scopedCollectionsForPurge is ScopedCollections minus `instances` itself.
|
||||
//
|
||||
// Derived rather than duplicated on purpose. ScopedCollections is the canonical
|
||||
// list and AssertNoScopedCollectionMissed fails boot when a collection outside
|
||||
// it holds a tenant key; a second hand-maintained copy here would silently miss
|
||||
// whatever that assertion catches, leaking rows that outlive their instance.
|
||||
//
|
||||
// `instances` is excluded because it is keyed by instance_id rather than scoped
|
||||
// by it, and is deleted last so an interrupted purge is retried rather than
|
||||
// orphaning rows.
|
||||
func scopedCollectionsForPurge() []string {
|
||||
out := make([]string, 0, len(ScopedCollections))
|
||||
for _, name := range ScopedCollections {
|
||||
if name == "instances" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reapAfter reads FREE_INSTANCE_REAP_AFTER.
|
||||
//
|
||||
// An empty or unparseable value returns 0, which disables the reaper. Defaulting
|
||||
// OFF is the whole safety design: a deployment that never heard of this variable
|
||||
// must never delete a customer's instance.
|
||||
func reapAfter() time.Duration {
|
||||
v := os.Getenv("FREE_INSTANCE_REAP_AFTER")
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Printf("reaper: FREE_INSTANCE_REAP_AFTER %q is not a duration; reaper stays OFF", v)
|
||||
return 0
|
||||
}
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// purgeInstance deletes an instance and every document scoped to it.
|
||||
//
|
||||
// Unexported and unguarded: it trusts its caller completely and performs an
|
||||
// irreversible delete on whatever instance ID it is handed. The tier and expiry
|
||||
// gate — Free tier, an expiry that exists, an expiry past the window — lives in
|
||||
// ReapFreeInstances, which is the only caller. Do not export this.
|
||||
//
|
||||
// Idempotent: re-running over a half-deleted instance completes it. The instance
|
||||
// document goes last, so an interrupted purge is retried on the next sweep
|
||||
// instead of leaving rows behind with nothing pointing at them.
|
||||
func purgeInstance(ctx context.Context, instanceID string) (map[string]int64, error) {
|
||||
counts := map[string]int64{}
|
||||
for _, name := range scopedCollectionsForPurge() {
|
||||
res, err := db.Col(name).DeleteMany(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return counts, fmt.Errorf("purge %s: %w", name, err)
|
||||
}
|
||||
if res.DeletedCount > 0 {
|
||||
counts[name] = res.DeletedCount
|
||||
}
|
||||
}
|
||||
res, err := db.Col("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return counts, fmt.Errorf("purge instances: %w", err)
|
||||
}
|
||||
if res.DeletedCount > 0 {
|
||||
counts["instances"] = res.DeletedCount
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// ReapFreeInstances deletes Free cloud instances whose licence expired longer
|
||||
// ago than the configured window.
|
||||
//
|
||||
// Eligibility requires ALL of:
|
||||
// - license_tier == "free" — a paid instance is never eligible
|
||||
// - license_expiry present — an instance that was never licensed, or whose
|
||||
// issuance failed, has no expiry and is never eligible whatever its age
|
||||
// - license_expiry older than now minus the window
|
||||
//
|
||||
// Every one of those is a positive assertion. Nothing is eligible by default,
|
||||
// which is what makes a missing or stale field fail safe.
|
||||
func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
|
||||
window := reapAfter()
|
||||
if window == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-window)
|
||||
|
||||
cur, err := db.Col("instances").Find(ctx, bson.M{
|
||||
"license_tier": license.TierFree,
|
||||
"license_expiry": bson.M{"$ne": nil, "$lt": cutoff},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var doomed []struct {
|
||||
InstanceID string `bson:"instance_id"`
|
||||
Name string `bson:"name"`
|
||||
Slug string `bson:"slug"`
|
||||
Expiry time.Time `bson:"license_expiry"`
|
||||
}
|
||||
if err := cur.All(ctx, &doomed); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, d := range doomed {
|
||||
checked++
|
||||
|
||||
// Logged BEFORE the delete. Afterwards there is nothing left to
|
||||
// describe, and "why did this instance vanish" is the only question
|
||||
// anyone will ever ask about this code.
|
||||
//
|
||||
// The process log is the durable record, not the audit row: the purge
|
||||
// deletes this instance's audit_logs along with everything else, so an
|
||||
// audit entry written here would delete itself moments later. It is
|
||||
// written anyway, because an operator reading audit during the window
|
||||
// should see it coming.
|
||||
log.Printf("REAPING instance %s (%s, slug=%s) — Free licence expired %s, past the %s window",
|
||||
d.InstanceID, d.Name, d.Slug, d.Expiry.Format(time.RFC3339), window)
|
||||
LogEvent(d.InstanceID, "instance.reaped", "system", "", "",
|
||||
fmt.Sprintf("free licence expired %s, window %s", d.Expiry.Format(time.RFC3339), window))
|
||||
|
||||
counts, err := purgeInstance(ctx, d.InstanceID)
|
||||
if err != nil {
|
||||
log.Printf("reaper: purge of %s failed after %v: %v", d.InstanceID, counts, err)
|
||||
continue
|
||||
}
|
||||
purged++
|
||||
log.Printf("reaped instance %s: %v", d.InstanceID, counts)
|
||||
}
|
||||
return checked, purged, nil
|
||||
}
|
||||
|
||||
// StartReaper sweeps once at boot, then on a ticker until ctx is cancelled, and
|
||||
// logs loudly which mode it is in.
|
||||
//
|
||||
// The pass at boot follows inject.StartReconciler's precedent and earns its keep
|
||||
// the same way: it makes a restart a supported way to force a sweep, which is
|
||||
// the only way this code can be exercised on demand — the ticker is hourly and
|
||||
// deletion is measured in days.
|
||||
func StartReaper(ctx context.Context) {
|
||||
window := reapAfter()
|
||||
if window == 0 {
|
||||
log.Printf("reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)")
|
||||
return
|
||||
}
|
||||
log.Printf("reaper: ENABLED — Free instances are deleted %s after their licence expires", window)
|
||||
|
||||
go func() {
|
||||
reapOnce(ctx)
|
||||
|
||||
t := time.NewTicker(ReapInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
reapOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func reapOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
checked, purged, err := ReapFreeInstances(runCtx)
|
||||
if err != nil {
|
||||
log.Printf("reaper: %v", err)
|
||||
return
|
||||
}
|
||||
if purged > 0 {
|
||||
log.Printf("reaper: checked %d, purged %d", checked, purged)
|
||||
}
|
||||
}
|
||||
@@ -62,12 +62,21 @@ func CreateUser(instanceID, email, password, role, authSource string) (*models.U
|
||||
return u, err
|
||||
}
|
||||
|
||||
func GetUserByEmail(email string) (*models.User, error) {
|
||||
// GetUserInInstanceByEmail finds a user by address WITHIN one instance.
|
||||
//
|
||||
// There is deliberately no unscoped lookup by email. users is unique on
|
||||
// (instance_id, email), not on email alone, so an unscoped FindOne would return
|
||||
// an arbitrary one of several matching users — which on the login path means
|
||||
// signing someone into a tenant that is not theirs.
|
||||
func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var u models.User
|
||||
err := db.Col("users").FindOne(ctx, bson.M{"email": email}).Decode(&u)
|
||||
err := db.Col("users").FindOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"email": email,
|
||||
}).Decode(&u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package indexes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -11,21 +12,44 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug.
|
||||
// legacyUserEmailIndex is the global unique index on users.email that this
|
||||
// package used to declare. It is dropped on sight.
|
||||
const legacyUserEmailIndex = "email_1"
|
||||
|
||||
// indexNotFound is MongoDB's IndexNotFound error code. Two services booting at
|
||||
// once can both decide to drop the legacy index; the loser must not treat that
|
||||
// as a failure.
|
||||
const indexNotFound = 27
|
||||
|
||||
// EnsureCoreIndexes declares the unique indexes on users and instances.
|
||||
//
|
||||
// These are a security 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. Every caller must treat a failure here as
|
||||
// fatal.
|
||||
// users is unique on (instance_id, email), NOT on email alone. One address is
|
||||
// one user WITHIN an instance; the same address may hold a user in several
|
||||
// instances, because an account's people are projected into each instance they
|
||||
// are granted access to.
|
||||
//
|
||||
// This is a security property, not an optimisation, and it is only sufficient
|
||||
// because every lookup by email is scoped by instance. There is deliberately no
|
||||
// unscoped lookup by email anywhere in the codebase: an unscoped FindOne would
|
||||
// return an arbitrary one of several matching users, which on the login path
|
||||
// means signing someone into a tenant that is not theirs. If you are about to
|
||||
// add one, you are about to reintroduce that bug.
|
||||
//
|
||||
// Creating an index that already exists with the same specification is a no-op,
|
||||
// so this is safe to call at every boot from every service.
|
||||
func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
|
||||
// Create the replacement BEFORE dropping the legacy index. A failure here
|
||||
// leaves the old constraint in place, which is safe; a failure after the
|
||||
// drop would leave the collection unconstrained, which is not.
|
||||
if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetName("instance_email_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("users.email index: %w", err)
|
||||
return fmt.Errorf("users.instance_id+email index: %w", err)
|
||||
}
|
||||
|
||||
if err := dropIndexIfExists(ctx, db.Collection("users"), legacyUserEmailIndex); err != nil {
|
||||
return fmt.Errorf("drop users.%s: %w", legacyUserEmailIndex, err)
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
@@ -37,3 +61,39 @@ func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropIndexIfExists drops name, treating "it was not there" as success whether
|
||||
// that is discovered by listing or by racing another service to the drop.
|
||||
func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string) error {
|
||||
cur, err := col.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var existing []struct {
|
||||
Name string `bson:"name"`
|
||||
}
|
||||
if err := cur.All(ctx, &existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, i := range existing {
|
||||
if i.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = col.Indexes().DropOne(ctx, name)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var srvErr mongo.ServerError
|
||||
if errors.As(err, &srvErr) && srvErr.HasErrorCode(indexNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
+16
-2
@@ -20,6 +20,17 @@ func ValidRole(role string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Auth sources. A user's auth_source says who owns the row.
|
||||
const (
|
||||
AuthLocal = "local"
|
||||
AuthOIDC = "oidc"
|
||||
// AuthHQ marks a user projected from a Vantage HQ account. Its role,
|
||||
// password and existence are owned by HQ, and the instance API refuses to
|
||||
// change any of them locally — a role editable in two places is a role with
|
||||
// two answers.
|
||||
AuthHQ = "hq"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
@@ -28,6 +39,9 @@ type User struct {
|
||||
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
AuthSource string `bson:"auth_source" json:"auth_source"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
|
||||
// HQUserID is the customer_users.user_id this row was projected from,
|
||||
// absent on locally-created users.
|
||||
HQUserID string `bson:"hq_user_id,omitempty" json:"hq_user_id,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ COPY . .
|
||||
# SITE_ORIGIN for CORS.
|
||||
ARG NEXT_PUBLIC_SITE_API=""
|
||||
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
|
||||
# Browser-reachable admin URL; site/lib/submit.ts posts account signups here.
|
||||
ARG NEXT_PUBLIC_ADMIN_API_URL=""
|
||||
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
|
||||
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
||||
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
+10
-13
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { InstanceForm } from "@/components/InstanceForm";
|
||||
import { AccountForm } from "@/components/AccountForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vantage Cloud",
|
||||
description: "An instance owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
|
||||
description: "Your account is where instances, licences and billing live. Free for three servers, hosted or self-hosted.",
|
||||
};
|
||||
|
||||
export default function StartPage() {
|
||||
@@ -12,14 +12,13 @@ export default function StartPage() {
|
||||
<div className="split">
|
||||
<div>
|
||||
<span className="tag">Vantage Cloud</span>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your instance.</h1>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Create your account.</h1>
|
||||
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
|
||||
An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Confirm your email and it is created with you
|
||||
as its owner.
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ marginTop: "1.9rem" }}>
|
||||
<InstanceForm />
|
||||
<AccountForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,23 +29,21 @@ export default function StartPage() {
|
||||
<span className="spec__k">FIRST</span>
|
||||
<div>
|
||||
<h3>Confirm your email</h3>
|
||||
<p>We send a link that works once. Your instance is created when you open it, not before.</p>
|
||||
<p>We send a link that works once. Your account is created when you open it, not before.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">THEN</span>
|
||||
<div>
|
||||
<h3>Add a key</h3>
|
||||
<p>
|
||||
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses duplicates.
|
||||
</p>
|
||||
<h3>Create your instance</h3>
|
||||
<p>One click in the portal. It gets its own subdomain and a free licence, and you are its owner.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">THEN</span>
|
||||
<div>
|
||||
<h3>Add a server</h3>
|
||||
<p>Run the install command as root. It expires in an hour and works once.</p>
|
||||
<h3>Add a key and a server</h3>
|
||||
<p>Paste your public key, then run the install command as root. It expires in an hour and works once.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
|
||||
@@ -2,20 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Honeypot } from "@/components/Honeypot";
|
||||
import { submitSignup, type SubmitResult } from "@/lib/submit";
|
||||
import { submitAccountSignup, type SubmitResult } from "@/lib/submit";
|
||||
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function InstanceForm() {
|
||||
const [slug, setSlug] = useState("");
|
||||
export function AccountForm() {
|
||||
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
|
||||
const sending = result.state === "sending";
|
||||
|
||||
@@ -24,8 +15,8 @@ export function InstanceForm() {
|
||||
const data = new FormData(event.currentTarget);
|
||||
setResult({ state: "sending" });
|
||||
setResult(
|
||||
await submitSignup({
|
||||
instance_name: String(data.get("instance_name") ?? ""),
|
||||
await submitAccountSignup({
|
||||
name: String(data.get("name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
password: String(data.get("password") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
@@ -38,7 +29,7 @@ export function InstanceForm() {
|
||||
<div role="status">
|
||||
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
|
||||
We sent a confirmation link. Open it and <b>{slug || "your instance"}</b> is created with you as its owner. The link works once and expires in 24 hours.
|
||||
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.
|
||||
</p>
|
||||
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
|
||||
Nothing exists until you confirm if the email does not arrive, start again or contact support@hostxtra.co.uk.
|
||||
@@ -54,14 +45,11 @@ export function InstanceForm() {
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-instance">Instance name</label>
|
||||
<input id="o-instance" name="instance_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-instance-err" />
|
||||
<span className="hostline">
|
||||
<b>{slug || "your-instance"}</b>.vantage.hostxtra.co.uk
|
||||
</span>
|
||||
{fieldError("instance_name") && (
|
||||
<small id="o-instance-err" className="field__err">
|
||||
{fieldError("instance_name")}
|
||||
<label htmlFor="o-name">Your organisation</label>
|
||||
<input id="o-name" name="name" type="text" placeholder="Northgate Systems" required aria-describedby="o-name-err" />
|
||||
{fieldError("name") && (
|
||||
<small id="o-name-err" className="field__err">
|
||||
{fieldError("name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
+16
-4
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
const SITE_API = (process.env.NEXT_PUBLIC_SITE_API ?? "").replace(/\/$/, "");
|
||||
const ADMIN_API = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
|
||||
const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostxtra.co.uk";
|
||||
|
||||
export type SubmitState = "idle" | "sending" | "sent" | "error";
|
||||
@@ -56,12 +57,23 @@ export async function submitContact(fields: { name: string; email: string; serve
|
||||
return post(`${SITE_API}/api/contact`, fields);
|
||||
}
|
||||
|
||||
export async function submitSignup(fields: { instance_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
/*
|
||||
* Account signup posts to the admin service, not sitesvc. The two form targets
|
||||
* are deliberately separate variables rather than one base URL: contact and
|
||||
* 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> {
|
||||
if (!ADMIN_API) {
|
||||
return {
|
||||
state: "error",
|
||||
message: `Signup is not available from here yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
|
||||
message: `Signup is not connected yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
|
||||
};
|
||||
}
|
||||
return post(`${SITE_API}/api/signup`, fields);
|
||||
return post(`${ADMIN_API}/auth/signup`, fields);
|
||||
}
|
||||
|
||||
+1
-4
@@ -43,12 +43,9 @@ func main() {
|
||||
if mailCfg.Enabled() {
|
||||
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
|
||||
} else {
|
||||
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact and signup forms will refuse submissions")
|
||||
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact form will refuse submissions")
|
||||
}
|
||||
|
||||
if os.Getenv("PUBLIC_URL") == "" {
|
||||
log.Println("warning: PUBLIC_URL is unset verification links will be relative and will not work")
|
||||
}
|
||||
if os.Getenv("SITE_ORIGIN") == "" {
|
||||
log.Println("warning: SITE_ORIGIN is unset cross-origin browser requests will be refused")
|
||||
}
|
||||
|
||||
@@ -23,31 +23,22 @@ const (
|
||||
type Server struct {
|
||||
mail mail.Config
|
||||
limiter *limiter
|
||||
signups *limiter
|
||||
allowOrigin map[string]bool
|
||||
trustProxy bool
|
||||
publicURL string
|
||||
appLoginURL string
|
||||
}
|
||||
|
||||
func New(mailCfg mail.Config) *Server {
|
||||
return &Server{
|
||||
mail: mailCfg,
|
||||
limiter: newLimiter(perIPLimit, perIPWindow),
|
||||
signups: newLimiter(signupPerIPLimit, signupPerIPWindow),
|
||||
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
|
||||
trustProxy: os.Getenv("TRUST_PROXY") == "true",
|
||||
publicURL: os.Getenv("PUBLIC_URL"),
|
||||
appLoginURL: os.Getenv("APP_LOGIN_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/contact", s.handleContact)
|
||||
mux.HandleFunc("POST /api/signup", s.handleSignup)
|
||||
|
||||
mux.HandleFunc("GET /api/verify", s.handleVerify)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
minPasswordLength = 12
|
||||
signupPerIPLimit = 3
|
||||
signupPerIPWindow = time.Hour
|
||||
)
|
||||
|
||||
type signupBody struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body signupBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
return
|
||||
}
|
||||
|
||||
var problems []fieldError
|
||||
|
||||
instanceName, nameErr := text("instance_name", body.InstanceName, true, maxShort)
|
||||
if nameErr != nil {
|
||||
problems = append(problems, *nameErr)
|
||||
}
|
||||
|
||||
addr, emailErr := email("email", body.Email)
|
||||
if emailErr != nil {
|
||||
problems = append(problems, *emailErr)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(body.Password) < minPasswordLength {
|
||||
problems = append(problems, fieldError{
|
||||
Field: "password",
|
||||
Message: fmt.Sprintf("Use at least %d characters.", minPasswordLength),
|
||||
})
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
writeFieldErrors(w, problems)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.signups.allow(s.clientIP(r)) {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(signupPerIPWindow.Seconds())))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Too many organisations created from here recently. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mail.Enabled() {
|
||||
log.Println("signup refused: smtp is not configured, so no verification email could be sent")
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Signup is unavailable right now. Email support@hostxtra.co.uk and we will set you up.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := store.CreatePending(ctx, instanceName, addr, body.Password)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "That email already has an account.",
|
||||
"fields": []fieldError{{
|
||||
Field: "email",
|
||||
Message: "This address is already registered. Sign in instead.",
|
||||
}},
|
||||
})
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
writeFieldErrors(w, []fieldError{{
|
||||
Field: "instance_name",
|
||||
Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "),
|
||||
}})
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("signup: create pending: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "We could not start that signup. Try again in a moment.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
link := s.verifyURL(token)
|
||||
if err := s.mail.SendVerification(addr, instanceName, link, store.PendingTTL); err != nil {
|
||||
|
||||
log.Printf("signup: send verification to %s: %v", addr, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
}
|
||||
|
||||
func (s *Server) verifyURL(token string) string {
|
||||
base := strings.TrimSuffix(s.publicURL, "/")
|
||||
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
s.verifyPage(w, http.StatusBadRequest, "Link incomplete",
|
||||
"That link is missing its token. Copy the whole address from the email and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
inst, err := store.Verify(ctx, token)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrBadToken):
|
||||
s.verifyPage(w, http.StatusGone, "Link expired",
|
||||
"This link has already been used or has expired. Start the signup again and we will send a new one.")
|
||||
return
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
s.verifyPage(w, http.StatusConflict, "Already registered",
|
||||
"That address already has an account. Sign in instead.")
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
s.verifyPage(w, http.StatusUnprocessableEntity, "Name unavailable",
|
||||
"We could not use that organisation name. Start the signup again with a different one.")
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("verify: %v", err)
|
||||
s.verifyPage(w, http.StatusInternalServerError, "Something went wrong",
|
||||
"We could not finish creating your organisation. Email support@hostxtra.co.uk and we will sort it out.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("verify: provisioned instance %s (%s)", inst.Slug, inst.InstanceID)
|
||||
|
||||
if login := s.loginURL(inst.Slug); login != "" {
|
||||
http.Redirect(w, r, login, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.verifyPage(w, http.StatusOK, "Organisation ready",
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", inst.Name))
|
||||
}
|
||||
|
||||
// loginURL is the instance-specific sign-in URL a verified owner is sent to. Each
|
||||
// instance lives on its own subdomain (<slug>.vantage.hostxtra.co.uk), so the slug
|
||||
// must be substituted per signup rather than pointing at one shared address.
|
||||
//
|
||||
// APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the
|
||||
// instance's slug; a value without one is treated as a literal (a single shared
|
||||
// login page) so a plain URL still works. An empty value falls back to the
|
||||
// confirmation page.
|
||||
func (s *Server) loginURL(slug string) string {
|
||||
if s.appLoginURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(s.appLoginURL, "{slug}", url.PathEscape(slug))
|
||||
}
|
||||
|
||||
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.WriteHeader(status)
|
||||
|
||||
page := fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>%s Vantage</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #eaedf3; color: #0a1b33; padding: 2rem;
|
||||
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { max-width: 46ch; }
|
||||
h1 { font-size: 1.6rem; letter-spacing: -0.03em; margin: 0 0 0.6rem; }
|
||||
p { margin: 0; color: #41556f; }
|
||||
a { color: #0b2a58; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #071628; color: #e4ecf6; }
|
||||
p { color: #9fb3ca; }
|
||||
a { color: #5b9be8; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`, html.EscapeString(heading), html.EscapeString(heading), html.EscapeString(detail))
|
||||
|
||||
if _, err := w.Write([]byte(page)); err != nil {
|
||||
log.Printf("write verify page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFieldErrors(w http.ResponseWriter, problems []fieldError) {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": "Some fields need another look.",
|
||||
"fields": problems,
|
||||
})
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// PendingSignup lives here rather than in the shared module because only
|
||||
// sitesvc writes site_pending_signups. The control plane does not know the
|
||||
// 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.
|
||||
type PendingSignup struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
PendingID string `bson:"pending_id"`
|
||||
InstanceName string `bson:"instance_name"`
|
||||
Email string `bson:"email"`
|
||||
PasswordHash string `bson:"password_hash"`
|
||||
TokenHash string `bson:"token_hash"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
}
|
||||
@@ -2,35 +2,15 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const PendingTTL = 24 * time.Hour
|
||||
|
||||
// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so
|
||||
// errors.Is keeps working for existing callers in internal/api.
|
||||
var (
|
||||
ErrEmailTaken = provision.ErrEmailTaken
|
||||
ErrBadToken = errors.New("verification link is invalid or has expired")
|
||||
ErrNameRejected = provision.ErrNameRejected
|
||||
)
|
||||
|
||||
var database *mongo.Database
|
||||
@@ -74,26 +54,7 @@ func EnsureIndexes() error {
|
||||
// users.email and instances.slug are declared in the shared module so both
|
||||
// services agree. Re-declaring at boot means sitesvc does not depend on the
|
||||
// control plane having started first.
|
||||
if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "token_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{Keys: bson.D{{Key: "email", Value: 1}}},
|
||||
|
||||
{
|
||||
Keys: bson.D{{Key: "expires_at", Value: 1}},
|
||||
Options: options.Index().SetExpireAfterSeconds(0),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pending signup indexes: %w", err)
|
||||
}
|
||||
return nil
|
||||
return indexes.EnsureCoreIndexes(ctx, database)
|
||||
}
|
||||
|
||||
// RequireMigratedDatabase refuses to start against a control-plane database
|
||||
@@ -129,96 +90,3 @@ func RequireMigratedDatabase(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EmailTaken(ctx context.Context, email string) (bool, error) {
|
||||
n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) {
|
||||
if _, err := provision.BaseSlug(instanceName); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
|
||||
taken, err := EmailTaken(ctx, email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if taken {
|
||||
return "", ErrEmailTaken
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), provision.BcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
raw, err := randomToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
pending := models.PendingSignup{
|
||||
PendingID: uuid.NewString(),
|
||||
InstanceName: strings.TrimSpace(instanceName),
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
TokenHash: hashToken(raw),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(PendingTTL),
|
||||
}
|
||||
if _, err := col("site_pending_signups").InsertOne(ctx, pending); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, error) {
|
||||
var pending models.PendingSignup
|
||||
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
|
||||
"token_hash": hashToken(rawToken),
|
||||
"expires_at": bson.M{"$gt": time.Now().UTC()},
|
||||
}).Decode(&pending)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrBadToken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inst, err := provision.CreateInstance(ctx, database, pending.InstanceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The password was hashed when the signup was recorded; only the hash
|
||||
// survives to this point.
|
||||
_, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, pending.Email,
|
||||
pending.PasswordHash, sharedmodels.RoleOwner, "local")
|
||||
if err != nil {
|
||||
// Leaving an instance behind would permanently occupy a slug nobody owns.
|
||||
if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil {
|
||||
log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user