Policies & Features
A Policy is the template that every License is issued from. It defines the policy type, duration, optional grace period, optional activation limit, and the bag of feature flags (PolicyFeature records) that licenses derived from it will grant.
Policy Types
class PolicyTypes {
static readonly TRIAL = '000_TRIAL';
static readonly SUBSCRIPTION = '100_SUBSCRIPTION';
static readonly PERPETUAL = '200_PERPETUAL';
}| Type | Code | Has expiration | Renewable | Typical use |
|---|---|---|---|---|
| Trial | 000_TRIAL | Yes | No (issue a fresh one) | Free evaluation period |
| Subscription | 100_SUBSCRIPTION | Yes | Yes (extend expiresAt) | Paid time-bound license |
| Perpetual | 200_PERPETUAL | No (duration is null) | No | One-time grant, no expiry |
The
PolicyTypesvalue is purely a label —LicenseManagementService.renew()rejects any license whose policy has noduration(i.e. perpetual) regardless of the type code.
Policy Configuration
Duration
Controls how long an issued license stays valid.
interface IDuration {
unit: TDurationUnit; // 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'
value: number;
}DurationMultipliers.toMilliseconds(duration) converts an IDuration to a millisecond count, used by issue() and renew() to compute expiresAt.
| Unit | Constant | Milliseconds |
|---|---|---|
millisecond | MILLISECOND | 1 |
second | SECOND | 1,000 |
minute | MINUTE | 60,000 |
hour | HOUR | 3,600,000 |
day | DAY | 86,400,000 |
week | WEEK | 604,800,000 |
month | MONTH | 30 days = 2,592,000,000 |
year | YEAR | 365 days = 31,536,000,000 |
Calendar-naive
month is hard-coded to 30 days and year to 365 days. There is no calendar-aware arithmetic, no leap-year handling, and no DST handling. If finance or billing later need calendar precision, that has to be added separately.
A perpetual policy stores duration: null. issue() then leaves both expiresAt and graceExpiresAt as null.
Grace Period
Optional extension after expiresAt. While now < graceExpiresAt, validation still returns valid: true but with code: GRACE_PERIOD instead of VALID.
{
"duration": { "unit": "year", "value": 1 },
"gracePeriod": { "unit": "day", "value": 7 }
}A license issued today against the above policy:
| Phase | Duration | Validation result |
|---|---|---|
| Active | days 0–365 | valid: true, code: VALID |
| Grace | days 365–372 | valid: true, code: GRACE_PERIOD |
| Expired | day 372+ | valid: false, code: LICENSE_EXPIRED (status auto-flips to expired on next validation) |
gracePeriod is itself an IDuration. If omitted, the license simply transitions from active straight to expired.
Activation Config
Caps the number of unique device fingerprints that may activate against one license.
interface IActivationConfig {
limit: number;
}| Field | Behavior |
|---|---|
limit | Max unique (licenseId, fingerprint) rows in the Activation table |
No "fixed vs floating" modes
There is no mode field on IActivationConfig. There is no floating activation, no heartbeat mechanism, and no automatic slot reaping. An activation slot is held until somebody explicitly deletes the activation row (DELETE /activations/{id}) or the parent license is deleted (which cascades).
If policy.activation is null (or absent), activations are unlimited.
Per-license override
Individual licenses can override the policy's activation config and feature values via the override JSONB column on License:
{
"override": {
"activation": { "limit": 10 },
"features": {
"max_products": 1000,
"custom_branding": true
}
}
}LicensingBaseService.resolveActivation()returnslicense.override.activation ?? policy.activation ?? null.LicensingBaseService.resolveFeatures()mergespolicy features → override features, with override values taking precedence.
Override updates don't republish
The override field is set via PATCH /licenses/{id}, which is a plain CRUD update — it does not republish the cached certificate. To force the new override into Redis (and therefore into LicenseMiddleware consumers), you currently need to trigger a state-changing lifecycle operation such as suspend → reinstate or renew. The POST /validation/validate endpoint always reads fresh from the database, so it reflects the change immediately.
Feature Flags
PolicyFeature rows are typed key/value flags attached to a policy.
Statuses
class PolicyFeatureStatuses {
static readonly ACTIVATED = Statuses.ACTIVATED; // 'activated'
static readonly DEACTIVATED = Statuses.DEACTIVATED; // 'deactivated'
}When a feature is deactivated, LicensingBaseService.resolveFeatureValue() returns the DATA_TYPE_DEFAULTS falsy value for its type instead of the configured value:
dataType | Active value source | Deactivated value |
|---|---|---|
BOOLEAN | boValue ?? true | false |
NUMBER | nValue ?? 0 | 0 |
TEXT | tValue ?? '' | '' |
JSON | jValue ?? null | null |
This lets you keep a feature row in place but turn it off without deletion.
Data Types
dataType | Column | TypeScript | Example |
|---|---|---|---|
BOOLEAN | boValue (boolean) | boolean | true |
NUMBER | nValue (numeric) | number | 100 |
TEXT | tValue (text) | string | "professional" |
JSON | jValue (jsonb) | Record<string, any> | { "modules": ["pos","crm"] } |
The dataType value comes from the shared DataTypes constants in @venizia/ignis-helpers.
Adding features
There is no dedicated PolicyService.addFeature() method — features are managed via the standard PolicyFeatureController CRUD endpoints:
POST /v1/api/licensing/policy-features{
"policyId": "<policy-id>",
"code": "max_products",
"name": { "en": "Maximum Products", "vi": "Sản phẩm tối đa" },
"description": { "en": "Max products allowed", "vi": "Số sản phẩm tối đa" },
"dataType": "NUMBER",
"nValue": 500,
"status": "activated",
"sequence": 10
}The (policyId, code) pair is unique. Deleting a policy cascades and removes all its features.
Feature Resolution
LicensingBaseService.resolveFeatures({ policyId, featuresOverride }):
policyFeatureRepository.find({ where: { policyId } })— loads all features (bothactivatedanddeactivated)- For each feature, calls
resolveFeatureValue():- If
status !== activated, return theDATA_TYPE_DEFAULTSvalue for its type - Otherwise, return the matching
<x>Valuecolumn (with the per-type fallback above)
- If
- Build a
Record<string, unknown>keyed byfeature.code - If
featuresOverrideis provided (fromlicense.override.features), spread it on top so overrides win
The resulting record is what shows up in the validation response under features, and inside the certificate payload's features field.
/policies/catalogs
Public catalog endpoint exposed by PolicyController. It loads every Policy whose status is ACTIVATED, ordered by sequence ASC, with each policy's ACTIVATED features inlined (also ordered by sequence ASC):
GET /v1/api/licensing/policies/catalogs{
"data": [
{
"id": "...",
"name": "BANA Professional — Yearly",
"type": "100_SUBSCRIPTION",
"duration": { "unit": "year", "value": 1 },
"gracePeriod": { "unit": "day", "value": 14 },
"activation": { "limit": 5 },
"features": [
{ "code": "max_products", "dataType": "NUMBER", "nValue": 500 },
{ "code": "custom_branding","dataType": "BOOLEAN", "boValue": true }
]
}
]
}The route is still gated by the Policy.find permission — the controller does not bypass authentication.
Data Model
Full column reference, indexes, and constraints for
PolicyandPolicyFeaturelive in Domain Model. Key points for this page:Policyis soft-deletable;PolicyFeatureis not (deleted column disabled) and cascade-deletes with its parentPolicy;(policyId, code)is unique; the feature value lives in the typed column matchingdataType(boValue/nValue/tValue/jValue).
Related Pages
| Page | Description |
|---|---|
| Domain Model | Policy + PolicyFeature full schema |
| License Lifecycle | How licenses move through states |
| Validation & Activation | How features and activations are resolved at validation time |
| Configuration | Seeded FREE_TRIAL policy |