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 thekeyPrefixparameter toissue()) - 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_keyunique constraint on thekeycolumn
State Machine
Status values
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'
}| Status | Terminal | Description |
|---|---|---|
activated | No | Valid and usable. The only state where validation can return VALID or GRACE_PERIOD. |
expired | No (renewable) | Past graceExpiresAt. Set automatically by ValidationService.checkExpiration(). renew() can move it back to activated. |
suspended | No (reversible) | Reversibly disabled by suspend(). reinstate() puts it back to activated. |
revoked | Yes | Terminal. renew() and suspend() reject this state. Only re-issuing a brand new license can restore service. |
Status helpers
| Method | Returns 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. UseisInactive()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:
- Mutates the license inside a transaction
- Logs a
LicenseEventaftercommit() - Republishes the certificate via
LicensingBaseService.publishCertificate()
If the transaction fails, none of the side effects (event log, certificate republish) are emitted.
Issue
POST /licenses/issue — LicenseManagementService.issue(opts)
| Field | Required | Description |
|---|---|---|
policyId | Yes | Policy to base the license on |
entity.type | Yes | Entity bucket (e.g. merchants, users) |
entity.id | Yes | Entity ID |
name | No | Display name |
startsAt | No | ISO timestamp; defaults to now |
keyPrefix | No | Override the default BANA prefix |
Process (transactional):
- Look up the policy (
findPolicy— throws 500 if missing) - Generate a unique license key
expiresAt = startsAt + DurationMultipliers.toMilliseconds(policy.duration)(ornullfor perpetual)graceExpiresAt = expiresAt + DurationMultipliers.toMilliseconds(policy.gracePeriod)(ornull)- Create the
Licenserow inside a transaction (status = activated) - Commit
- Log a
createdevent with{ policyId, key } publishCertificate()— sign and cache the certificate
Returns: { data: <License> }, HTTP 201 Created.
Suspend
POST /licenses/{id}/suspend — LicenseManagementService.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}/reinstate — LicenseManagementService.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
expiresAtis now in the past will be reinstated toactivatedand immediately auto-expire on the next validation call.
Renew
POST /licenses/{id}/renew — LicenseManagementService.renew(opts)
Preconditions:
| Check | Failure |
|---|---|
| License exists | 404 Not Found |
status !== suspended && status !== revoked | 409 Conflict |
policy.duration is set (not perpetual) | 400 Bad Request ("Cannot renew a perpetual license") |
Process (transactional, with LockStrengths.UPDATE on the license row):
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}/revoke — LicenseManagementService.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 toexpiredlazily, only when avalidate()call detects thatnow > graceExpiresAt. A license that nobody validates staysactivatedin 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
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';
}| Event | Triggered by | data payload |
|---|---|---|
created | LicenseManagementService.issue() | { policyId, key } |
activated | LicensingBaseService.createActivation() (device activation) | { fingerprint, activationId } |
deactivated | ActivationService.deactivate() | { fingerprint, activationId } |
suspended | LicenseManagementService.suspend() | { reason } |
reinstated | LicenseManagementService.reinstate() | — |
renewed | LicenseManagementService.renew() | { newExpiresAt } |
expired | ValidationService.validate() (auto-expiry inside checkExpiration) | — |
revoked | LicenseManagementService.revoke() | { reason } |
There is no
validatedorheartbeatevent type.validate()does not log a row on every call; it only logs anexpiredevent when it auto-transitions a license out ofactivated.
LicenseEventis append-only (nodeletedAt) and usesON DELETE SET NULLso 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), andlastValidatedAt(touched fire-and-forget byvalidate()).
Related Pages
| Page | Description |
|---|---|
| Architecture | License state machine + runtime scenarios |
| Domain Model | License + LicenseEvent full schema |
| Policies & Features | Policy types, feature configuration, overrides |
| Validation & Activation | Runtime validation pipeline + activation slots |
| Certificate System | What publishCertificate() actually does |
| Operations | Lazy-expiry caveat, cert re-publish runbook |