From 3a6c5ebae8990fdb45d03abb27dae64f136f2fab Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 18:10:07 +0100 Subject: [PATCH] fix(admin): licence and plan features serialise as [], never null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/internal/api/staff.go | 8 ++++++-- admin/internal/licensing/issue.go | 4 ++-- admin/internal/models/models.go | 33 +++++++++++++++++++++++++++++-- admin/internal/models/plans.go | 2 +- shared/license/plans.go | 4 +++- 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 01bb0cf..72b3ed9 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -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, diff --git a/admin/internal/licensing/issue.go b/admin/internal/licensing/issue.go index 118374b..acc8b9a 100644 --- a/admin/internal/licensing/issue.go +++ b/admin/internal/licensing/issue.go @@ -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, diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index 4b31764..e5e23b6 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -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"` diff --git a/admin/internal/models/plans.go b/admin/internal/models/plans.go index 06a9177..c254225 100644 --- a/admin/internal/models/plans.go +++ b/admin/internal/models/plans.go @@ -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)) diff --git a/shared/license/plans.go b/shared/license/plans.go index 5d969a2..c4d2197 100644 --- a/shared/license/plans.go +++ b/shared/license/plans.go @@ -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,