Skip to content

License Lifecycle

License Key Format

Every license receives a unique key generated at issuance by LicensingBaseService.generateKey():

BANA-AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD
└──┘ └────────────────────────────────────┘
prefix     4 segments × 8 hex chars
  • Default prefix: BANA (overridable via the keyPrefix parameter to issue())
  • Segments: each segment is crypto.randomUUID().replace(/-/g, '').slice(0, 8).toUpperCase() — 8 hex characters
  • Entropy: 4 segments × 32 bits = 128 bits per key
  • Uniqueness: enforced by the UQ_License_key unique constraint on the key column

State Machine

Status values

ts
class LicenseStatuses {
  static readonly ACTIVATED = Statuses.ACTIVATED;  // 'activated'
  static readonly EXPIRED   = Statuses.EXPIRED;    // 'expired'
  static readonly SUSPENDED = Statuses.SUSPENDED;  // 'suspended'
  static readonly REVOKED   = Statuses.REVOKED;    // 'revoked'
}
StatusTerminalDescription
activatedNoValid and usable. The only state where validation can return VALID or GRACE_PERIOD.
expiredNo (renewable)Past graceExpiresAt. Set automatically by ValidationService.checkExpiration(). renew() can move it back to activated.
suspendedNo (reversible)Reversibly disabled by suspend(). reinstate() puts it back to activated.
revokedYesTerminal. renew() and suspend() reject this state. Only re-issuing a brand new license can restore service.

Status helpers

MethodReturns true for
LicenseStatuses.isValid(status)Any of the four known statuses
LicenseStatuses.isActive(status)activated only
LicenseStatuses.isInactive(status)suspended, revoked, expired (used by validation: returns LICENSE_${status})

There is no isTerminal() helper. Use isInactive() to detect any non-active state.

Operations

All five operations live in LicenseManagementService. issue() wraps its work in a transaction; the other four take a SELECT … FOR UPDATE row lock on the license before mutating it. Each operation:

  1. Mutates the license inside a transaction
  2. Logs a LicenseEvent after commit()
  3. Republishes the certificate via LicensingBaseService.publishCertificate()

If the transaction fails, none of the side effects (event log, certificate republish) are emitted.

Issue

POST /licenses/issueLicenseManagementService.issue(opts)

FieldRequiredDescription
policyIdYesPolicy to base the license on
entity.typeYesEntity bucket (e.g. merchants, users)
entity.idYesEntity ID
nameNoDisplay name
startsAtNoISO timestamp; defaults to now
keyPrefixNoOverride the default BANA prefix

Process (transactional):

  1. Look up the policy (findPolicy — throws 500 if missing)
  2. Generate a unique license key
  3. expiresAt = startsAt + DurationMultipliers.toMilliseconds(policy.duration) (or null for perpetual)
  4. graceExpiresAt = expiresAt + DurationMultipliers.toMilliseconds(policy.gracePeriod) (or null)
  5. Create the License row inside a transaction (status = activated)
  6. Commit
  7. Log a created event with { policyId, key }
  8. publishCertificate() — sign and cache the certificate

Returns: { data: <License> }, HTTP 201 Created.

Suspend

POST /licenses/{id}/suspendLicenseManagementService.suspend(opts) — body: { reason? }

Preconditions: license exists; LicenseStatuses.isActive(license.status) — i.e. currently activated. Anything else → 409 Conflict.

Effect: status → suspended, log suspended with { reason }, republish certificate.

Reinstate

POST /licenses/{id}/reinstateLicenseManagementService.reinstate(opts)

Preconditions: status === suspended. Anything else → 409 Conflict.

Effect: status → activated, log reinstated, republish certificate.

Note: reinstate does not verify the license hasn't expired in the meantime. A suspended license whose expiresAt is now in the past will be reinstated to activated and immediately auto-expire on the next validation call.

Renew

POST /licenses/{id}/renewLicenseManagementService.renew(opts)

Preconditions:

CheckFailure
License exists404 Not Found
status !== suspended && status !== revoked409 Conflict
policy.duration is set (not perpetual)400 Bad Request ("Cannot renew a perpetual license")

Process (transactional, with LockStrengths.UPDATE on the license row):

ts
const currentExpiry = license.expiresAt ? new Date(license.expiresAt) : now;
const base = currentExpiry > now ? currentExpiry : now;          // never extend backwards
const newExpiresAt = new Date(base.getTime() + durationMs);
const newGraceExpiresAt = graceMs ? new Date(newExpiresAt.getTime() + graceMs) : null;

The license is then updated with the new dates and status: activated (so renewing an expired license rehydrates it). Logs renewed with { newExpiresAt } and republishes the certificate.

Renew-from-expired semantics

If the license is already past its expiresAt, the new period starts from now, not retroactively from the old expiry. The merchant gets a full fresh period; they don't "lose" the days they were lapsed.

Race window

The UPDATE lock prevents two concurrent renewals from double-extending the license, but it does not prevent an interleaved validate() call from auto-expiring the license between the lock acquisition and the update — validate()'s expiration check is a separate updateBy(... where status = activated) and won't trip while renew holds the row.

Revoke

POST /licenses/{id}/revokeLicenseManagementService.revoke(opts) — body: { reason? }

Preconditions: license exists; status !== revoked. Already-revoked → 409 Conflict.

Effect: status → revoked (terminal), log revoked with { reason }, republish certificate.

Certificate republish on every state change

Every successful lifecycle operation calls publishCertificate() after the transaction commits. This re-signs the certificate with the new status / dates and pushes it to Redis at lic:certs:{entityType}:{entityId} so that downstream services consuming LicenseMiddleware see the change on their next request. See Certificate System for the signing details.

Background workers

There are none. This package does not register any cron jobs, queue workers, or scheduled tasks. In particular:

  • There is no WorkerService.processExpirations() — licenses transition to expired lazily, only when a validate() call detects that now > graceExpiresAt. A license that nobody validates stays activated in the database forever, even past its expiry.
  • There is no stale-heartbeat reaper, because there are no heartbeats at all.

If you need eager expiration (e.g. for reporting or for sweeping abandoned merchants), it has to be added as new functionality.

License Events Audit Log

Every state-changing action writes one row into LicenseEvent. The log is append-only and uses ON DELETE SET NULL rather than cascade so the audit trail survives a license deletion.

Event types

ts
class LicenseEventTypes {
  static readonly CREATED     = 'created';
  static readonly ACTIVATED   = 'activated';
  static readonly DEACTIVATED = 'deactivated';
  static readonly SUSPENDED   = 'suspended';
  static readonly REINSTATED  = 'reinstated';
  static readonly RENEWED     = 'renewed';
  static readonly EXPIRED     = 'expired';
  static readonly REVOKED     = 'revoked';
}
EventTriggered bydata payload
createdLicenseManagementService.issue(){ policyId, key }
activatedLicensingBaseService.createActivation() (device activation){ fingerprint, activationId }
deactivatedActivationService.deactivate(){ fingerprint, activationId }
suspendedLicenseManagementService.suspend(){ reason }
reinstatedLicenseManagementService.reinstate()
renewedLicenseManagementService.renew(){ newExpiresAt }
expiredValidationService.validate() (auto-expiry inside checkExpiration)
revokedLicenseManagementService.revoke(){ reason }

There is no validated or heartbeat event type. validate() does not log a row on every call; it only logs an expired event when it auto-transitions a license out of activated.

LicenseEvent is append-only (no deletedAt) and uses ON DELETE SET NULL so the audit trail survives license deletion. Full column reference: Domain Model → LicenseEvent.

Reading the log

There is currently no dedicated REST endpoint for license events — the path constant RestPaths.LICENSE_EVENTS = '/license-events' exists but no controller is registered for it. To query events you have to go through the database directly, or expose a controller as a follow-up task.

License Table

Full column reference, indexes, and constraints live in Domain Model → License. Lifecycle-relevant columns: status (the state machine above), expiresAt / graceExpiresAt (drive renew & lazy expiry), certificate (re-signed after each operation), and lastValidatedAt (touched fire-and-forget by validate()).

PageDescription
ArchitectureLicense state machine + runtime scenarios
Domain ModelLicense + LicenseEvent full schema
Policies & FeaturesPolicy types, feature configuration, overrides
Validation & ActivationRuntime validation pipeline + activation slots
Certificate SystemWhat publishCertificate() actually does
OperationsLazy-expiry caveat, cert re-publish runbook

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