feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s
Server Deploy / deploy (push) Successful in 5m22s
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1. Admin: plans re-keyed on (deployment, tier); new catalogue collection holds every Paddle price ID (one row per priceable component); new entitlements collection holds desired beside granted. admin/internal/catalogue owns both folds — entitlement to licence limits, and entitlement to Paddle line items — so the base allowance is subtracted in exactly one place. licensing.Issue now snapshots the instance's granted entitlement, never desired. Free is enforced per account AND deployment. Staff endpoints for plans, catalogue and entitlements; Free self-hosted can be claimed and renewed on its annual term; the reaper stays cloud-only. Server: enforces the monitor cap, audit-log retention (daily sweep, skips Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are filled from the seed plan at the single decode site so old blobs never read as zero. Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance screen, and a catalogue-driven PlanConfigurator mounted on the staff instance page. web shows monitors, audit retention and support level on the licence page. Docs: CLAUDE.md, spec index and plan 5 preamble updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,12 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/catalogue"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
@@ -18,7 +20,7 @@ import (
|
||||
var (
|
||||
ErrUnknownTier = errors.New("unknown tier")
|
||||
ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
|
||||
ErrFreeLimit = errors.New("this account already has a Free instance")
|
||||
ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type")
|
||||
ErrUnknownInstance = errors.New("instance not found")
|
||||
)
|
||||
|
||||
@@ -54,7 +56,10 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
return nil, ErrUnknownInstance
|
||||
}
|
||||
|
||||
plan, err := models.GetPlan(ctx, in.Tier)
|
||||
// The plan is looked up by the INSTANCE's deployment, not by a caller's
|
||||
// guess. That is what makes the deployment comparison below a consistency
|
||||
// check rather than the thing that decides which plan applies.
|
||||
plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier)
|
||||
if err != nil {
|
||||
return nil, ErrUnknownTier
|
||||
}
|
||||
@@ -67,11 +72,35 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
}
|
||||
|
||||
if plan.Tier == license.TierFree {
|
||||
if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil {
|
||||
if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// What this licence grants comes from the instance's entitlement, not from
|
||||
// the plan. The plan is only the base.
|
||||
//
|
||||
// An instance with no entitlement gets the plan's base, which covers staff
|
||||
// manual issuance and anything predating the backfill. Falling back is
|
||||
// deliberate: refusing here would make a missing row an outage rather than a
|
||||
// default.
|
||||
limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty())
|
||||
ent, entErr := models.GetEntitlement(ctx, inst.InstanceID)
|
||||
switch {
|
||||
case entErr == nil:
|
||||
// Granted, never Desired. A configuration nobody has paid for must not
|
||||
// reach a signed payload.
|
||||
limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve entitlement: %w", err)
|
||||
}
|
||||
case errors.Is(entErr, models.ErrNoEntitlement):
|
||||
log.Printf("licensing: instance %s has no entitlement; issuing plan base",
|
||||
inst.InstanceID)
|
||||
default:
|
||||
return nil, fmt.Errorf("read entitlement: %w", entErr)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expires := in.ExpiresAt
|
||||
if expires.IsZero() {
|
||||
@@ -94,10 +123,11 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
Deployment: plan.Deployment,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
// Snapshotted, not referenced: editing a plan tomorrow must not change
|
||||
// what this licence grants.
|
||||
Limits: plan.Limits,
|
||||
Features: plan.Features.OrEmpty(),
|
||||
// Snapshotted, not referenced: editing a plan or an entitlement tomorrow
|
||||
// must not change what this licence grants.
|
||||
Limits: limits,
|
||||
Features: features,
|
||||
SupportLevel: plan.SupportLevel,
|
||||
}
|
||||
|
||||
blob, err := license.Sign(payload, signingKey)
|
||||
@@ -111,8 +141,8 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
AccountID: inst.AccountID,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
Limits: plan.Limits,
|
||||
Features: plan.Features.OrEmpty(),
|
||||
Limits: limits,
|
||||
Features: models.Features(features).OrEmpty(),
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
Blob: blob,
|
||||
@@ -157,13 +187,19 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// checkFreeLimit enforces one Free instance per account.
|
||||
// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT.
|
||||
//
|
||||
// It used to be one per account, which was sufficient while Free existed only on
|
||||
// cloud. With a self-hosted Free plan, an account-wide count would refuse a
|
||||
// self-hosted Free instance to anyone holding a cloud one, and tell them about a
|
||||
// limit they have not reached.
|
||||
//
|
||||
// Cancelled instances do not count: a customer who cancelled their Free instance
|
||||
// is allowed another one.
|
||||
func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error {
|
||||
func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error {
|
||||
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
|
||||
"account_id": accountID,
|
||||
"deployment": deployment,
|
||||
"tier": license.TierFree,
|
||||
"status": bson.M{"$ne": models.StatusCancelled},
|
||||
"instance_id": bson.M{"$ne": exceptInstanceID},
|
||||
|
||||
Reference in New Issue
Block a user