fix(admin): licence and plan features serialise as [], never null
Server Deploy / deploy (push) Successful in 5m22s
Server Deploy / deploy (push) Successful in 5m22s
Free has no features, so a nil Go slice served "features": null. The portal's type said string[], called .length on it, and took the page down. The guarantee lives on a named Features type with its own MarshalJSON rather than at each of the six places a licence or plan is serialised, because the seventh is the one that would have been forgotten. Applying it at marshal time also fixes rows already holding null in Mongo, which a write-side fix alone would not. Same class, found while checking the rest: staffListInstances built its expiring filter from a nil []string, marshalling to $in: null, which Mongo rejects — so the quiet week when nothing is expiring is precisely when the staff Operations page's own query would have failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -122,7 +122,11 @@ func staffListInstances(c *gin.Context) {
|
||||
}
|
||||
if c.Query("expiring") == "true" {
|
||||
// Instances whose licence expires within 14 days, for renewal chasing.
|
||||
var ids []string
|
||||
//
|
||||
// Empty rather than nil: a nil slice marshals to `$in: null`, which
|
||||
// Mongo rejects outright, so the quiet week when nothing is expiring is
|
||||
// exactly when this query would have failed.
|
||||
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)},
|
||||
@@ -401,7 +405,7 @@ func staffUpdatePlan(c *gin.Context) {
|
||||
set := bson.M{
|
||||
"name": body.Name,
|
||||
"limits": body.Limits,
|
||||
"features": body.Features,
|
||||
"features": body.Features.OrEmpty(),
|
||||
"paddle_product_id": body.PaddleProductID,
|
||||
"paddle_price_ids": body.PaddlePriceIDs,
|
||||
"active": body.Active,
|
||||
|
||||
@@ -97,7 +97,7 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
// Snapshotted, not referenced: editing a plan tomorrow must not change
|
||||
// what this licence grants.
|
||||
Limits: plan.Limits,
|
||||
Features: plan.Features,
|
||||
Features: plan.Features.OrEmpty(),
|
||||
}
|
||||
|
||||
blob, err := license.Sign(payload, signingKey)
|
||||
@@ -112,7 +112,7 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
Limits: plan.Limits,
|
||||
Features: plan.Features,
|
||||
Features: plan.Features.OrEmpty(),
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
Blob: blob,
|
||||
|
||||
@@ -6,12 +6,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Features is a list of feature flags that marshals as `[]` rather than `null`.
|
||||
//
|
||||
// A nil Go slice becomes JSON null. The Free plan has no features, so every
|
||||
// Free licence served `"features": null`, and the portal — whose type said
|
||||
// string[] — called .length on it and took the page down with it.
|
||||
//
|
||||
// The guarantee lives on the type rather than at each of the six places a
|
||||
// licence or plan is serialised, because the seventh is the one that would have
|
||||
// been forgotten. It also fixes rows already holding null in Mongo, since it
|
||||
// applies at marshal time rather than at write time.
|
||||
type Features []string
|
||||
|
||||
func (f Features) MarshalJSON() ([]byte, error) {
|
||||
if f == nil {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
return json.Marshal([]string(f))
|
||||
}
|
||||
|
||||
// OrEmpty is the same guarantee for values headed to Mongo rather than to JSON,
|
||||
// so a null never enters the database in the first place.
|
||||
func (f Features) OrEmpty() Features {
|
||||
if f == nil {
|
||||
return Features{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Instance statuses.
|
||||
const (
|
||||
StatusAwaitingLink = "awaiting_link"
|
||||
@@ -107,7 +136,7 @@ type License struct {
|
||||
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"`
|
||||
Features Features `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:"-"`
|
||||
@@ -140,7 +169,7 @@ type Plan struct {
|
||||
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"`
|
||||
Features Features `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"`
|
||||
|
||||
@@ -27,7 +27,7 @@ func SeedPlans(ctx context.Context) error {
|
||||
"name": p.Name,
|
||||
"deployment": p.Deployment,
|
||||
"limits": p.Limits,
|
||||
"features": p.Features,
|
||||
"features": Features(p.Features).OrEmpty(),
|
||||
"active": true,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
|
||||
@@ -22,7 +22,9 @@ var plans = map[string]Plan{
|
||||
Name: "Free",
|
||||
Deployment: DeploymentCloud, // cloud only, by construction
|
||||
Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
|
||||
Features: nil,
|
||||
// Empty rather than nil: nil marshals as JSON null, and this table is
|
||||
// the seed every plan and licence is cut from.
|
||||
Features: []string{},
|
||||
},
|
||||
TierProfessional: {
|
||||
Tier: TierProfessional,
|
||||
|
||||
Reference in New Issue
Block a user