App licensing
licenceSell your app with signed licence files that switch features on, verify offline against a pinned key, and keep serving through a grace period.
licenceFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
licence lets you sell your app with signed licence files. The app checks them offline against a public key you pin, and licence.require("sso") switches a licensed feature on or off.
At a glance
licence:
driver: http
product: acme-desktop
roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"] # the well-known test root; pin your own in production
file: ./main.licence
features: [sso, audit-export]
url: https://api.mainsdk.dev
refresh: 6h
trust: ./main.licence-trust # CA certificates and revocation lists
machine: os # os | env
over: enforce # enforce | report
skew: 5m # clock skew tolerated, 0 to 1himport { licence } from "mainsdk";
// 403 unless the licence in scope includes sso and is valid or in grace
licence.require("sso");
// "valid", "grace", "expired", "revoked", "suspended", "tampered" or "missing"
const { status, graceEndsAt } = licence.state();lib/licence.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { createClock, createTestApp, createTestLicenceCA } from "@mainsdk/testing";
const yaml = `schema: 1
project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY
licence:
product: acme-desktop
roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]
features: [sso, audit-export]
`;
const ca = await createTestLicenceCA({ product: "acme-desktop", project: "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY" });
it("turns sso off when the licence has no sso", async () => {
// test licences start on 2026-11-01, so set the clock after that
const app = createTestApp({ yaml, clock: createClock(Date.UTC(2026, 10, 1, 12)) });
await app.licence.install(await ca.issue({ features: ["audit-export"] }), { scope: "app" });
assert.equal(app.licence.has("sso"), false);
assert.equal(app.licence.has("audit-export"), true);
assert.throws(() => app.licence.require("sso"), { code: "MAIN-LICENCE-0001" });
});
it("refuses a licence with one changed byte", async () => {
const app = createTestApp({ yaml, clock: createClock(Date.UTC(2026, 10, 1, 12)) });
const bad = ca.tamper(await ca.issue(), "payload");
await assert.rejects(app.licence.install(bad, { scope: "app" }), { code: "MAIN-LICENCE-0004" });
});Calls
licence.state()licence.has(feature)licence.require(feature)licence.limit(name)licence.get()licence.update(token, { scope? })licence.refresh()licence.machine()licence.watch(scope, fn)new AppLicence(opts)storeKv(store)parseLicenceConfig(raw)licenceIssues(raw)createTestLicenceCA(opts?)
Guarantees
Whatever the driver.
- Verified offline against pinned root keys; no call home on the request path
- A licence can't be edited, forged or rolled back to an older one through anything the SDK reads
- An unreachable revocation list or refresh endpoint never blocks a valid licence
- Expiry plus grace is the only stop, and it turns off only licensed features; the app always starts
- A licence only narrows plans and limits, never widens them
Drivers
3 drivers. Select one to see its line in main.yaml.
licence:
driver: offlineChange this one line to swap providers; your calls stay the same.Offline integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-LICENCE-0011 | Checkmainsdk check, before deploy | The licence section is invalid: unknown driver or key, no product, no roots or more than 4, a root that isn't ed25519: and 32 bytes, both file and token, http without url, refresh outside 5m to 7d, skew outside 0 to 1h. The app doesn't start |
MAIN-LICENCE-0012 | Checkmainsdk check, before deploy | A feature name passed to licence.has or licence.require isn't under licence.features |
MAIN-LICENCE-0014 | Checkmainsdk check, before deploy | The test root is pinned under licence.roots in production |
MAIN-LICENCE-0001 | RuntimeError responses and logs | The feature needs a licence and the licence in scope doesn't include it, or isn't valid or in grace (403, with licence: { feature, status, reason }) |
MAIN-LICENCE-0002 | RuntimeError responses and logs | A licensed limit is reached: the licence's ceiling is the one that binds (403, with licence: { name, limit }) |
MAIN-LICENCE-0003 | RuntimeError responses and logs | A licensed limit was passed with over: report; the call was allowed. Logged once per limit and scope per UTC day |
MAIN-LICENCE-0004 | RuntimeError responses and logs | A licence, CA certificate or revocation list doesn't verify; reason is signature, chain, format, subject, binding, rollback or test-root (400 from licence.update()) |
MAIN-LICENCE-0005 | RuntimeError responses and logs | The licence is in grace: expiring, revoked or suspended. Warned once a day with graceEndsAt |
MAIN-LICENCE-0006 | RuntimeError responses and logs | The licence is expired, revoked or suspended past its grace; licensed features are off |
MAIN-LICENCE-0007 | RuntimeError responses and logs | No licence, or not yet valid; licensed features are off |
MAIN-LICENCE-0008 | RuntimeError responses and logs | Refresh failed, or the newest revocation list is past its nextUpdate; the current state is kept. Logged once per outage |
MAIN-LICENCE-0009 | RuntimeError responses and logs | An older licence (seq) or revocation list (number), or a different list with the same number, was refused; the newer one stays |
MAIN-LICENCE-0010 | RuntimeError responses and logs | The clock is behind the high-water time by more than licence.skew; the high-water time is used for licence decisions |
MAIN-LICENCE-0013 | RuntimeError responses and logs | A feature name that isn't under licence.features: has returns false and logs once; require throws (500) |
MAIN-LICENCE-0015 | RuntimeError responses and logs | The machine id can't be read: no /etc/machine-id for machine: os, or MAINSDK_MACHINE_ID unset for machine: env (500) |
MAIN-LICENCE-0016 | RuntimeError responses and logs | licence.update refused: no licence.manage permission, an app licence outside tenant.system() in a tenanted app, or a tenant licence for another tenant (403) |
MAIN-LICENCE-0020 | RuntimeError responses and logs | Cloud: the caller lacks the role, or a write has no passkey session from the last 5 minutes (403) |
MAIN-LICENCE-0021 | RuntimeError responses and logs | Cloud: the licence claims are invalid; errors names each field (422) |
MAIN-LICENCE-0022 | RuntimeError responses and logs | Cloud: no such root, CA or licence in this account (404) |
MAIN-LICENCE-0023 | RuntimeError responses and logs | Cloud: the change conflicts with the state, such as renewing a revoked licence or issuing from a retired CA (409) |
MAIN-LICENCE-0024 | RuntimeError responses and logs | Cloud: a second approver is needed; the problem carries approvalId |
MAIN-LICENCE-0025 | RuntimeError responses and logs | Cloud: more than 60 refresh calls for one licence in an hour (429, Retry-After) |
MAIN-LICENCE-0026 | RuntimeError responses and logs | Cloud: the key service can't sign; nothing was issued or changed (503, retryable) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| DSSE over JCS, Ed25519, root to CA to licence | Phase 1 | 0.4 | licence#verify |
| States: valid, grace, expired, revoked, suspended, tampered, missing | Phase 1 | 0.4 | licence#state |
| require, has, limit and state reading memory only | Phase 1 | 0.4 | licence#checks |
| Signed revocation lists with per-entry grace | Phase 1 | 0.4 | licence#revocation |
| Anti-rollback of licences | Phase 1 | 0.4 | licence#anti-rollback |
| Licensed features and limits narrow entitlements | Phase 1 | 0.4 | licence#entitlements |
| Licence limits lower limits quotas | Phase 1 | 0.4 | licence#limits |
| Tenant licences | Phase 1 | 0.4 | licence#tenant |
| Background refresh over HTTP | Phase 1 | 0.4 | licence#refresh |
| mainsdk licence verify and show | Phase 1 | 0.4 | licence#cli |
| Test licence CA and golden vectors | Phase 1 | 0.4 | licence#testing |
| Issue, renew, suspend, revoke and rotate in MainSDK Cloud | Phase 2 | 0.6 | licence-cloud#issue |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Teams | Roots and issuing CAs in the Cloud key service; issue, renew, suspend, revoke and rotate licences per tenant and environment, with audit, hosted revocation lists and refresh (spec licence-cloud) |
Quick start
licence:
product: acme-desktop
roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"] # the well-known test root; pin your own in production
file: ./main.licence
features: [sso, audit-export]app/api/sso/route.ts
import { licence } from "mainsdk";
export async function GET() {
licence.require("sso"); // throws MAIN-LICENCE-0001 (403) unless the licence is valid or in grace and lists sso
return Response.json({ ok: true });
}use mainsdk::prelude::*;
let app = App::test();
let state = app.licence().state().await?;
assert_eq!(state.status, "missing");Configuration
licence:
driver: http # offline (default) | http | local
product: acme-desktop # must equal the licence's product; a-z, 0-9 and -, 1 to 64 characters
roots: # pinned root public keys, 1 to 4
- ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ
file: ./main.licence # read at start; or token, not both
# token: secret:MAINSDK_LICENCE
trust: ./main.licence-trust # optional: CA certificates and revocation lists
features: [sso, audit-export, offline-sync]
url: https://api.mainsdk.dev # http driver only
refresh: 6h
skew: 5m
over: enforce
machine: os| Key | What it does |
|---|---|
driver | offline reads files and tokens only. http verifies offline and refreshes in the background. local serves a dev licence for mainsdk dev and tests |
product | The licence's product must equal it, or the licence is tampered with reason subject |
roots | Pinned root keys: ed25519: and the base64url of the 32-byte key. A licence verifies only through a chain that ends at one |
file, token | Where the app licence comes from. token is a secret:NAME holding the compact mlic1. form |
trust | A trust bundle file with CA certificates and revocation lists |
features | The feature names that need a licence. A name not listed here is refused with MAIN-LICENCE-0013 |
url | http driver only, and required there: https, or localhost. Its host is added to network.allow |
refresh | http driver: 5m to 7d, jittered by up to 10%. A licence with under a third of its lifetime left refreshes at once |
skew | Clock skew tolerated, 0 to 1h |
over | enforce refuses a call past a licensed limit. report allows it, logs MAIN-LICENCE-0003 and publishes mainsdk:licence.over |
machine | Source of the machine id: os (/etc/machine-id, Linux) or env (MAINSDK_MACHINE_ID) |
An invalid section stops the app with MAIN-LICENCE-0011; mainsdk check finds it first. The full key list is in the main.yaml reference.
What you get
A customer gets a signed licence file, the way desktop and self-hosted software is sold. The key you pin is in main.yaml. Nothing calls home while a request runs. When a licence expires, or you revoke it and the app hears about it, licensed features keep working through a grace period and then turn off. The rest of the app keeps serving. It runs in your app and is free. Licensing explains how app licences differ from TAPS licences, API keys and entitlements.
Examples
Gate a feature
has returns a boolean from memory. require throws. Both are true only while the licence in scope is valid or in grace and lists the feature.
import { licence } from "mainsdk";
const showAudit = licence.has("audit-export"); // false when expired, revoked, tampered or missing
licence.require("sso"); // MAIN-LICENCE-0001 with { feature, status, reason }Read the state and the limits
state() returns the status, the reason, the dates and whether revocation data is fresh. limit(name) returns the licence's number, seats for "seats", or null. get() returns the verified claims, or null when the licence is missing or tampered.
import { licence } from "mainsdk";
const { status, reason, expiresAt, graceEndsAt, revocation } = licence.state();
const seats = licence.limit("seats"); // number | null
const claims = licence.get(); // claims.features, claims.plan, claims.taps ... or null
if (status === "grace") console.warn(`licence ends ${graceEndsAt} (${reason})`);Watch for changes
watch(scope, fn) calls fn once per status change for the app ("app") or one tenant id, and returns an unsubscribe.
import { licence } from "mainsdk";
declare function notifyOwner(status: string, reason?: string): void;
const stop = licence.watch("app", (s) => {
if (s.status !== "valid") notifyOwner(s.status, s.reason);
});
// stop() to unsubscribeStatus changes are worked out when the licence is read or installed. There is no timer, so fn runs on the next read, install or refresh after a status changes, not at the instant of expiry.
Install a licence
update(token, { scope? }) verifies a compact token, then stores it. A token that doesn't verify throws MAIN-LICENCE-0004 (400) and changes nothing. The caller needs the licence.manage permission. An app licence is installed outside a tenant or inside tenant.system(); a tenant licence (scope: "tenant") must name the current tenant. Anything else is MAIN-LICENCE-0016.
import { licence, tenant } from "mainsdk";
export async function installTenantLicence(tenantId: string, token: string) {
return tenant.run(tenantId, () => licence.update(token, { scope: "tenant" }));
}
export async function installAppLicence(token: string) {
return tenant.system("install licence", () => licence.update(token, { scope: "app" }));
}An installed licence takes precedence over licence.file and licence.token while its seq is the highest. Tenant licences load at start and refresh from the runtime store.
Refresh and machine id
refresh() fetches new licences and revocation lists now. On the http driver it posts to the refresh endpoint; on offline it re-reads the files. It never throws for a network fault. machine() returns this machine's id, m_ and 32 hex digits, which you give your vendor for a machine-bound licence.
import { licence } from "mainsdk";
const state = await licence.refresh();
const id = await licence.machine(); // MAIN-LICENCE-0015 when the OS id can't be readHow a licence is checked
A licence file holds your licence, signed by an issuing CA, and the CA's certificate, signed by a root. The app pins the root, so the chain verifies with no network. Ed25519 and canonical JSON make any changed byte fail. A licence is accepted only if every step passes, in order: format, chain, signature, CA scope, test root, subject, environment and machine binding, then no rollback. The first failure decides the reason.
| Status | Licensed features | Reason | When |
|---|---|---|---|
valid | On | Verified, inside its dates, no revocation applies | |
grace | On, with a daily warning | expiring, revoked, suspended, key-compromise | Expired, or revoked or suspended by a signed list, within its grace |
expired | Off | expiring | Past expiry plus grace |
revoked, suspended | Off | revoked, suspended, key-compromise | Past the grace of a revocation entry |
tampered | Off | signature, chain, format, subject, binding, rollback, test-root | The licence doesn't verify |
missing | Off | none, not-yet-valid | No licence installed, or its start date is in the future |
revocation in the state is fresh (the newest list for the licence's CA is before its nextUpdate), stale (past it) or none (never seen). A suspension is lifted by a later list that leaves the entry out. A revoked entry stays.
With entitlements and limits
A feature under licence.features is in a tenant's plan only when the plan includes it and the licence does. entitlements.require for such a feature throws MAIN-LICENCE-0001, not MAIN-PLAN-0001. A licence limit, or seats, that has the name of a plan limit or of a limits.quotas or limits.concurrency entry lowers it. It never raises it. A tenant licence with a plan is a plan source in entitlements, after a manual plan and before pay.
over: report covers plan-limit checks and quotas. Concurrency has no report mode: a concurrency ceiling from a licence always enforces.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-LICENCE-0001 | The feature needs a licence the scope doesn't have, or the licence isn't valid or in grace (403) | mainsdk licence show; install a licence that lists it |
MAIN-LICENCE-0002 | A licensed limit is reached (403) | Ask for a licence with a higher limit, or set over: report |
MAIN-LICENCE-0003 | A licensed limit was passed with over: report (logged) | Renew with a higher limit |
MAIN-LICENCE-0004 | A licence, certificate or list doesn't verify; update throws it (400) | Ask the issuer for a new file; check the roots, product and project |
MAIN-LICENCE-0005 | The licence is in grace (warned daily) | Renew it |
MAIN-LICENCE-0006 | Expired, revoked or suspended past grace (logged once) | Install a renewed licence |
MAIN-LICENCE-0007 | No licence, or not valid yet (logged) | Set licence.file or licence.token |
MAIN-LICENCE-0008 | Refresh failed or the newest list is stale; the state is kept (logged once per outage) | Check licence.url and the network |
MAIN-LICENCE-0009 | An older licence or list was refused; the newer one stays | Install the newest licence |
MAIN-LICENCE-0010 | The clock is behind the high-water time by more than skew | Fix the machine's clock |
MAIN-LICENCE-0011 | The licence section is invalid; the app doesn't start (check, runtime) | Fix the key the finding names |
MAIN-LICENCE-0012 | A name passed to has or require isn't under licence.features (check) | Add it, or fix the typo |
MAIN-LICENCE-0013 | The same at runtime: has returns false, require throws (500) | Add it, or fix the typo |
MAIN-LICENCE-0014 | The test root is pinned in production (check) | Pin your own root |
MAIN-LICENCE-0015 | The machine id can't be read (500) | Set machine: env and MAINSDK_MACHINE_ID |
MAIN-LICENCE-0016 | update refused: no licence.manage, wrong scope or wrong tenant (403) | Grant it, or install in the right tenant |
See check codes and errors for all of them.
Swapping drivers
| Driver | Use it for |
|---|---|
offline (default) | Files and tokens only, with a trust bundle from licence.trust. No network call at all |
http | Offline verification plus background refresh of licences, CA certificates and revocation lists |
local | mainsdk dev and tests. It trusts the test root and, with no licence installed, serves a 30-day dev licence with every name under licence.features and test: true. mainsdk check refuses it in production |
The http driver refreshes only after the server listens: first within 60 seconds, then every licence.refresh, with exponential backoff from 1 minute on failure and at least Retry-After after a 429. The wire contract is POST {licence.url}/v1/licences/refresh. The answer is untrusted: every licence, certificate and list in it is verified like a file, and what fails is dropped and logged.
Testing
createTestLicenceCA() from @mainsdk/testing makes a CA under the well-known test root. It issues licences, rotates, revokes, writes signed lists and tampers with tokens. app.licence.install(token, { scope, tenant }) installs one under createTestApp() as the system would. createRefreshServer() is a fake refresh endpoint for the http driver, and buildLicenceFixtures() rebuilds the golden files in packages/testing/fixtures/licence/ byte for byte.
import { it } from "node:test";
import assert from "node:assert/strict";
import { createClock, createTestApp, createTestLicenceCA } from "@mainsdk/testing";
const yaml = `schema: 1
project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY
licence:
product: acme-desktop
roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]
features: [sso, audit-export]
`;
const ca = await createTestLicenceCA({ product: "acme-desktop", project: "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY" });
it("turns sso off when the licence has no sso", async () => {
// test licences start on 2026-11-01, so set the clock after that
const app = createTestApp({ yaml, clock: createClock(Date.UTC(2026, 10, 1, 12)) });
await app.licence.install(await ca.issue({ features: ["audit-export"] }), { scope: "app" });
assert.equal(app.licence.has("sso"), false);
assert.equal(app.licence.has("audit-export"), true);
assert.throws(() => app.licence.require("sso"), { code: "MAIN-LICENCE-0001" });
});
it("refuses a licence with one changed byte", async () => {
const app = createTestApp({ yaml, clock: createClock(Date.UTC(2026, 10, 1, 12)) });
const bad = ca.tamper(await ca.issue(), "payload");
await assert.rejects(app.licence.install(bad, { scope: "app" }), { code: "MAIN-LICENCE-0004" });
});From the shell, mainsdk licence verify and mainsdk licence show check a file. See the CLI and the testing kit.
Security and privacy
has,require,state,limitandgetread memory only: no store, file or network call, so a check adds no request-path latency.- Verification uses the platform's WebCrypto and makes no network call. A licence signed directly by a root is
tampered. The test root is refused in production. - The app remembers the highest licence
seq, the highest listnumberand the latest time it has seen. An older licence, an older list or a clock set backwards is refused and raises a security event. - Logs, channels, problems and audit records carry the licence id, key ids and machine id, never the token, a signature or the raw machine source.
- Channels:
mainsdk:licence.verify,.state,.check,.refreshand.over. Audit records:licence.installed,licence.renewed,licence.state_changed.
What it promises, and what it doesn't
A licence can't be forged or edited without a CA's private key. A CA can't exist without a pinned root's private key. An app that has seen a newer licence, a newer list or a later time can't be rolled back through any input the SDK reads. A network or clock fault never stops the app.
It doesn't stop someone who controls the machine. MainSDK is open source and runs on the customer's machine, so that person can patch the SDK, replace the pinned root, wipe the runtime store to forget the rollback marks, or keep the machine offline so a revocation never arrives. The seal reports changed files, but can be rewritten too. Machine binding stops a casual copy between machines; with machine: env the id is set by whoever runs the app. Licensing keeps honest customers honest and makes your terms auditable. Serve features worth stealing from a server you run.
FAQ
Does a licence check make a network call? No. Verification is offline. Only the http driver talks to your refresh endpoint, in the background after the server listens, and a failure never blocks a valid licence.
What happens when the licence expires? The state moves to grace, then expired. Only features under licence.features and licence limits turn off. The app starts and serves either way.
Who issues licences? Today, your own CA, or the testing kit for development. Issuing from MainSDK Cloud is planned for 0.6 (spec licence-cloud).
Next
- Licensing: app licences compared with TAPS licences, API keys and entitlements
- License your app: the steps, start to finish
- Plans and entitlements
- Rate limits and quotas
Helpers for wiring and tests
| Call | What it does |
|---|---|
new AppLicence(opts) | The licence of one app instance, for tests and custom wiring; apps use licence.has and licence.limit. |
storeKv(store) | Wraps a Store as the key-value port an AppLicence keeps its cached licence in. |
parseLicenceConfig(raw) | Read the licence section of main.yaml into settings, or null when it is absent. |
licenceIssues(raw) | List what is wrong in the licence section, without throwing. |
Was this page helpful?