feat(license): metered licensing — catalogue, entitlements, and enforcement
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:
2026-07-27 09:37:39 +01:00
co-authored by Claude Opus 5
parent 3fc726da9e
commit c4e6ad5485
35 changed files with 2241 additions and 222 deletions
+14
View File
@@ -98,6 +98,20 @@ func HandleOIDCCallback(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
// The start handler checks this too, but an ungated callback is the half
// that matters: a start that refuses is a dead end, while a callback that
// completes signs somebody in. A licence that lapsed mid-flow stops the
// exchange here rather than after it.
//
// Resolved from the consumed state rather than from the host, because on
// this route the instance is whatever the state said and nobody is signed
// in yet.
if !services.GetLicenseState(instanceID).Feature("oidc") {
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
return
}
provider, oauthCfg, err := providerForInstance(ctx, c, instanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})