Architecture
1. System Context (C4 L1)
2. Container View (C4 L2)
No Kafka consumer, BullMQ worker, or WebSocket emitter container exists — the service is a single REST process plus a migration entry.
3. Component View (C4 L3) — Internal Layering
| Layer | Responsibility |
|---|---|
| Routes | HTTP surface, declared in RestPaths + per-controller definitions.ts |
| Controllers | ControllerFactory.defineCrudController (Policy/PolicyFeature/License/Activation) + custom @post lifecycle handlers; auth + permission gate |
| Services | Business logic, transactions, certificate re-publish (LicenseManagementService, ActivationService, ValidationService) |
LicensingBaseService | Shared base: key generation, feature resolution, publishCertificate(), logEvent(), createActivation() |
| Repositories | Re-exported from @nx/core; soft-delete via generateCommonColumnDefs |
| Components | Redis cache only (cert distribution + authz cache) |
4. State Machines Index
| Entity | States | Diagram |
|---|---|---|
License | ACTIVATED, SUSPENDED, EXPIRED, REVOKED | → jump |
PolicyandPolicyFeatureuse generic lifecycle statuses (ACTIVATED/DEACTIVATED/ARCHIVED) with no guarded transitions.Activationhas no status column — its lifecycle is create/delete.
License
| From | Event | To | Guards |
|---|---|---|---|
* (new) | issue | ACTIVATED | Policy must exist; key generated; cert published |
ACTIVATED | suspend | SUSPENDED | Must be isActive() else SUSPEND_INVALID_STATUS |
SUSPENDED | reinstate | ACTIVATED | Must be SUSPENDED else REINSTATE_INVALID_STATUS |
ACTIVATED | validate (lazy) | EXPIRED | expiresAt < now AND past graceExpiresAt; conditional UPDATE ... WHERE status=ACTIVATED |
ACTIVATED/EXPIRED | renew | ACTIVATED | Rejects REVOKED/SUSPENDED; Policy must have a duration (else RENEW_PERPETUAL) |
any non-REVOKED | revoke | REVOKED | Idempotent-guard REVOKE_ALREADY_REVOKED |
Expiry is lazy. There is no scheduled reaper. A license flips to
EXPIREDonly whenValidationService.validate()runs and observes thatexpiresAtand the grace window have both passed.
5. Runtime Scenarios
5.1 Issue a license
5.2 Validate a key (lazy expiry + implicit activation)
5.3 Activate a device (race-safe seat limit)
5.4 Certificate consumption (other services)
6. Crosscutting Concerns
| Concern | How this service handles it |
|---|---|
| AuthN | JWT (Issuer = identity, JWKS) or HTTP Basic — [AuthenticateStrategy.JWT, AuthenticateStrategy.BASIC] on every route |
| AuthZ | Casbin via PolicyDefinition; permissions seeded by migrations 0001/0003/0004. License read routes temporarily authorize.skip (ADR-0003) |
| i18n | jsonb i18n columns ({ default, en, vi }) on Policy.name/description, PolicyFeature.name/description, License.name |
| Logging | Structured, key-value (key: %s); per-method scope via this.logger.for(name) |
| Tracing | No-op (no tracer wired) |
| Idempotency | Activation fast-path (find by licenseId+fingerprint before lock); issueFreeTrial returns existing trial; expiry flip via conditional UPDATE; partial-unique index on (licenseId, fingerprint) |
| Concurrency | SELECT … FOR UPDATE row-lock on the License row for all lifecycle mutations and activation-limit checks |
| Soft-delete | License, Policy, Activation carry deletedAt (via generateCommonColumnDefs); PolicyFeature & LicenseEvent disable deleted column |
| IDs | Snowflake via IdGenerator, worker 11 |