Validation & Activation
Validation Pipeline
POST /validation/validate → ValidationService.validate(opts). Authentication is required (JWT or Basic); there is no separate permission code.
Request
interface IValidateRequest {
key: string; // license key — required
fingerprint?: string; // device fingerprint — triggers slot handling when present
label?: string; // device display name
platform?: string; // OS / platform identifier
ip?: string; // forwarded by the route handler when applicable
userAgent?: string; // forwarded by the route handler when applicable
}The request body schema (ValidateRequestSchema in controllers/validation/definitions.ts) accepts only { key, fingerprint?, label?, platform? } from clients. ip / userAgent are populated by the controller layer, not by the client.
Pipeline
Step 1 — Find license
licenseRepository.findOne({ filter: { where: { key } } }). If null, return LICENSE_NOT_FOUND immediately with no further work.
Step 2 — Status / start-date gate (checkLicenseStatus)
| Condition | Result code |
|---|---|
LicenseStatuses.isInactive(license.status) (any of suspended, revoked, expired) | LICENSE_${license.status.toUpperCase()} → LICENSE_SUSPENDED, LICENSE_REVOKED, LICENSE_EXPIRED |
new Date(license.startsAt) > new Date() | LICENSE_NOT_STARTED |
Step 3 — Expiration check (checkExpiration)
Three branches, evaluated against now:
| Condition | Effect |
|---|---|
expiresAt is null (perpetual) OR expiresAt >= now | Continue, no grace |
expiresAt < now AND graceExpiresAt > now | inGracePeriod = true, continue |
| Past grace (or no grace at all) | Auto-expire |
The auto-expire branch executes:
const { count } = await licenseRepository.updateBy({
data: { status: 'expired' },
where: { id: license.id, status: 'activated' },
options: { shouldReturn: false },
});The where status = activated clause is the race guard. If two requests both detect a past-grace license, only one of them gets count === 1. The loser:
- Re-reads the license with
findLicense({ id }) - Returns
LICENSE_${current.status}based on whatever the winner left it as
The winner additionally:
- Logs an
expiredLicenseEvent - Calls
publishCertificate({ license: { ...license, status: 'expired' } })so consumers see the new status
Step 4 — Parallel resolution
Once the license has cleared status and expiration, two queries run via Promise.all:
resolveFeatures({ policyId, featuresOverride: license.override?.features ?? null })resolveActivationForValidation({ license, fingerprint, label, platform, ip, userAgent })
resolveActivationForValidation itself loads the policy and the activations list in parallel inside its own Promise.all.
Step 5 — Activation slot (resolveActivationForValidation)
The slot logic is skipped entirely if fingerprint is missing. The response then carries activation: { id: null, used: <total>, limit: <policy or override limit, or null> } but does not consume a slot.
If fingerprint is provided:
- Look for an existing
(licenseId, fingerprint)activation among the already-loaded list. If found, reuse it. - Otherwise call
tryCreateActivation().
Two-phase activation creation (tryCreateActivation)
| Phase | Why it exists |
|---|---|
| Fast path (no lock) | If the limit is already reached at the unlocked count, we can refuse without paying for a transaction. If there is no limit at all, we just create the row directly. |
| Slow path (locked) | When we are under the limit at the unlocked check, we then take a LockStrengths.UPDATE row lock on the parent license and re-count inside the transaction. This serializes concurrent activations against the same license and prevents the limit from being bypassed. |
If the slow-path recount finds the license is now at or over the limit, the transaction is rolled back and the caller receives isLimitReached: true.
When isLimitReached, validate() short-circuits with:
{
"valid": false,
"code": "ACTIVATION_LIMIT_REACHED",
"license": { ... },
"features": {},
"activation": { "id": null, "used": <currentCount>, "limit": <limit> }
}Step 6 — Fire-and-forget bookkeeping
After a successful validation, validate() updates last_validated_at without awaiting the promise:
this.licenseRepository
.updateById({ id: license.id, data: { lastValidatedAt: nowIso } })
.catch(err => this.logger.for('validate').error('Failed to update lastValidatedAt | Error: %s', err));If the update fails, the error is logged but the validation response still succeeds. There is no validated LicenseEvent written by this path — the only event validate() emits is expired, and only on the auto-expiry branch in step 3.
Response (IValidationResult)
interface IValidationResult {
valid: boolean;
code: string;
license: {
id: string;
key: string;
status: string;
expiresAt: string | null;
};
features: Record<string, unknown>;
activation: {
id: string | null;
used: number;
limit: number | null;
};
certificate?: string;
}The response does not carry a separate
licenseKeyfield. Thelicense.keyis present when a license was found; the cachedcertificate(if any) is what the client forwards to other services. There is no AES decryption secret in the response — see Certificate System.
Validation result codes
| Code | valid | Set by | Meaning |
|---|---|---|---|
VALID | ✅ | step 6 fall-through | License active and not expired |
GRACE_PERIOD | ✅ | step 3 (isInGracePeriod) | Past expiresAt but within graceExpiresAt |
LICENSE_NOT_FOUND | ❌ | step 1 | No row matches key |
LICENSE_SUSPENDED | ❌ | step 2 | status === 'suspended' |
LICENSE_REVOKED | ❌ | step 2 | status === 'revoked' |
LICENSE_EXPIRED | ❌ | step 2 (was already expired) or step 3 (auto-expiry) | Past grace |
LICENSE_NOT_STARTED | ❌ | step 2 | startsAt is in the future |
ACTIVATION_LIMIT_REACHED | ❌ | step 5 | Slot limit hit, no slot consumed |
If the auto-expiry race-loser branch fires (step 3), the returned code is
LICENSE_${actual_status}— typicallyLICENSE_EXPIREDbut could beLICENSE_SUSPENDEDorLICENSE_REVOKEDif the winner happened to be a different operation.
ActivationService
ActivationService is what backs the standard /activations CRUD controller and is also reused by the validation pipeline. It exposes two methods:
activate({ licenseId, fingerprint, label?, platform?, hostname?, ip? })
Two-phase, similar to validate()'s tryCreateActivation, but used when a client wants to register a device explicitly rather than at validation time.
- Fast path (no lock): look for an existing
(licenseId, fingerprint). If found, return it as-is. - Slow path (transaction):
SELECT license FOR UPDATE404if missing;409if!isActive(status)- Resolve the activation config (
license.override.activation ?? policy.activation ?? null) - If config is non-null, count activations inside the transaction; if at limit →
409 Conflict(Activation limit reached (<limit>)) - Insert the activation row inside the transaction
- Log an
activatedLicenseEventwith{ fingerprint, activationId } - Commit and return
{ data: <activation> }
deactivate({ activationId, ip? })
findById—404if missingdeleteById- Log a
deactivatedLicenseEventwith{ fingerprint, activationId }
There is no named REST endpoint for
deactivate; clients call the standardDELETE /activations/{id}and rely on the controller wiring or call this service directly. There is also no "deactivate by fingerprint" path — clients must know the activation id.
No heartbeats, no auto-reaping
There is no heartbeat() method, no isAlive() method, no lastHeartbeatAt column on Activation, no POST /validation/heartbeat endpoint, no Redis TTL for activations, and no background reaper. An activation slot is held until somebody calls deactivate() (or until the parent license is deleted, which cascades). If a device is lost, an admin must clear its activation row manually.
Activation table
Full column reference lives in Domain Model → Activation. Key points for this page:
fingerprintis stored verbatim; the partial-unique constraintUQ_Activation_licenseId_fingerprintenforces one row per device per license (and powers the idempotent fast-path);ON DELETE CASCADEfromLicensefrees all seats when a license is deleted.
Related Pages
| Page | Description |
|---|---|
| Architecture | Validate + activate sequence diagrams |
| Domain Model | Activation full schema + invariants |
| Policies & Features | Activation limit configuration, feature resolution |
| License Lifecycle | State transitions and the audit log |
| Certificate System | How the certificate field in the response is produced |