MainSDK
Search⌘K
Interfaces

Payments and plans

App licensing

licence

Sell your app with signed licence files that switch features on, verify offline against a pinned key, and keep serving through a grace period.

Building for 0.4ships as a experimental APIPhase 1 · Core, public betaSpec licenceFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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 1h

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.

main.yamlOffline (licence and revocation files only) (default)
licence:
  driver: offline

Change this one line to swap providers; your calls stay the same.Offline integration

Codes

CodeWhere it appearsMeaning
MAIN-LICENCE-0011Checkmainsdk check, before deployThe 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-0012Checkmainsdk check, before deployA feature name passed to licence.has or licence.require isn't under licence.features
MAIN-LICENCE-0014Checkmainsdk check, before deployThe test root is pinned under licence.roots in production
MAIN-LICENCE-0001RuntimeError responses and logsThe 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-0002RuntimeError responses and logsA licensed limit is reached: the licence's ceiling is the one that binds (403, with licence: { name, limit })
MAIN-LICENCE-0003RuntimeError responses and logsA licensed limit was passed with over: report; the call was allowed. Logged once per limit and scope per UTC day
MAIN-LICENCE-0004RuntimeError responses and logsA 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-0005RuntimeError responses and logsThe licence is in grace: expiring, revoked or suspended. Warned once a day with graceEndsAt
MAIN-LICENCE-0006RuntimeError responses and logsThe licence is expired, revoked or suspended past its grace; licensed features are off
MAIN-LICENCE-0007RuntimeError responses and logsNo licence, or not yet valid; licensed features are off
MAIN-LICENCE-0008RuntimeError responses and logsRefresh failed, or the newest revocation list is past its nextUpdate; the current state is kept. Logged once per outage
MAIN-LICENCE-0009RuntimeError responses and logsAn older licence (seq) or revocation list (number), or a different list with the same number, was refused; the newer one stays
MAIN-LICENCE-0010RuntimeError responses and logsThe clock is behind the high-water time by more than licence.skew; the high-water time is used for licence decisions
MAIN-LICENCE-0013RuntimeError responses and logsA feature name that isn't under licence.features: has returns false and logs once; require throws (500)
MAIN-LICENCE-0015RuntimeError responses and logsThe machine id can't be read: no /etc/machine-id for machine: os, or MAINSDK_MACHINE_ID unset for machine: env (500)
MAIN-LICENCE-0016RuntimeError responses and logslicence.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-0020RuntimeError responses and logsCloud: the caller lacks the role, or a write has no passkey session from the last 5 minutes (403)
MAIN-LICENCE-0021RuntimeError responses and logsCloud: the licence claims are invalid; errors names each field (422)
MAIN-LICENCE-0022RuntimeError responses and logsCloud: no such root, CA or licence in this account (404)
MAIN-LICENCE-0023RuntimeError responses and logsCloud: the change conflicts with the state, such as renewing a revoked licence or issuing from a retired CA (409)
MAIN-LICENCE-0024RuntimeError responses and logsCloud: a second approver is needed; the problem carries approvalId
MAIN-LICENCE-0025RuntimeError responses and logsCloud: more than 60 refresh calls for one licence in an hour (429, Retry-After)
MAIN-LICENCE-0026RuntimeError responses and logsCloud: 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.

FeaturePhaseMilestoneSpec ticket
DSSE over JCS, Ed25519, root to CA to licencePhase 10.4licence#verify
States: valid, grace, expired, revoked, suspended, tampered, missingPhase 10.4licence#state
require, has, limit and state reading memory onlyPhase 10.4licence#checks
Signed revocation lists with per-entry gracePhase 10.4licence#revocation
Anti-rollback of licencesPhase 10.4licence#anti-rollback
Licensed features and limits narrow entitlementsPhase 10.4licence#entitlements
Licence limits lower limits quotasPhase 10.4licence#limits
Tenant licencesPhase 10.4licence#tenant
Background refresh over HTTPPhase 10.4licence#refresh
mainsdk licence verify and showPhase 10.4licence#cli
Test licence CA and golden vectorsPhase 10.4licence#testing
Issue, renew, suspend, revoke and rotate in MainSDK CloudPhase 20.6licence-cloud#issue

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
TeamsRoots 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

main.yaml
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 });
}

Configuration

main.yaml
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
KeyWhat it does
driveroffline reads files and tokens only. http verifies offline and refreshes in the background. local serves a dev licence for mainsdk dev and tests
productThe licence's product must equal it, or the licence is tampered with reason subject
rootsPinned root keys: ed25519: and the base64url of the 32-byte key. A licence verifies only through a chain that ends at one
file, tokenWhere the app licence comes from. token is a secret:NAME holding the compact mlic1. form
trustA trust bundle file with CA certificates and revocation lists
featuresThe feature names that need a licence. A name not listed here is refused with MAIN-LICENCE-0013
urlhttp driver only, and required there: https, or localhost. Its host is added to network.allow
refreshhttp driver: 5m to 7d, jittered by up to 10%. A licence with under a third of its lifetime left refreshes at once
skewClock skew tolerated, 0 to 1h
overenforce refuses a call past a licensed limit. report allows it, logs MAIN-LICENCE-0003 and publishes mainsdk:licence.over
machineSource 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.

app/settings/page.tsx
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.

src/licence-banner.ts
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.

src/licence-watch.ts
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 unsubscribe

Status 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.

src/admin/licence.ts
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.

src/admin/refresh.ts
import { licence } from "mainsdk";

const state = await licence.refresh();
const id = await licence.machine();   // MAIN-LICENCE-0015 when the OS id can't be read

How 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.

StatusLicensed featuresReasonWhen
validOnVerified, inside its dates, no revocation applies
graceOn, with a daily warningexpiring, revoked, suspended, key-compromiseExpired, or revoked or suspended by a signed list, within its grace
expiredOffexpiringPast expiry plus grace
revoked, suspendedOffrevoked, suspended, key-compromisePast the grace of a revocation entry
tamperedOffsignature, chain, format, subject, binding, rollback, test-rootThe licence doesn't verify
missingOffnone, not-yet-validNo 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

CodeWhenFix
MAIN-LICENCE-0001The 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-0002A licensed limit is reached (403)Ask for a licence with a higher limit, or set over: report
MAIN-LICENCE-0003A licensed limit was passed with over: report (logged)Renew with a higher limit
MAIN-LICENCE-0004A 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-0005The licence is in grace (warned daily)Renew it
MAIN-LICENCE-0006Expired, revoked or suspended past grace (logged once)Install a renewed licence
MAIN-LICENCE-0007No licence, or not valid yet (logged)Set licence.file or licence.token
MAIN-LICENCE-0008Refresh failed or the newest list is stale; the state is kept (logged once per outage)Check licence.url and the network
MAIN-LICENCE-0009An older licence or list was refused; the newer one staysInstall the newest licence
MAIN-LICENCE-0010The clock is behind the high-water time by more than skewFix the machine's clock
MAIN-LICENCE-0011The licence section is invalid; the app doesn't start (check, runtime)Fix the key the finding names
MAIN-LICENCE-0012A name passed to has or require isn't under licence.features (check)Add it, or fix the typo
MAIN-LICENCE-0013The same at runtime: has returns false, require throws (500)Add it, or fix the typo
MAIN-LICENCE-0014The test root is pinned in production (check)Pin your own root
MAIN-LICENCE-0015The machine id can't be read (500)Set machine: env and MAINSDK_MACHINE_ID
MAIN-LICENCE-0016update 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

DriverUse it for
offline (default)Files and tokens only, with a trust bundle from licence.trust. No network call at all
httpOffline verification plus background refresh of licences, CA certificates and revocation lists
localmainsdk 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.

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" });
});

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, limit and get read 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 list number and 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, .refresh and .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

Helpers for wiring and tests

CallWhat 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?

↑↓ moveEnter openEsc close

Not decided yet