Integration
Detailed guide for integrating @corelicense/node into a production system — for developers and AI agents generating code.
Read first: Installation
Table of Contents
- Architecture & Principles
- Runtime Lifecycle
- Express Integration (Full)
- Fastify Integration
- NestJS Integration
- Feature Gate, Limit & Quota
- Worker & CLI Guard
- Deep Integration — Module Registry & DI
- Large Application Patterns
- Error Handling
- Events & Background Sync
- Security & Data Sent to Server
- Production Checklist
- Troubleshooting
1. Architecture & Principles
Middleware Is Not Enough
HTTP middleware only blocks requests. Attackers or bugs can call services/workers directly. Deep integration means:
- Modules/services must not be bound if the feature is disabled in policy
- Every
get()/resolve()re-checks the license (revoke blocks immediately) - Workers/CLI have their own guards
- Quota/limit enforced at the point of consumption
Two Layers of Protection
| Layer | Mechanism | When |
|---|---|---|
| HTTP | express() / fastifyPlugin() / Nest guards | Every API request |
| Runtime | requireFeature, registry, DI, worker guard | Service, job, CLI |
Policy Is the Source of Truth
The policy token (JWT, EdDSA) contains:
status:active|suspended|revoked| ...features: map of feature → booleanlimits: maximum values (users, workers, ...)quotas/ local usage: daily consumption
Admin changes policy on the server → SDK refreshes → runtime updates. Binding modules/services only happens at bootstrap() — changing a feature requires an app restart to mount/unmount modules; but get()/resolve() still block immediately on revoke.
2. Runtime Lifecycle
constructor → boot() → [bootstrap()] → app running
↓
background check (6h default)
↓
syncFromServer (mutex — no race)
↓
requireActive() on every critical gate
boot() — Required Before Everything
await license.boot();
- Fail-closed v0.5: if license is revoked/expired and offline grace is exhausted → throw, process should exit
- Call
boot()once when the process starts (beforelisten())
Instance ID
instance: {
strategy: 'install-id-domain-hash', // default — recommended for production
domain: process.env.APP_DOMAIN, // e.g.: app.customer.com
}
install-id-domain-hash:sha256(productId:licenseKey:installId:domain)— install ID stored ininstall.idfile undercachePathfixed: use existinginstance.instanceId(reuse a registered instance)custom:sha256(customId)— custom container/K8s pod id
The server counts max instances per license — each install-id + domain pair is one instance.
3. Express Integration (Full)
App Skeleton
import express from 'express';
import { CoreLicense, loadOptionsFromEnv, registerLicensedRoutes } from '@corelicense/node';
const app = express();
app.use(express.json());
const license = new CoreLicense(
loadOptionsFromEnv({
cachePath: './storage/corelicense',
instance: { strategy: 'install-id-domain-hash', domain: process.env.APP_DOMAIN },
checkIntervalMs: 6 * 60 * 60 * 1000,
}),
);
async function main() {
await license.boot();
// --- Routes that do NOT require license ---
app.get('/health', (_req, res) => res.json({ ok: true }));
app.get('/license/status', async (_req, res) => {
res.json(await license.getStatus());
});
// --- License middleware (entire app) ---
app.use(
license.express({
allowPaths: ['/health', '/license/status', '/license/reactivate'],
mode: 'block',
}),
);
// --- Deep integration (optional) ---
const { modules, services, skipped } = await license.bootstrap({
modules: [
{
name: 'automation',
feature: 'automation',
factory: ({ license }) => new AutomationModule(license),
},
{
name: 'export',
feature: 'export',
factory: ({ license }) => new ExportModule(license),
},
],
services: [
{
name: 'ExportService',
feature: 'export',
singleton: true,
factory: ({ license }) => new ExportService(license),
},
],
});
console.log('Skipped (feature off):', skipped);
// Mount router ONLY when module is registered
registerLicensedRoutes(app, modules, [
{ path: '/api/automation', module: 'automation', router: automationRouter },
{ path: '/api/export', module: 'export', router: exportRouter },
]);
// --- Regular routes (already passed requireActive middleware) ---
app.get('/api/me', async (_req, res) => {
await license.requireFeature('dashboard');
res.json({ ok: true });
});
app.listen(process.env.PORT ?? 3000);
}
main().catch((err) => {
console.error('Boot failed:', err);
process.exit(1);
});
allowPaths
Exact match on req.path. Always whitelist:
/health— load balancer/license/status— ops dashboard/license/reactivate— reactivation flow (if applicable)
Middleware 403 Response
{
"ok": false,
"code": "LICENSE_REVOKED",
"source": "core-license.requireActive",
"message": "..."
}
Use error.toClientPayload() — do not leak stack traces.
registerLicensedRoutes
- Only
app.use(path, router)ifmodules.has(name) - Feature off → route does not exist (404), not 403 — harder to discover locked APIs
4. Fastify Integration
import Fastify from 'fastify';
import { CoreLicense, loadOptionsFromEnv } from '@corelicense/node';
const app = Fastify({ logger: true });
const license = new CoreLicense(loadOptionsFromEnv({ cachePath: './storage/corelicense' }));
async function main() {
await license.boot();
app.get('/health', async () => ({ ok: true }));
await app.register(
license.fastifyPlugin({
allowPaths: ['/health', '/license/status'],
}),
);
app.get('/api/data', async () => {
await license.requireFeature('api');
return { data: [] };
});
await app.listen({ port: 3000, host: '0.0.0.0' });
}
main().catch((err) => {
app.log.error(err);
process.exit(1);
});
Direct import:
import { createFastifyPlugin } from '@corelicense/node/fastify';
await app.register(createFastifyPlugin(license, { allowPaths: ['/health'] }));
5. NestJS Integration
AppModule
// app.module.ts
import { Module } from '@nestjs/common';
import { CoreLicenseModule } from '@corelicense/node/nestjs';
import { loadOptionsFromEnv } from '@corelicense/node';
import { AutomationModule } from './automation/automation.module.js';
@Module({
imports: [
CoreLicenseModule.forRoot({
...loadOptionsFromEnv({
cachePath: './storage/corelicense',
instance: { strategy: 'install-id-domain-hash', domain: process.env.APP_DOMAIN },
}),
}),
AutomationModule,
],
})
export class AppModule {}
CoreLicenseModule.forRoot():
- Creates
CoreLicense, callsboot()in the factory - Registers
CoreLicenseGuard,CoreLicenseFeatureGuard - Registers
CoreLicenseExceptionFilter→ HTTP 403 +toClientPayload()
Controller — License Active
import { Controller, Get, UseGuards } from '@nestjs/common';
import { CoreLicense } from '@corelicense/node';
import { CoreLicenseGuard } from '@corelicense/node/nestjs';
@UseGuards(CoreLicenseGuard)
@Controller('api')
export class ApiController {
constructor(private readonly license: CoreLicense) {}
@Get('status')
async status() {
return this.license.getStatus();
}
}
Controller — Specific Feature
import {
Controller,
Get,
UseGuards,
} from '@nestjs/common';
import {
CoreLicenseGuard,
CoreLicenseFeatureGuard,
RequireFeature,
} from '@corelicense/node/nestjs';
@RequireFeature('automation')
@UseGuards(CoreLicenseGuard, CoreLicenseFeatureGuard)
@Controller('automation')
export class AutomationController {
@Get()
list() {
return { items: [] };
}
}
Health Controller (No Guard)
@Controller('health')
export class HealthController {
@Get()
health() {
return { ok: true };
}
}
Place HealthController without CoreLicenseGuard — or use a global guard with @SetMetadata skip (app-dependent).
Inject CoreLicense into Service
import { Injectable } from '@nestjs/common';
import { CoreLicense, LicensedService } from '@corelicense/node';
@Injectable()
export class ExportService extends LicensedService {
constructor(license: CoreLicense) {
super(license);
}
async exportUsers() {
await this.require('export.users');
await this.license.consumeQuota('export.users', 1);
// business logic...
}
}
6. Feature Gate, Limit & Quota
Feature
await license.requireFeature('automation');
await license.requireFeature('export.pdf');
await license.requireFeature('worker.video');
Example policy features:
{
"automation": true,
"export": true,
"export.pdf": false,
"worker.video": true
}
requireFeature → audit log feature_allowed. Throws FeatureDisabledError (FEATURE_DISABLED).
Limit (Current Value vs Policy Ceiling)
const running = await jobQueue.countActive();
await license.checkLimit('maxConcurrentJobs', running);
const userCount = await db.users.count();
await license.checkLimit('maxUsers', userCount);
Throws when currentValue >= limit in policy.
Quota (Daily Cumulative Consumption, Local Cache)
await license.consumeQuota('export.daily', 1);
await license.consumeQuota('api.calls', 10);
- Usage stored in
usage.jsonundercachePath - Resets by day (UTC date string)
consumeQuota/checkLimit/getPolicyall callrequireActive()first
Runtime Config
const runtime = await license.getRuntimeConfig();
// {
// status: 'active',
// modules: { dashboard: true, automation: false, ... },
// limits: { maxWorkers: 2, maxUsers: 100 },
// policyVersion: 'v3',
// workers: { ... } // if defined in policy
// }
Use to conditionally drive UI or configure worker count — does not replace requireFeature for security.
7. Worker & CLI Guard
BullMQ / Queue Worker
import { Worker } from 'bullmq';
await license.boot();
await license.guardWorker('main-worker');
const worker = new Worker('jobs', async (job) => {
await license.requireFeature(`job.${job.name}`);
await license.consumeQuota(`job.${job.name}`, 1);
return processJob(job);
});
guardWorker(name) checks whether policy allows the worker name — throws WORKER_NOT_ALLOWED.
Cron / CLI
async function main() {
await license.boot();
await license.guardCommand('nightly-export');
await runExport();
}
main().catch(console.error);
Throws COMMAND_NOT_ALLOWED if the feature/command is not licensed.
8. Deep Integration — Module Registry & DI
When to Use
- App has multiple large modules (automation, export, AI, ...)
- Want to not load code for license-locked modules
- Want to conditionally mount routes / register providers
bootstrap() — Once After boot()
const result = await license.bootstrap({
modules: ModuleDefinition[],
services: ServiceDefinition[],
});
// result.modules → LicensedModuleRegistry
// result.services → LicensedServiceContainer
// result.skipped → [{ name, feature, reason: 'feature_disabled' }]
ModuleDefinition
{
name: 'automation', // unique id
feature: 'automation', // key in policy.features
factory: ({ license, runtime }) => new AutomationModule(license),
}
ServiceDefinition
{
name: 'ExportService',
feature: 'export',
singleton: true, // default true — factory runs once
factory: ({ license }) => new ExportService(license),
}
modules.get(name)
const automation = await modules.get<AutomationModule>('automation');
await automation.runJob(payload);
- Module not registered →
ModuleNotRegisteredError - Feature off at bootstrap → not registered →
ModuleNotLicensedError - Every
get()callsrequireFeature— revoke/suspend blocks immediately
services.resolve(name)
const svc = await services.resolve<ExportService>('ExportService');
await svc.exportUsers();
- Singleton: internal instance cache, but every
resolve()re-checks license - Re-check does not write audit (unlike
license.requireFeature())
Create Registry Manually (Advanced)
const modules = license.createLicensedRegistry();
const services = license.createLicensedContainer();
const ctx = {
license,
runtime: await license.getRuntimeConfig(),
};
await modules.bootstrap(definitions, ctx);
LicensedService Base Class
import { LicensedService, CoreLicense } from '@corelicense/node';
export class ExportService extends LicensedService {
constructor(license: CoreLicense) {
super(license);
}
async exportUsers() {
await this.require('export.users');
await this.license.consumeQuota('export.users', 1);
// ...
}
}
9. Large Application Patterns
Suggested Directory Structure
src/
bootstrap/
license.ts # initialize CoreLicense + boot()
modules.ts # define bootstrap modules/services
modules/
automation/
automation.module.ts
automation.router.ts
export/
export.service.ts
app.ts # express + registerLicensedRoutes
bootstrap/license.ts
import { CoreLicense, loadOptionsFromEnv } from '@corelicense/node';
let license: CoreLicense;
export async function initLicense() {
license = new CoreLicense(loadOptionsFromEnv({ cachePath: './storage/corelicense' }));
await license.boot();
return license;
}
export function getLicense() {
if (!license) throw new Error('License not initialized');
return license;
}
bootstrap/modules.ts
import type { BootstrapResult } from '@corelicense/node';
import { getLicense } from './license.js';
import { AutomationModule } from '../modules/automation/automation.module.js';
import { ExportService } from '../modules/export/export.service.js';
export async function bootstrapApp(): Promise<BootstrapResult> {
return getLicense().bootstrap({
modules: [
{ name: 'automation', feature: 'automation', factory: ({ license }) => new AutomationModule(license) },
],
services: [
{ name: 'ExportService', feature: 'export', factory: ({ license }) => new ExportService(license) },
],
});
}
Graceful Shutdown
process.on('SIGTERM', () => {
license.stopBackgroundCheck();
process.exit(0);
});
Docker
ENV CORELICENSE_KEY=...
ENV CORELICENSE_PRODUCT_ID=...
ENV APP_DOMAIN=app.customer.com
VOLUME ["/app/storage/corelicense"]
Persist cachePath — losing the volume = losing install-id → counts as a new instance.
Multi-Tenant
- One license / one deploy (recommended): each customer gets their own deployment + key
- Do not share one
CoreLicenseinstance across multiple tenants — instance ID and policy are tied to one license
10. Error Handling
Type Guard
import {
isLicenseError,
formatLicenseError,
LicenseErrorCode,
} from '@corelicense/node';
try {
await license.boot();
} catch (error) {
if (isLicenseError(error)) {
console.error(error.code, error.source, error.message);
// CONFIG_MISSING_ENV, LICENSE_REVOKED, OFFLINE_GRACE_EXPIRED, ...
}
throw error;
}
HTTP API
if (isLicenseError(error)) {
res.status(403).json(error.toClientPayload());
}
Common Error Codes
| Code | Meaning |
|---|---|
CONFIG_MISSING_ENV | Missing CORELICENSE_KEY / CORELICENSE_PRODUCT_ID |
LICENSE_REVOKED | Key revoked |
LICENSE_SUSPENDED | Suspended |
LICENSE_EXPIRED | Expired |
OFFLINE_GRACE_EXPIRED | Server unreachable for too long |
FEATURE_DISABLED | Feature not in policy |
MODULE_NOT_LICENSED | Module not bound / feature off |
QUOTA_EXCEEDED | Daily quota exceeded |
MAX_INSTANCES | Instance count exceeded |
INSTANCE_BLOCKED | Instance blocked in admin |
POLICY_SIGNATURE_ERROR | Token failed verification |
See full list: API Reference — Error codes
11. Events & Background Sync
Event Bus
license.on('licenseRevoked', (payload) => {
console.error('License revoked', payload);
process.exit(1);
});
license.on('policyUpdated', (payload) => {
console.log('Policy updated', payload);
});
license.on('offlineMode', (payload) => {
console.warn('Offline mode', payload);
});
Events: statusChanged, policyUpdated, licenseRevoked, licenseSuspended, offlineMode
Background Check
boot() auto-starts if checkIntervalMs > 0. Manual control:
license.startBackgroundCheck();
license.stopBackgroundCheck();
syncFromServer uses a mutex — parallel refresh does not race.
Manual Refresh
await license.refresh();
refresh endpoint on the License Server.
12. Security & Data Sent to Server
SDK only sends (activate):
productId,licenseKey,instanceIdapplication.domainHash,application.environment(fromNODE_ENV)- SDK metadata (
name,version,runtime,runtimeVersion,appVersion)
SDK only sends (check/refresh):
productId,licenseKey,instanceIdcurrentPolicyHash(SHA-256 of current policy token)- SDK metadata
Does not send: database, user data, business logs.
apiUrl Pin (v0.5)
The server may return apiUrl in the response. The SDK only accepts URLs in the whitelist (trusted apiUrl, env, embedded key). Malicious redirects are rejected + audit api_url_redirect_rejected.
Public Key
- Default: fetch
GET /v1/public/products/{productId}/keys - Enterprise: pin
CORELICENSE_PUBLIC_KEY
13. Production Checklist
-
CORELICENSE_KEY+CORELICENSE_PRODUCT_IDin env (secret manager) -
APP_DOMAIN/instance.domainset correctly -
cachePathpersisted (volume) -
boot()failure →process.exit(1)— do not start app "half-licensed" -
/healthinallowPaths - License Server:
SDK_PUBLIC_URL=https://api.corelicense.net - Policy signer configured on Go API
- Audit log path has rotation
- Test revoke: admin revoke → app blocks within refresh interval / immediately at
requireFeature
14. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
CONFIG_MISSING_ENV | Missing env | Set CORELICENSE_* |
boot() fails offline | Grace expired | Connect to server or temporarily set offlineGrace: false only if you understand the risk |
MAX_INSTANCES | Too many instances | Delete old instance in admin or increase limit |
POLICY_SIGNATURE_ERROR | Key rotation / wrong pin | Update public key or remove pin |
| Middleware 403 on every route | Missing allowPaths | Add /health, ... |
| Module 404 | Feature off | By design — registerLicensedRoutes skips |
| Nest 403 without standard JSON | Missing filter | Use CoreLicenseModule.forRoot() (filter included) |