Module Licensing
The commercial modules (Núcleos, Volunteers, etc.) are shipped in the Worker bundle and activated by a license — a JWT signed with Ed25519 containing the modules the customer is entitled to. Validation is fully offline, at Worker boot: no network calls, no external service, no phone-home.
This guide covers the complete license lifecycle from the operator's point of view:
- How it works — what a license is and how it is enforced
- Generate the keypair — once, on the signing machine
- Mint a license —
pnpm sign-license - Deploy —
LICENSEon the Worker - Key rotation — swapping the signing key
How it works
Signing machine (private):
license-private.pem ──pnpm sign-license──> LICENSE JWT ──send──> customer
Worker (boot):
env.LICENSE ──verify──> embedded public key ──ok──> module routes registered
└──fail──> module disabled (routes 404)License format
A license is a compact JWT (alg: "EdDSA") with these claims:
| Claim | Description | Required |
|---|---|---|
sub | Customer identifier (e.g. igreja-batista) | yes |
modules | Entitled module ids (e.g. ["nucleus"]) | yes |
plan | Plan name (optional, e.g. growth) | no |
exp | Expiry in epoch seconds — absent means non-expiring | no |
Enforcement
- The license is read from
env.LICENSEand verified at boot byloadLicense()(Ed25519 signature + claim shape). - A module route is only registered if the module is licensed AND enabled (AND-gate):
- licensed:
isModuleLicensed(id)— valid signature,idinmodules, and within the grace period (below); - enabled:
ENABLED_MODULES(comma-separated ids or*; default*).
- licensed:
- Fail-closed: without
LICENSE(or with an invalid/expired license), the module routes simply do not exist — they return404. Core functionality (attendance, students, classes, reports) is never affected.
Grace period
After exp, the module stays active for 30 days — renewing customers are not cut off abruptly. Past the grace period, module routes are deactivated. A license without exp (minted with --no-expiry) never expires.
Generate the keypair
Generate the pair once, on the signing machine (never in CI, never in the repo):
openssl genpkey -algorithm Ed25519 -out license-private.pem
openssl pkey -in license-private.pem -pubout -out license-public.pemlicense-private.pem— NEVER commit, never share. Store it in a secret vault / password manager. Whoever holds this key can mint licenses.license-public.pem— the public key is embedded in the Worker build, inworkers/src/license/key.ts(theLICENSE_PUBLIC_KEYconstant, single and swappable — this is the rotation point). It is not an env var: it cannot be swapped at runtime.
The repo keeps a dev key at
workers/.dev-license-private.pem(gitignored) used by the integration tests and for development licenses. Before the first paying customer, generate a production keypair (the commands above) and replace the constant inkey.ts.
Mint a license
Use the pnpm sign-license CLI (repo root):
# via env var (PEM) or --key-file
LICENSE_PRIVATE_KEY="$(cat license-private.pem)" pnpm sign-license \
"igreja-batista" "nucleus,volunteers" --plan growth --expiry-days 365
# or, for a license that never expires:
LICENSE_PRIVATE_KEY="$(cat license-private.pem)" pnpm sign-license \
--no-expiry "igreja-batista" "nucleus"Options
| Option | Description | Default |
|---|---|---|
customer-id | Customer identifier (sub) | — |
modules | Comma-separated list (e.g. nucleus,volunteers) | — |
--expiry-days N | Duration in days | 365 |
--no-expiry | Omit exp — non-expiring license | off |
--plan NAME | Plan name (plan) | — |
--key-file PATH | Path to the private key PEM (PKCS#8) | — |
--help | Show help | — |
The private key is read from --key-file or the LICENSE_PRIVATE_KEY env var (PEM). Without either, the CLI fails with exit 1.
Output
- The JWT goes to stdout (for copy/paste).
- A human-readable summary goes to stderr:
license minted: customer=igreja-batista modules=[nucleus, volunteers] expiry=2027-07-31T00:00:00.000Z plan=growth
Email the JWT to the customer (or configure it yourself at deploy time — see below).
Deploy
Set the Worker LICENSE env var to the minted JWT:
workers/wrangler.toml→[vars](the variable is already documented, commented out, pointing at this guide):toml[vars] LICENSE = "eyJhbGciOiJFZERTQSJ9..."Or on the Cloudflare dashboard → Workers →
api-neemias→ Settings → Variables → addLICENSE(preferEncryptfor the variable).(Optional) restrict which modules the deployment enables:
tomlENABLED_MODULES = "nucleus" # default "*" — everything the license allowsDeploy:
bashpnpm deploy:worker
Verify
# Active module route → 401 (exists, requires authentication)
curl -i https://api.neemias.app/api/v1/nuclei
# Module disabled / unlicensed → 404
curl -i https://api.neemias.app/api/v1/nuclei # (with LICENSE absent/invalid)If the route returns 401, the module is licensed and registered. If it returns 404, the license is missing, invalid, expired (beyond the grace period), or the module is not in ENABLED_MODULES. Note: a route registered with no auth requirement would return 200 — the point is that an unlicensed module's routes return 404, never reachable.
Key rotation
Swapping the signing key invalidates all existing licenses — plan the window and re-issue everything:
- Generate a new pair:
openssl genpkey -algorithm Ed25519 ...(commands in Generate the keypair). - Replace the
LICENSE_PUBLIC_KEYconstant inworkers/src/license/key.tswith the new public key. - Update the test tokens (signed with the old key, they stop validating):
workers/vitest.integration.config.ts(LICENSEbinding);workers/src/__tests__/gating-fetch.test.ts(LICENSE_NUCLEUSandLICENSE_VOLUNTEERS_ONLY). Re-mint with:pnpm sign-license --no-expiry integration-tests nucleus,volunteersandpnpm sign-license --no-expiry test-volunteers-only volunteers.
- Deploy the Worker (the new public key takes effect).
- Re-issue every customer's license with the new private key and send it.
Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
Module routes return 404 | LICENSE missing / invalid / beyond grace | Check the env var and re-mint the license |
401 on a module route | License OK — route registered, requires auth | Nothing to do (expected behavior) |
ENABLED_MODULES restricts but the license covers more modules | Intentional deploy config | Adjust ENABLED_MODULES |
| License "valid" but route 404 | Module not in the license's modules | Re-mint including the module |
References
- Issue #466 — licensing specification (Ed25519 JWT, offline)
workers/src/license/validate.ts—loadLicense()/isModuleLicensed()workers/src/license/key.ts— embedded public key + rotation- Activating Modules — customer view