fix(admin): licence and plan features serialise as [], never null
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:
mrhid6
2026-07-26 18:10:07 +01:00
co-authored by Claude Opus 5
parent 34a0373eca
commit 3a6c5ebae8
5 changed files with 43 additions and 8 deletions
+6 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+31 -2
View File
@@ -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"`
+1 -1
View File
@@ -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))