MainSDK
Search⌘K
How-to

License your app

Gate paid features with signed licence files checked offline, test every state with the testing kit, and check a customer's file from the command line.

Building for 0.4

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

Your app will check a signed licence file offline, switch the features you sell on and off, and keep serving when the licence expires, when the network is down and when a revocation hasn't arrived. You will pin a root key, name the licensed features, gate them in code, test each state, and check a file with the CLI. It takes about 20 minutes.

You need

  • A MainSDK app with a main.yaml and a project id (see Thirty-minute path).
  • A product name, such as acme-desktop, that your licences name.
  • A root public key from your issuer. While you build, use the well-known test root ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ. mainsdk check refuses it in production.

1. Declare the licence

main.yaml
licence:
  product: acme-desktop
  roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"]   # the test root; swap for yours before you ship
  file: ./main.licence
  features: [sso, audit-export]

features lists every name that needs a licence. A name you pass to the code and don't list is refused. The default driver is offline: it reads the file and makes no network call.

2. Gate the feature

app/api/sso/route.ts
import { licence } from "mainsdk";

export async function GET() {
  licence.require("sso");   // throws MAIN-LICENCE-0001 (403) outside valid and grace
  return Response.json({ ok: true });
}

Use licence.has("audit-export") to hide a button, and licence.state() to show how many days are left. Both read memory only. Gate only what you sell: the app always starts and serves, and nothing else depends on the licence.

3. Test every 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]
`;

it("serves sso in grace and turns it off after", async () => {
  const clock = createClock(Date.UTC(2026, 10, 1, 12));
  const app = createTestApp({ yaml, clock });
  const ca = await createTestLicenceCA({ product: "acme-desktop", project: "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY" });
  await app.licence.install(await ca.issue({ features: ["sso"] }), { scope: "app" });   // valid until 2027-11-01, 14d grace
  assert.equal(app.licence.has("sso"), true);
  clock.advance("370d");
  assert.equal(app.licence.state().status, "grace");
  assert.equal(app.licence.has("sso"), true);
  clock.advance("20d");
  assert.equal(app.licence.state().status, "expired");
  assert.throws(() => app.licence.require("sso"), { code: "MAIN-LICENCE-0001" });
});

ca.revoke(...), ca.crl() and ca.tamper(token, how) cover revocation and tampering. See the licence interface.

4. Check a customer's file

bash
npx mainsdk licence show ./main.licence            # claims, chain, newest revocation list and state
npx mainsdk licence verify ./main.licence --strict   # exit 0 valid, 1 not valid or in grace, 2 usage

Run verify in CI against a sample licence, or on a customer's file before you send a support reply. A licence bound to a machine needs the machine's id: the customer runs npx mainsdk licence show --machine and sends you the m_… value.

5. Keep installs current

For online installs, use the http driver so renewals and revocations arrive without a release:

main.yaml
licence:
  driver: http
  url: https://licences.acme.dev    # your issuer's refresh endpoint; added to network.allow
  refresh: 6h

The app refreshes in the background after it starts listening, never while a request waits. For air-gapped installs, keep offline and copy a new licence file and a licence.trust bundle in; licence.update(token) installs a token at run time and needs the licence.manage permission. Give online installs short licences that refresh, because an app that can't reach the endpoint never learns of a revocation.

6. Before you ship

Replace the test root with your own and check:

bash
npx mainsdk check --env prod

A pass exits 0 with no MAIN-LICENCE finding. Keep the ownership clear: the CA's private key stays with your issuer, never in the app.

Check it worked

  • npx mainsdk licence verify ./main.licence exits 0.
  • The test above passes: has("sso") is true in grace and false after.
  • npx mainsdk check --env prod reports no MAIN-LICENCE-0011 and no MAIN-LICENCE-0014.

If it fails

CodeCauseFix
MAIN-LICENCE-0001The licence doesn't list the feature, or isn't valid or in graceRun mainsdk licence show; install a licence that lists it
MAIN-LICENCE-0004The file doesn't verify: wrong root, product or project, an older licence, or an edited byteRun mainsdk licence verify --json and read reason; ask the issuer for a new file
MAIN-LICENCE-0007No file at licence.file, or not valid yetPut the file there, or set licence.token
MAIN-LICENCE-0011The licence section is invalidFix the key the finding names
MAIN-LICENCE-0012The code names a feature that isn't under licence.featuresAdd it, or fix the typo
MAIN-LICENCE-0014The test root is pinned in productionPin your own root

What it can't do

It keeps honest customers honest and makes your terms auditable. It can't stop someone who controls the machine from patching the SDK, replacing the pinned root, wiping the runtime store or staying offline so a revocation never arrives. Serve features worth stealing from a server you run.

For agents

text
Goal: gate the features we sell with signed licences.
Read: docs/specs/licence.md, this page, main.yaml.
Touch only: main.yaml (licence section), the routes that gate a feature, tests.
Do: add the licence section with product, roots and features; call licence.require or licence.has for each
licensed feature; test valid, grace, expired and tampered with createTestLicenceCA.
Verify: npx mainsdk check --json --agent; npx mainsdk licence verify <file>
Never pin the test root for production, never put a CA private key or a licence token in code, and never gate
anything that isn't listed under licence.features.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet