feat(admin): staff API and the route table

Completes the service -- this is the first commit where the whole thing
compiles and serves.

staffCreateInstance adopts a cloud instance that already exists in the
control plane, taking its name and slug from there and refusing when no
such instance exists: an admin row pointing at nothing would issue licences
nobody can use. Adopt then issue is how the existing read-only cloud
instances get licensed.

staffRelink has no attempt cap. The customer-facing limit exists to put a
human in front of the fourth attempt, and this is that human.

Editing a plan changes what a tier grants from now on only; existing
licences snapshotted theirs at issue time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 19:11:04 +01:00
co-authored by Claude Opus 5
parent c829cc41d9
commit 268134d821
3 changed files with 423 additions and 1 deletions
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
@@ -72,7 +73,7 @@ func main() {
srv := &http.Server{
Addr: cfg.Addr,
Handler: http.NotFoundHandler(), // replaced in Task 8
Handler: api.Routes(cfg),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
+80
View File
@@ -0,0 +1,80 @@
// Package api mounts admin's HTTP surface.
//
// The route table is the single place scoping is guaranteed. Customer routes
// live behind RequireCustomer and every handler that names an instance calls
// ownedInstance. A new customer route that skips that helper is a scoping bug,
// so keep them together and review them together.
package api
import (
"net/http"
"slices"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
)
func Routes(cfg config.Config) http.Handler {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Use(cors(cfg.AllowedOrigins))
if cfg.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
r.POST("/auth/staff/login", auth.HandleStaffLogin)
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/verify", auth.HandleVerify)
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{
cust.GET("/account", getAccount)
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/subscriptions", listSubscriptions)
}
staff := r.Group("/api/staff")
staff.Use(auth.RequireStaff())
{
staff.GET("/accounts", staffListAccounts)
staff.POST("/accounts", staffCreateAccount)
staff.GET("/accounts/:id", staffGetAccount)
staff.GET("/instances", staffListInstances)
staff.POST("/instances", staffCreateInstance)
staff.POST("/instances/:id/issue", staffIssue)
staff.POST("/instances/:id/relink", staffRelink)
staff.GET("/licenses", staffListLicenses)
staff.GET("/plans", staffListPlans)
staff.PUT("/plans/:tier", staffUpdatePlan)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
}
return r
}
func cors(allowed []string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" && slices.Contains(allowed, origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+341
View File
@@ -0,0 +1,341 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func staffListAccounts(c *gin.Context) {
filter := bson.M{}
if q := c.Query("q"); q != "" {
filter["$or"] = []bson.M{
{"name": bson.M{"$regex": q, "$options": "i"}},
{"billing_email": bson.M{"$regex": q, "$options": "i"}},
}
}
cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
accounts := []models.Account{}
if err := cur.All(c.Request.Context(), &accounts); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, accounts)
}
func staffCreateAccount(c *gin.Context) {
var body struct {
Name string `json:"name"`
BillingEmail string `json:"billing_email"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
return
}
acct := models.Account{
AccountID: uuid.NewString(),
Name: body.Name,
BillingEmail: body.BillingEmail,
Status: models.AccountActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, acct)
}
func staffGetAccount(c *gin.Context) {
ctx := c.Request.Context()
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID})
instances := []models.Instance{}
if cur != nil {
_ = cur.All(ctx, &instances)
}
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
}
func staffListInstances(c *gin.Context) {
filter := bson.M{}
for param, field := range map[string]string{
"account_id": "account_id",
"deployment": "deployment",
"status": "status",
} {
if v := c.Query(param); v != "" {
filter[field] = v
}
}
if c.Query("expiring") == "true" {
// Instances whose licence expires within 14 days, for renewal chasing.
var ids []string
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
"superseded_by": bson.M{"$exists": false},
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
})
if err == nil {
var lics []models.License
if cur.All(c.Request.Context(), &lics) == nil {
for _, l := range lics {
ids = append(ids, l.InstanceID)
}
}
}
filter["instance_id"] = bson.M{"$in": ids}
}
cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
options.Find().SetLimit(500))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
instances := []models.Instance{}
if err := cur.All(c.Request.Context(), &instances); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, instances)
}
// staffCreateInstance attaches an instance to an account.
//
// For cloud, this ADOPTS an instance that already exists in the control plane —
// the control-plane row is the source of truth for its name and slug, and this
// refuses if no such instance exists, because an admin row pointing at nothing
// would issue licences nobody can use.
//
// For self-hosted it does the same job as the customer-facing link endpoint, so
// staff can link on a customer's behalf during support.
//
// This is how existing cloud instances get licensed: adopt, then issue.
func staffCreateInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
Deployment string `json:"deployment"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
return
}
ctx := c.Request.Context()
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
return
}
inst := models.Instance{
InstanceID: body.InstanceID,
AccountID: body.AccountID,
Name: body.Name,
Deployment: body.Deployment,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if body.Deployment == license.DeploymentCloud {
var remote sharedmodels.Instance
if err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
return
}
inst.Name = remote.Name
inst.Slug = remote.Slug
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
c.JSON(http.StatusCreated, inst)
}
func staffIssue(c *gin.Context) {
var body struct {
Tier string `json:"tier"`
Term string `json:"term"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
return
}
if body.Reason == "" {
body.Reason = models.ReasonManual
}
s := auth.Current(c)
lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
InstanceID: c.Param("id"),
Tier: body.Tier,
Term: body.Term,
Reason: body.Reason,
IssuedBy: s.Email,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var inst models.Instance
if db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
deliver(c, &inst, lic)
}
c.JSON(http.StatusCreated, lic)
}
// staffRelink has no attempt cap. The customer-facing limit exists to put a
// human in front of the fourth attempt; this is that human.
func staffRelink(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
ctx := c.Request.Context()
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, lic)
}
func staffListLicenses(c *gin.Context) {
filter := bson.M{}
if v := c.Query("instance_id"); v != "" {
filter["instance_id"] = v
}
if v := c.Query("account_id"); v != "" {
filter["account_id"] = v
}
cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
lics := []models.License{}
if err := cur.All(c.Request.Context(), &lics); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, lics)
}
func staffListPlans(c *gin.Context) {
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
plans := []models.Plan{}
if err := cur.All(c.Request.Context(), &plans); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, plans)
}
// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
// snapshotted their plan at issue time and are unaffected — the same rule as
// workflow_runs.steps_snapshot.
func staffUpdatePlan(c *gin.Context) {
var body models.Plan
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
return
}
set := bson.M{
"name": body.Name,
"limits": body.Limits,
"features": body.Features,
"paddle_product_id": body.PaddleProductID,
"paddle_price_ids": body.PaddlePriceIDs,
"active": body.Active,
}
if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func staffAudit(c *gin.Context) {
cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{},
options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
entries := []models.AuditEntry{}
if err := cur.All(c.Request.Context(), &entries); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entries)
}
// staffInjectionHealth lists instances whose last injection failed. This is the
// page to look at when a customer says their cloud instance is read-only.
func staffInjectionHealth(c *gin.Context) {
cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
bson.M{"inject_failed_at": bson.M{"$exists": true}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
failed := []models.Instance{}
if err := cur.All(c.Request.Context(), &failed); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
}