docs: implementation plan for spec 7, metered licensing

Ten tasks, each ending in something independently verifiable. The plan
stops at the Paddle boundary on purpose: spec 7 lands before plan 5, so
there is no client, no webhook and no checkout here, and the configurator
is built as a component mounted for staff first rather than as a customer
screen with nothing behind it.

Corrects two things the spec got wrong about the control plane. Feature
gating is already built and mounted, so Free tenants have already lost the
console and no customer email is owed. The only real gap is that
HandleOIDCCallback lacks the check HandleOIDCStart already has, which is
the half that completes a sign-in.

Adds the Free self-hosted lifecycle the spec called for and the first draft
of the plan missed: linking issues nothing today, and renewInstance
hardcodes a monthly term that would hand a self-hosted install a one-month
licence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 23:34:34 +01:00
co-authored by Claude Opus 5
parent ff0caf5a90
commit 73b6b548f0
2 changed files with 4083 additions and 20 deletions
@@ -295,28 +295,28 @@ price, and issues with `ReasonTierChange` as it already would.
## Control-plane enforcement
Two of the six columns in the pricing table are enforced by nothing today. A
feature picker that sells an ungated checkbox sells nothing.
**Feature gating already exists and is already mounted.** `RequireFeature` in
`server/internal/api/licence.go` answers 403 `feature_unavailable`, and
`server/internal/api/handlers.go` already wraps `POST /api/console/connect`,
`GET /api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. Free's feature list
is empty, so a Free instance already cannot open the console. **No capability is
taken away from an existing tenant by this spec, and no customer email is owed.**
**`license.HasFeature` is currently called from nowhere.** Add gates:
**One gap remains, and it is a single check.** `HandleOIDCStart` already tests
`Feature("oidc")` and redirects to `/login?error=oidc_unavailable`.
`HandleOIDCCallback` does not test it at all. A start that 403s is a dead end; an
ungated callback completes a sign-in, so the unguarded half is the half that
matters.
- `POST /api/console/connect` and `GET /api/console/tunnel` require
`FeatureConsole`.
- `GET`/`PUT /api/org/oidc`, `/auth/oidc/start` and `/auth/oidc/callback` require
`FeatureOIDC`. The callback matters most: an expired or downgraded licence must
not leave a working side door into the instance.
The callback cannot copy the start's instance resolution: the start reads
`InstanceFromHost(c)`, while the callback resolves the instance from the OAuth
state it consumes, and by then it holds `instanceID` directly. The check goes
after `ConsumeStateInstance` and before `providerForInstance`, so a licence that
lapsed mid-flow stops the exchange rather than completing it.
A new `FeatureError` maps to 403 with a machine-readable body, mirroring the
existing `LimitError`. `web/` hides the Console button and the SSO card when the
feature is absent, but as everywhere else in this codebase the API is the
boundary and the UI is the courtesy.
**This removes a capability from existing Free cloud tenants.** Free's features
list has always been empty, but nothing gated on it, so a Free instance can use
the browser console today and will not be able to afterwards. That is the
intended pricing, and it is a deliberate behaviour change rather than a
side-effect — it needs to be named in the release note and, ideally, emailed to
affected accounts before the gate lands.
`web/` hides the Console button and the SSO card when the feature is absent, but
as everywhere else in this codebase the API is the boundary and the UI is the
courtesy.
**`CheckMonitorLimit`** joins the three existing checks in
`server/internal/services/licence_limits.go`, counting `monitors` for the
@@ -445,7 +445,9 @@ sharp edge in the whole migration and it is worth a comment at the decode site.
- Free self-hosted can be created, renewed from HQ, and lapses to read-only
without being reaped.
- Unticking Browser console removes it from the next issued licence, and
`POST /api/console/connect` answers 403 on an instance whose licence lacks it.
`POST /api/console/connect` answers 403 on an instance whose licence lacks it
(already true; the new part is that a customer controls the tick).
- `/auth/oidc/callback` answers 403 on an instance whose licence lacks `oidc`.
- A monitor beyond the cap is refused with a machine-readable 403.
- `audit_logs` older than the licence's retention are gone, and an unlimited
licence's are not.