Licence key format — signed-licence-v1
This page documents the wire format of Pulp Engine commercial licence
tokens, the env vars operators set, the /health/ready payload that
exposes verifier state, and the runbook for diagnosing a non-licensed
deployment.
For the design context and what’s deferred, see
docs/initiatives/signed-licence-v1.md.
Wire format
{iat_ms}.{exp_ms}.{customerId_b64url}.{claims_b64url_json}.{sig_b64url}
Five base64url segments separated by dots. Total length capped at 4096 chars.
The signed payload (UTF-8 bytes, fed verbatim to crypto.sign(null, ...)):
licence-v1:{iat_ms}:{exp_ms}:{customerId}:{claims_json}
Where:
| Segment / field | Format | Notes |
|---|---|---|
iat_ms | positive integer (ms since Unix epoch) | matches ^[1-9]\d*$; safe-integer range |
exp_ms | positive integer (ms since Unix epoch) | same; rejected if now > exp_ms |
customerId | ^[A-Za-z0-9._-]{1,64}$ | base64url-encoded in the segment, raw form in the signed payload |
claims_json | JSON object — v1: { "licenceId": "<id>" } | licenceId matches ^[A-Za-z0-9._-]{1,64}$ |
sig | Ed25519 signature, base64url-encoded | over the signed payload above |
v1 claims object — minimum-viable. Future additive fields (edition tier, deployment hints) will not break v1 verifiers because the slot is JSON.
Environment variables
| Var | Required? | Format | Effect |
|---|---|---|---|
PULP_LICENCE_KEY | no | full 5-part token | absence (or empty/whitespace) → evaluation mode (watermark) |
PULP_LICENCE_PUBLIC_KEY | no | raw 32-byte Ed25519 public key, base64url | overrides the baked public key |
The override accepts only the same format the baked constant uses (raw
32-byte JWK x coordinate). PEM/SPKI is not accepted on purpose — one
format everywhere reduces surprises during rotation. A malformed override
is logged once at startup and falls back to the baked key; the API still
boots.
The render workers (RENDER_MODE=child-process, container, or socket)
need both env vars forwarded into their isolated environment. The
dispatchers do this automatically — no manual plumbing required.
/health/ready payload
Schema: apps/api/src/schemas/shared.ts.
/health/ready adds two licence-related fields to the existing readiness
shape:
{
"status": "ok",
"version": "0.77.0",
"timestamp": "2026-04-25T12:34:56.789Z",
"checks": {
"storage": "ok",
"assetBinaryStore": "ok",
"renderer": "ok",
"licence": "ok"
},
"licence": {
"kind": "licensed",
"customerId": "acme-corp",
"expiresAt": "2027-04-01T00:00:00.000Z",
"daysUntilExpiry": 341
}
}
Other states surface as:
- evaluation mode (no key set):
{ "checks": { "licence": "evaluation" }, "licence": { "kind": "evaluation" } } - expired key (or any other verifier failure):
{ "checks": { "licence": "invalid" }, "licence": { "kind": "invalid", "reason": "expired" } }
Possible reason values: malformed, bad-signature, expired,
not-yet-valid. Licence state never participates in the 503 calculation
— rendering continues to work even when the licence is invalid; the watermark
is the enforcement signal. Wire alerts to licence.kind === 'invalid' and
licence.daysUntilExpiry < N independently of the readiness HTTP status.
Prometheus alerts
The licence state is also exposed on GET /metrics (the /health/ready JSON
body is not Prometheus-scrapable). Two gauges are refreshed on every scrape:
pulp_engine_licence_valid—1only for a genuine, verified, unexpired commercial licence;0for evaluation or invalid.pulp_engine_licence_days_until_expiry— days remaining when licensed; sentinel-1in evaluation/invalid state.
Gate the expiry alert on validity so evaluation deployments never page, and alert separately on an invalid licence:
# Licence expiring soon (only fires for a real, valid licence)
pulp_engine_licence_valid == 1 and pulp_engine_licence_days_until_expiry < 14
# Licence outright invalid (expired / bad signature / malformed) — silent
# re-watermarking, so page on it
pulp_engine_licence_valid == 0
(The second query also matches evaluation deployments, which run with
valid == 0 by design — scope it to production targets via your scrape labels
if evaluation instances share the same Prometheus.)
CLI
Both commands live in packages/cli/src/commands/licence.ts.
pulp-engine licence verify <token>— signature-verifies the token against the baked public key (orPULP_LICENCE_PUBLIC_KEYoverride). Exits 0 onlicensed, exits 1 on any failure. Prints a structured JSON result.pulp-engine licence inspect <token>— decodes claims without verifying the signature. Useful for support triage when a customer’s deployment reportskind: 'invalid'and you need to confirm the token’scustomerId,licenceId, and expiry without trusting it. Output is prefixed with a non-trust-decision warning.
Operator runbook
A new key fails verification on deployment
- Run
pulp-engine licence inspect <token>locally — confirm thecustomerId,licenceId, and thatexpis in the future. - Run
pulp-engine licence verify <token>against the deployment’s effective public key:- If you’ve set
PULP_LICENCE_PUBLIC_KEY, export the same value locally before running. - Without an override, the CLI verifies against the baked key for the CLI version installed locally — this MUST match the version running in production for the verify result to be meaningful.
- If you’ve set
- Check
/health/readyon the deployment — thelicence.reasonfield indicates whether the failure isbad-signature(key mismatch) orexpired/not-yet-valid(clock or contract issue).
A licence expires soon
- Issue a new token via the operator script
scripts/issue-licence.mjsagainst the same private key. - Replace the value of
PULP_LICENCE_KEYin the deployment’s env config. - Restart the API process —
getLicenceState()is memoised at startup, so a hot env reload is not enough.
Rotating the public key
See docs/initiatives/signed-licence-v1.md.