Integration

Detailed guide for integrating @corelicense/node into a production system — for developers and AI agents generating code.

Read first: Installation


Table of Contents


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

LayerMechanismWhen
HTTPexpress() / fastifyPlugin() / Nest guardsEvery API request
RuntimerequireFeature, registry, DI, worker guardService, job, CLI

Policy Is the Source of Truth

The policy token (JWT, EdDSA) contains:

  • status: active | suspended | revoked | ...
  • features: map of feature → boolean
  • limits: 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 (before listen())

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 in install.id file under cachePath
  • fixed: use existing instance.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) if modules.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, calls boot() 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.json under cachePath
  • Resets by day (UTC date string)
  • consumeQuota / checkLimit / getPolicy all call requireActive() 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() calls requireFeature — 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 CoreLicense instance 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

CodeMeaning
CONFIG_MISSING_ENVMissing CORELICENSE_KEY / CORELICENSE_PRODUCT_ID
LICENSE_REVOKEDKey revoked
LICENSE_SUSPENDEDSuspended
LICENSE_EXPIREDExpired
OFFLINE_GRACE_EXPIREDServer unreachable for too long
FEATURE_DISABLEDFeature not in policy
MODULE_NOT_LICENSEDModule not bound / feature off
QUOTA_EXCEEDEDDaily quota exceeded
MAX_INSTANCESInstance count exceeded
INSTANCE_BLOCKEDInstance blocked in admin
POLICY_SIGNATURE_ERRORToken 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, instanceId
  • application.domainHash, application.environment (from NODE_ENV)
  • SDK metadata (name, version, runtime, runtimeVersion, appVersion)

SDK only sends (check/refresh):

  • productId, licenseKey, instanceId
  • currentPolicyHash (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_ID in env (secret manager)
  • APP_DOMAIN / instance.domain set correctly
  • cachePath persisted (volume)
  • boot() failure → process.exit(1) — do not start app "half-licensed"
  • /health in allowPaths
  • 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

SymptomCauseFix
CONFIG_MISSING_ENVMissing envSet CORELICENSE_*
boot() fails offlineGrace expiredConnect to server or temporarily set offlineGrace: false only if you understand the risk
MAX_INSTANCESToo many instancesDelete old instance in admin or increase limit
POLICY_SIGNATURE_ERRORKey rotation / wrong pinUpdate public key or remove pin
Middleware 403 on every routeMissing allowPathsAdd /health, ...
Module 404Feature offBy design — registerLicensedRoutes skips
Nest 403 without standard JSONMissing filterUse CoreLicenseModule.forRoot() (filter included)

Links