Certificate System
Overview
The licensing service publishes a signed, encrypted snapshot of every license — its status, tier, features, activation config, and expiration — to Redis whenever the license state changes. Other services (Commerce, Sale, Inventory, …) read this snapshot via LicenseMiddleware from @nx/core. They never call the licensing service for runtime licensing checks; the certificate carries everything they need.
This is "offline-capable" in the sense that consumer services don't need a synchronous round-trip to the licensing service for every request. They do still need Redis.
Architecture
What's in the payload
ILicenseCertificatePayload (from @nx/core/services/license/certs/types.ts):
interface ILicenseCertificatePayload<FeaturesType = Record<string, unknown>> {
license: { id: string; key: string };
entity: { type: string; id: string }; // e.g. { type: 'merchants', id: '<merchantId>' }
status: string; // 'activated' | 'expired' | 'suspended' | 'revoked'
tier: string; // policy.type — '000_TRIAL' / '100_SUBSCRIPTION' / '200_PERPETUAL'
features: FeaturesType; // resolved feature flags (overrides applied)
activation: { limit: number } | null; // resolved activation config (override → policy → null)
expiresAt: string | null; // license.expiresAt (ISO) or null for perpetual
issuedAt: string; // ISO timestamp at signing time (NOT license.issuedAt)
certExpiresAt: string; // signing time + APP_ENV_LICENSING_CERT_TTL_SECONDS
}issuedAt is the signing time, not the license issuance time
publishCertificate() sets payload.issuedAt = new Date().toISOString() at the moment it builds the payload, which means it changes every time the certificate is republished. If you actually want the license's true issue date, read it from the database (license.issuedAt) — it isn't preserved in the certificate payload.
Signing process
Implemented by LicenseCertSignerHelper.sign({ payload, secret, privateKey }) in packages/core/src/services/license/certs/signer.ts.
The algorithm metadata stored in the envelope is the constant CertificateDefinitions.FULL_FORM = 'aes-256-gcm+ed25519'. The verifier rejects any envelope whose alg doesn't match this exact string.
Inputs
| Argument | Source | Required |
|---|---|---|
payload | Built by LicensingBaseService.publishCertificate() from the license, its policy, resolved features, and resolved activation config | ✅ |
secret | APP_ENV_APPLICATION_SECRET (passed in by the caller) — used as the AES-256-GCM key | ✅ |
privateKey | APP_ENV_LICENSING_ED25519_PRIVATE_KEY (PEM string) | ✅ |
The AES secret is the application secret, not the license key
A common misunderstanding: the AES secret is APP_ENV_APPLICATION_SECRET, not license.key. The same secret is used by LicenseCertSignerHelper.sign() and LicenseCertVerifierHelper.verify(), which means every consumer service must be configured with the same APP_ENV_APPLICATION_SECRET as the licensing service that signed the certificate. This is a symmetric secret in addition to the asymmetric Ed25519 keypair.
Output envelope
interface ICertificateEnvelope {
enc: string; // AES-256-GCM ciphertext (string-encoded by the AES helper)
sig: string; // Ed25519 signature, base64url-encoded
alg: string; // 'aes-256-gcm+ed25519'
}The envelope is JSON-stringified and then base64-encoded. The base64 string is what ends up in license.certificate and in Redis.
Publishing process
LicensingBaseService.publishCertificate({ license }):
- Read
APP_ENV_LICENSING_ED25519_PRIVATE_KEY. If missing → log warning and return early (no certificate generated). - Read
APP_ENV_APPLICATION_SECRET. If missing → log warning and return early. - Read
APP_ENV_LICENSING_CERT_TTL_SECONDS(default86400). findPolicy({ id: license.policyId })resolveFeatures({ policyId, featuresOverride: license.override?.features ?? null })resolveActivation({ policy, license })→{ limit }ornull- Build the payload (see
issuedAtwarning above). LicenseCertSignerHelper.getInstance().sign({ payload, secret, privateKey })licenseRepository.updateById({ id: license.id, data: { certificate } })- If a Redis client is bound,
redis.client.set('lic:certs:<entityType>:<entityId>', certificate, 'EX', certTtl)
Silent skip on missing config
The "log warning and return" branches in steps 1–2 mean a misconfigured environment will quietly issue licenses with no certificate. Consumers will then see null from LicenseMiddleware (which fail-opens) and act as if the merchant has no license at all. There is no startup-time validation of these env vars.
When publishing fires
| Trigger | Why |
|---|---|
LicenseManagementService.issue() | Initial certificate after creation |
LicenseManagementService.suspend() | Status changes to suspended |
LicenseManagementService.reinstate() | Status changes back to activated |
LicenseManagementService.renew() | Status returns to activated and expiresAt extends |
LicenseManagementService.revoke() | Status changes to revoked |
ValidationService.validate() (auto-expiry only) | Status flips to expired after past-grace detection |
PATCH /licenses/{id} (the standard CRUD update — used to set override, for example) does not trigger republication. Until a lifecycle action runs, the cached certificate keeps the old override values, even though POST /validation/validate (which queries the database fresh each call) will already reflect them.
Redis cache
| Key pattern | Value | TTL |
|---|---|---|
lic:certs:{entityType}:{entityId} | The base64 envelope as a single string | APP_ENV_LICENSING_CERT_TTL_SECONDS (default 86400) |
Examples:
lic:certs:merchants:01234567890123lic:certs:users:01234567890456
The Redis value is a plain string
The cached value is just the base64 envelope as a string — not a JSON object like { certificate, licenseKey }. There is no licenseKey stored separately, because verification doesn't need it: the AES secret used by the verifier is APP_ENV_APPLICATION_SECRET.
Verification
Implemented by LicenseCertVerifierHelper.verify({ certificate, secret, publicKey }):
- base64-decode → JSON-parse →
ICertificateEnvelope envelope.alg === 'aes-256-gcm+ed25519'— otherwise400 Bad Request- Reconstruct
Buffer('license:<enc>')andEd25519.verify(message, publicKey, sig)— failure →401 Unauthorized(Certificate signature verification failed) aes.decrypt(envelope.enc, secret)→ JSON-parse the plaintext →ILicenseCertificatePayload- If
payload.certExpiresAt < now.toISOString()→401 Unauthorized(Certificate has expired) - Return the payload
The verifier must be given:
| Argument | Source |
|---|---|
certificate | The base64 envelope from Redis |
secret | APP_ENV_APPLICATION_SECRET (must match the signer's) |
publicKey | APP_ENV_LICENSING_ED25519_PUBLIC_KEY (PEM) |
Consumer side: LicenseMiddleware
Source: packages/core/src/middlewares/license/license.middleware.ts. Provided by @nx/core for any service that needs to read license context for the current authenticated user.
class LicenseMiddleware extends BaseHelper implements IProvider<MiddlewareHandler> {
static readonly LICENSE_CONTEXT_KEY = 'license';
constructor(
private readonly publicKey: string, // APP_ENV_LICENSING_ED25519_PUBLIC_KEY
private readonly applicationSecret: string, // APP_ENV_APPLICATION_SECRET
private readonly redis: DefaultRedisHelper,
) { ... }
}How it runs
- Read the authenticated user from
context.get(Authentication.CURRENT_USER). If absent, skip andnext(). - Pull
merchants: { id: string }[]anduserId: string | undefined(fromcurrentUser.sub ?? currentUser.id) off the auth context. - In parallel:
- For each merchant:
resolveCertificate({ entityType: 'merchants', entityId: m.id }) - For the user (if any):
resolveCertificate({ entityType: 'users', entityId: userId })
- For each merchant:
resolveCertificatereadslic:certs:<type>:<id>from Redis and runs the verifier helper. Any failure (Redis miss, deserialization error, signature failure, expired payload) is caught and returnsnull— fail-open.- Set the merged result on
contextunderLicenseMiddleware.LICENSE_CONTEXT_KEY = 'license':
interface ILicenseContext {
merchants: Record<string, ILicenseCertificatePayload | null>;
user: ILicenseCertificatePayload | null;
}Reading from a controller / service
const licenseCtx = context.get('license') as ILicenseContext | undefined;
const merchantLicense = licenseCtx?.merchants[merchantId] ?? null;
if (!merchantLicense) {
// Either no license, or verification failed (treated identically — "unknown")
}
if (merchantLicense?.status !== 'activated') {
// Suspended / revoked / expired — gate the feature
}
if (merchantLicense?.tier === '000_TRIAL') {
// Apply trial restrictions
}
const maxProducts = merchantLicense?.features['max_products'] as number | undefined;
if (maxProducts != null && currentCount >= maxProducts) {
// Enforce limit
}Fail-open consequences
The middleware deliberately never throws on certificate problems — Redis being down or a verification failure both produce null. Treat null as "unknown", not as "unlicensed". If you need a hard licensing gate, do the explicit check in your business logic and choose how to react when the value is null.
Environment variables
Full table (signer keys + the matching consumer keys) lives in Configuration. In short: the signer needs
APP_ENV_APPLICATION_SECRET+APP_ENV_LICENSING_ED25519_PRIVATE_KEY+APP_ENV_LICENSING_CERT_TTL_SECONDS; every consumer needs the sameAPP_ENV_APPLICATION_SECRETplus the matchingAPP_ENV_LICENSING_ED25519_PUBLIC_KEY.
Related Pages
| Page | Description |
|---|---|
| Integration | The cross-service trust seam |
| API Events | Cert envelope + payload schemas |
| License Lifecycle | The operations that trigger republishing |
| Operations | Keypair rotation runbook |
| ADR-0001 | Why Ed25519 + offline verification |