From b8fcf89ee7dc2148173bb6cd1624aca00c3b26db Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 19:00:37 +0100 Subject: [PATCH] feat(admin): documents, indexes and the plan seed Adds admin's own documents, its unique indexes and the plan seed from shared/license. Licences are append-only -- a renewal writes a new row and supersedes the old one -- because the history is the support tool. Plans are seeded with $setOnInsert only, so a redeploy never stamps over staff edits to limits, features or Paddle IDs. admin_instances.instance_id unique is a correctness property, not an optimisation: without it two customers could both claim one self-hosted UUID and both be issued licences for it. Co-Authored-By: Claude Opus 5 --- admin/cmd/main.go | 12 +++ admin/go.mod | 2 + admin/go.sum | 8 ++ admin/internal/db/db.go | 52 ++++++++++ admin/internal/models/models.go | 165 ++++++++++++++++++++++++++++++++ admin/internal/models/plans.go | 50 ++++++++++ 6 files changed, 289 insertions(+) create mode 100644 admin/internal/models/models.go create mode 100644 admin/internal/models/plans.go diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 23cd0b9..80758dc 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -13,6 +13,7 @@ import ( "github.com/joho/godotenv" "github.com/mrhid6/vantage/admin/internal/config" "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" ) func main() { @@ -31,6 +32,17 @@ func main() { cancel() log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName) + idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := db.EnsureIndexes(idxCtx); err != nil { + idxCancel() + log.Fatalf("indexes: %v", err) + } + if err := models.SeedPlans(idxCtx); err != nil { + idxCancel() + log.Fatalf("plan seed: %v", err) + } + idxCancel() + srv := &http.Server{ Addr: cfg.Addr, Handler: http.NotFoundHandler(), // replaced in Task 8 diff --git a/admin/go.mod b/admin/go.mod index 0fa1584..1bc1c72 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -4,10 +4,12 @@ go 1.26.4 require ( github.com/joho/godotenv v1.5.1 + github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000 go.mongodb.org/mongo-driver/v2 v2.8.0 ) require ( + github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect diff --git a/admin/go.sum b/admin/go.sum index a428327..1e6b933 100644 --- a/admin/go.sum +++ b/admin/go.sum @@ -2,10 +2,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0= +github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= @@ -46,3 +52,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go index fedfb90..ee5ed2c 100644 --- a/admin/internal/db/db.go +++ b/admin/internal/db/db.go @@ -12,6 +12,7 @@ import ( "time" "github.com/mrhid6/vantage/admin/internal/config" + "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) @@ -59,3 +60,54 @@ func Control(name string) *mongo.Collection { return controlDB.Collection(name) func Ctx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 10*time.Second) } + +// EnsureIndexes creates admin's unique indexes. +// +// These are a correctness property, not an optimisation. In particular +// admin_instances.instance_id unique is what stops the same self-hosted UUID +// being linked to two accounts — without it, two customers could both claim one +// instance and both be issued licences for it. +func EnsureIndexes(ctx context.Context) error { + unique := []struct { + coll string + field string + }{ + {"accounts", "account_id"}, + {"admin_instances", "instance_id"}, + {"licenses", "license_id"}, + {"plans", "tier"}, + {"staff_users", "email"}, + {"customer_users", "email"}, + } + for _, u := range unique { + if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: u.field, Value: 1}}, + Options: options.Index().SetUnique(true).SetName(u.field + "_unique"), + }); err != nil { + return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err) + } + } + + // Sparse: a subscription exists before Paddle assigns an ID, so empty must + // not collide with empty. + if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "paddle_subscription_id", Value: 1}}, + Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"), + }); err != nil { + return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err) + } + + for _, idx := range []struct { + coll string + keys bson.D + }{ + {"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}}, + {"admin_instances", bson.D{{Key: "account_id", Value: 1}}}, + {"admin_audit", bson.D{{Key: "created_at", Value: -1}}}, + } { + if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil { + return fmt.Errorf("index %s: %w", idx.coll, err) + } + } + return nil +} diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go new file mode 100644 index 0000000..47a45c8 --- /dev/null +++ b/admin/internal/models/models.go @@ -0,0 +1,165 @@ +// Package models holds admin's own documents. +// +// These are admin-owned and never shared with the control plane. The two +// structs that ARE shared — Instance and User on the control-plane side — come +// from shared/models, so there is no second copy of those shapes to drift. +package models + +import ( + "time" + + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Instance statuses. +const ( + StatusAwaitingLink = "awaiting_link" + StatusActive = "active" + StatusLapsed = "lapsed" + StatusCancelled = "cancelled" +) + +// Account statuses. +const ( + AccountActive = "active" + AccountSuspended = "suspended" +) + +// Licence issuance reasons. These end up in support conversations, so they are +// stable identifiers rather than prose. +const ( + ReasonNew = "new" + ReasonRenewal = "renewal" + ReasonTierChange = "tier_change" + ReasonRelink = "relink" + ReasonManual = "manual" +) + +// MaxRelinksPerTerm is the customer-facing relink cap. +// +// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be +// revoked, so a determined customer is not stopped by a counter. Its real job is +// to put a human in front of the fourth attempt. +const MaxRelinksPerTerm = 3 + +// GracePeriod is added to every licence expiry beyond the billing period end, +// so a renewal webhook arriving slightly late does not create a gap in which a +// paying customer's instance goes read-only. +const GracePeriod = 3 * 24 * time.Hour + +type Account struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + AccountID string `bson:"account_id" json:"account_id"` + Name string `bson:"name" json:"name"` + BillingEmail string `bson:"billing_email" json:"billing_email"` + PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"` + Status string `bson:"status" json:"status"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// Instance is admin's record of one deployment. +// +// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it +// is the UUID the customer pasted — their database is theirs, and we cannot see +// it, so this row is the only thing that exists on our side. +type Instance struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + AccountID string `bson:"account_id" json:"account_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug,omitempty" json:"slug,omitempty"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier,omitempty" json:"tier,omitempty"` + Status string `bson:"status" json:"status"` + 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"` +} + +// License is append-only. A renewal writes a new row and sets SupersededBy on +// the old one. Nothing here is ever edited or deleted: when a support question +// arrives about why an instance stopped working on a given date, the answer has +// to still be in the table. +type License struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + LicenseID string `bson:"license_id" json:"license_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` + AccountID string `bson:"account_id" json:"account_id"` + Tier string `bson:"tier" json:"tier"` + Deployment string `bson:"deployment" json:"deployment"` + Limits license.Limits `bson:"limits" json:"limits"` + Features []string `bson:"features" json:"features"` + IssuedAt time.Time `bson:"issued_at" json:"issued_at"` + ExpiresAt time.Time `bson:"expires_at" json:"expires_at"` + Blob string `bson:"blob" json:"-"` + SupersededBy string `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"` + IssuedBy string `bson:"issued_by" json:"issued_by"` + Reason string `bson:"reason" json:"reason"` +} + +type Subscription struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + SubscriptionID string `bson:"subscription_id" json:"subscription_id"` + AccountID string `bson:"account_id" json:"account_id"` + InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"` + PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"` + PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"` + Tier string `bson:"tier" json:"tier"` + Term string `bson:"term" json:"term"` + Status string `bson:"status" json:"status"` + CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"` +} + +// Plan is the authoritative tier definition, seeded from shared/license. +// +// It lives in the database so tier contents change without a deploy. Every +// issued licence snapshots it, so editing a plan never rewrites an existing +// licence — the same rule as workflow_runs.steps_snapshot. +type Plan struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + Tier string `bson:"tier" json:"tier"` + Name string `bson:"name" json:"name"` + Deployment string `bson:"deployment" json:"deployment"` + Limits license.Limits `bson:"limits" json:"limits"` + Features []string `bson:"features" json:"features"` + PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"` + PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"` + Active bool `bson:"active" json:"active"` +} + +type StaffUser struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + UserID string `bson:"user_id" json:"user_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash" json:"-"` + Name string `bson:"name" json:"name"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// CustomerUser is a self-hosted customer's login. Cloud customers do not have +// one — they authenticate against the control plane with credentials they +// already hold. +type CustomerUser struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + UserID string `bson:"user_id" json:"user_id"` + AccountID string `bson:"account_id" json:"account_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash" json:"-"` + VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"` + VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"` + VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type AuditEntry struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + Actor string `bson:"actor" json:"actor"` + Action string `bson:"action" json:"action"` + AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"` + Target string `bson:"target,omitempty" json:"target,omitempty"` + Detail string `bson:"detail,omitempty" json:"detail,omitempty"` + IP string `bson:"ip,omitempty" json:"ip,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/admin/internal/models/plans.go b/admin/internal/models/plans.go new file mode 100644 index 0000000..06a9177 --- /dev/null +++ b/admin/internal/models/plans.go @@ -0,0 +1,50 @@ +package models + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// SeedPlans inserts the tier table from shared/license on first boot. +// +// It uses $setOnInsert only: once a plan exists, staff edits to limits, features +// and Paddle IDs are authoritative and a redeploy must not stamp over them. +func SeedPlans(ctx context.Context) error { + for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} { + p, ok := license.PlanFor(tier) + if !ok { + continue + } + _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"tier": tier}, + bson.M{"$setOnInsert": bson.M{ + "tier": p.Tier, + "name": p.Name, + "deployment": p.Deployment, + "limits": p.Limits, + "features": p.Features, + "active": true, + }}, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return err + } + } + return nil +} + +// GetPlan reads a tier's authoritative definition. +func GetPlan(ctx context.Context, tier string) (*Plan, error) { + var p Plan + if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil { + return nil, err + } + return &p, nil +} + +func now() time.Time { return time.Now().UTC() }