Skip to content

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:

  1. How it works — what a license is and how it is enforced
  2. Generate the keypair — once, on the signing machine
  3. Mint a licensepnpm sign-license
  4. DeployLICENSE on the Worker
  5. 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:

ClaimDescriptionRequired
subCustomer identifier (e.g. igreja-batista)yes
modulesEntitled module ids (e.g. ["nucleus"])yes
planPlan name (optional, e.g. growth)no
expExpiry in epoch seconds — absent means non-expiringno

Enforcement

  • The license is read from env.LICENSE and verified at boot by loadLicense() (Ed25519 signature + claim shape).
  • A module route is only registered if the module is licensed AND enabled (AND-gate):
    • licensed: isModuleLicensed(id) — valid signature, id in modules, and within the grace period (below);
    • enabled: ENABLED_MODULES (comma-separated ids or *; default *).
  • Fail-closed: without LICENSE (or with an invalid/expired license), the module routes simply do not exist — they return 404. 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):

bash
openssl genpkey -algorithm Ed25519 -out license-private.pem
openssl pkey -in license-private.pem -pubout -out license-public.pem
  • license-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, in workers/src/license/key.ts (the LICENSE_PUBLIC_KEY constant, 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 in key.ts.

Mint a license

Use the pnpm sign-license CLI (repo root):

bash
# 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

OptionDescriptionDefault
customer-idCustomer identifier (sub)
modulesComma-separated list (e.g. nucleus,volunteers)
--expiry-days NDuration in days365
--no-expiryOmit exp — non-expiring licenseoff
--plan NAMEPlan name (plan)
--key-file PATHPath to the private key PEM (PKCS#8)
--helpShow 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:

  1. workers/wrangler.toml[vars] (the variable is already documented, commented out, pointing at this guide):

    toml
    [vars]
    LICENSE = "eyJhbGciOiJFZERTQSJ9..."
  2. Or on the Cloudflare dashboard → Workers → api-neemiasSettings → Variables → add LICENSE (prefer Encrypt for the variable).

  3. (Optional) restrict which modules the deployment enables:

    toml
    ENABLED_MODULES = "nucleus"   # default "*" — everything the license allows
  4. Deploy:

    bash
    pnpm deploy:worker

Verify

bash
# 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:

  1. Generate a new pair: openssl genpkey -algorithm Ed25519 ... (commands in Generate the keypair).
  2. Replace the LICENSE_PUBLIC_KEY constant in workers/src/license/key.ts with the new public key.
  3. Update the test tokens (signed with the old key, they stop validating):
    • workers/vitest.integration.config.ts (LICENSE binding);
    • workers/src/__tests__/gating-fetch.test.ts (LICENSE_NUCLEUS and LICENSE_VOLUNTEERS_ONLY). Re-mint with: pnpm sign-license --no-expiry integration-tests nucleus,volunteers and pnpm sign-license --no-expiry test-volunteers-only volunteers.
  4. Deploy the Worker (the new public key takes effect).
  5. Re-issue every customer's license with the new private key and send it.

Troubleshooting

SymptomLikely causeAction
Module routes return 404LICENSE missing / invalid / beyond graceCheck the env var and re-mint the license
401 on a module routeLicense OK — route registered, requires authNothing to do (expected behavior)
ENABLED_MODULES restricts but the license covers more modulesIntentional deploy configAdjust ENABLED_MODULES
License "valid" but route 404Module not in the license's modulesRe-mint including the module

References

  • Issue #466 — licensing specification (Ed25519 JWT, offline)
  • workers/src/license/validate.tsloadLicense() / isModuleLicensed()
  • workers/src/license/key.ts — embedded public key + rotation
  • Activating Modules — customer view

Distributed under MIT License.