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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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() }
|
||||
Reference in New Issue
Block a user