Skip to content

Validation & Activation

Validation Pipeline

POST /validation/validateValidationService.validate(opts). Authentication is required (JWT or Basic); there is no separate permission code.

Request

ts
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)

ConditionResult 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:

ConditionEffect
expiresAt is null (perpetual) OR expiresAt >= nowContinue, no grace
expiresAt < now AND graceExpiresAt > nowinGracePeriod = true, continue
Past grace (or no grace at all)Auto-expire

The auto-expire branch executes:

ts
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:

  1. Re-reads the license with findLicense({ id })
  2. Returns LICENSE_${current.status} based on whatever the winner left it as

The winner additionally:

  • Logs an expired LicenseEvent
  • 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:

  1. resolveFeatures({ policyId, featuresOverride: license.override?.features ?? null })
  2. 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:

  1. Look for an existing (licenseId, fingerprint) activation among the already-loaded list. If found, reuse it.
  2. Otherwise call tryCreateActivation().

Two-phase activation creation (tryCreateActivation)

PhaseWhy 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:

json
{
  "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:

ts
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)

ts
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 licenseKey field. The license.key is present when a license was found; the cached certificate (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

CodevalidSet byMeaning
VALIDstep 6 fall-throughLicense active and not expired
GRACE_PERIODstep 3 (isInGracePeriod)Past expiresAt but within graceExpiresAt
LICENSE_NOT_FOUNDstep 1No row matches key
LICENSE_SUSPENDEDstep 2status === 'suspended'
LICENSE_REVOKEDstep 2status === 'revoked'
LICENSE_EXPIREDstep 2 (was already expired) or step 3 (auto-expiry)Past grace
LICENSE_NOT_STARTEDstep 2startsAt is in the future
ACTIVATION_LIMIT_REACHEDstep 5Slot limit hit, no slot consumed

If the auto-expiry race-loser branch fires (step 3), the returned code is LICENSE_${actual_status} — typically LICENSE_EXPIRED but could be LICENSE_SUSPENDED or LICENSE_REVOKED if 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.

  1. Fast path (no lock): look for an existing (licenseId, fingerprint). If found, return it as-is.
  2. Slow path (transaction):
    • SELECT license FOR UPDATE
    • 404 if missing; 409 if !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 activated LicenseEvent with { fingerprint, activationId }
    • Commit and return { data: <activation> }

deactivate({ activationId, ip? })

  1. findById404 if missing
  2. deleteById
  3. Log a deactivated LicenseEvent with { fingerprint, activationId }

There is no named REST endpoint for deactivate; clients call the standard DELETE /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: fingerprint is stored verbatim; the partial-unique constraint UQ_Activation_licenseId_fingerprint enforces one row per device per license (and powers the idempotent fast-path); ON DELETE CASCADE from License frees all seats when a license is deleted.

PageDescription
ArchitectureValidate + activate sequence diagrams
Domain ModelActivation full schema + invariants
Policies & FeaturesActivation limit configuration, feature resolution
License LifecycleState transitions and the audit log
Certificate SystemHow the certificate field in the response is produced

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.