Skip to content

How licensing works

A bugb-server licence is a short Ed25519-signed token carrying claims: which product it is for, who it was issued to, the tier, the seat count, and an expiry. The server checks that signature itself.

Two properties matter more than the rest, and they sound like they contradict each other. They do not.

The server verifies a licence offline. It does not ask a Bugb service whether your licence is valid, and there is no revocation list to reach.

That is deliberate, and it is why self-hosting is worth doing: a licence check that phones home turns a Bugb outage into your outage. A deployment that already holds a valid token keeps working whatever happens to us.

A published bugb-server verifies against a key stamped into the image at build time. It reads no environment variable to change it: BUGB_LICENSE_PUBLIC_KEY is ignored on a published image, and the server logs one line saying so.

That is a deliberate change from how it used to work, and the reasoning is worth knowing because it decides what a rotation costs. When the key was configuration, there were two doors in the same wall: leave the variable unset and every route was open, or set it to a key you generated yourself and sign your own licence for as many seats as you liked. Paying was therefore a downgrade. Refusing to boot without a key closes only the first door. Pinning the key into the artifact closes both.

What it costs: a key rotation is now an image release. The mechanism is a keyring, a JSON object mapping key id to key, so two generations verify for the length of an overlap and the token’s kid selects between them. But the new generation arrives in a new image, not a config push.

A published server holds a licence or serves nothing

Section titled “A published server holds a licence or serves nothing”

With no licence, or one that is malformed, or one signed by somebody who is not us, a published bugb-server starts, says why in its log, and answers 402 to every request until it holds one. Not a warning and not a reduced feature set: the API, the dashboard at /app, and the token-minting script inside the container all refuse. /openapi.json is the single exception, kept open so a liveness probe reads “unlicensed” rather than “dead” and nothing restart-loops over an invoice.

A lapsed licence is a different thing. An expired licence is still a licence: it verifies, reads keep working, the grace window still applies, and only writes refuse. Everything below this line is about that case.

A licence is valid for 30 days. A server configured with a deployment key fetches a fresh one every day, and is handed a new 30 day term for as long as the subscription is live.

Nobody pastes anything at renewal. There is no expiry to diarise and no button for a human to press, and no restart: a refreshed licence is adopted as it arrives and takes effect on the next request.

What that buys, and what it costs:

  • A renewal is invisible. The subscription renews, the next daily fetch returns a fresh 30 days, and nothing about the deployment changes.
  • A cancellation ends the licence on its own. Refresh stops returning tokens, the held one runs out, and writes stop about 37 days later: 30 days of term plus the grace window.
  • An outage is survivable. Fetches fail open. The server keeps the token it has and retries, and every successful fetch leaves most of the term in hand, so the portal can be unreachable for weeks before anything is felt.

The grace window is a quarter of the term, capped at 14 days. A 30 day licence keeps writing for roughly a week past its expiry; an annual one gets the full fortnight. The cap is the verifier’s policy and is not in the token, so changing it never means reissuing anything.

GET /v1/license is the diagnostic, and it is never gated, because the route that answers “why am I getting a 402?” must not answer with another one.

state active Writes Reads
valid true accepted 200
grace true accepted 200
expired false 402 200
null, with a licence that did not verify false 402 200 on a dev build; a published image refuses everything

Inside its grace window a licence is still working:

{
"mode": "licensed",
"product": "bugb",
"schema_version": 1,
"tier": "team",
"state": "grace",
"active": true,
"expires_at": "2026-09-06T19:23:31+00:00",
"seats": 25,
"seats_used": 2,
"features": [],
"repo_limit": null
}

Past it, the same route says so and the write path refuses:

{
"state": "expired",
"active": false,
"expires_at": "2026-06-03T00:00:00+00:00",

Truncated to the three fields that changed.

{"detail":"license expired: acme's team plan expired at 2026-06-03T00:00:00+00:00 and the grace window ended at 2026-06-17T00:00:00+00:00"}
HTTP 402

The same server, on the read path, at the same moment:

GET /v1/repos -> HTTP 200

A lapsed customer keeps every GET, and the dashboard with it. Their findings are theirs: the event log is an audit trail they may be legally required to produce, and holding it hostage over a renewal is not a business worth being in. Billing pressure lands on the write path, where it costs new data rather than history.

402 and not 403 on purpose. The caller’s token is fine and their role is fine. What is wrong is a commercial fact about the deployment, so the person who can fix it is not the person holding the token. A 403 would send them to the wrong colleague.

A seat is a distinct identity among a tenant’s non-revoked tokens, and seats_used reports that count beside the seats the licence grants. The cap is enforced when a credential is minted and nowhere else, so a token that already exists keeps working: a customer over their cap finds out when somebody asks for a new credential and is at a keyboard expecting an answer, not as an outage in a credential CI has been using for a month.

Air-gapped deployments are supported, not degraded

Section titled “Air-gapped deployments are supported, not degraded”

Leave the deployment key unset and pin a token instead. With no deployment key configured the server builds no HTTP client and makes no outbound request of any kind. It holds no poll task, no thread, and no timer.

That is a supported shape, and the offline guarantee above is why it can be. Licences for those deployments are issued on a longer term through the engagement, because a token that cannot refresh cannot be renewed silently either.

Set the deployment key or a pinned token, never both

Section titled “Set the deployment key or a pinned token, never both”

An explicit BUGB_LICENSE_TOKEN wins over anything refresh fetches. A deployment configured with both runs on the pinned value and ignores every token it downloads. The server logs the refusal each time it declines, so the misconfiguration is visible in the log rather than only at the moment the pinned token expires.

Configured Result
BUGB_DEPLOYMENT_KEY only refreshes daily. The normal deployment
BUGB_LICENSE_TOKEN only pinned, never dials out. The air-gapped deployment
Both pinned wins. Refresh does nothing
Neither a published image serves nothing. A source build gates nothing