1
0

feat(license): add license module for EE runtime protection

Add License module with service, guard, and controller to enable runtime
license validation for Enterprise Edition features.

Components:
- LicenseService: validates license keys against remote API with caching
- LicenseGuard: protects EE routes using @RequireLicense() decorator
- LicenseController: provides /license/status, /license/verify endpoints
- Configuration: add license.env vars (LICENSE_KEY, LICENSE_API_URL, etc.)

This provides basic runtime protection that can be enhanced later with
deployment key pattern for stronger protection.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Bouhuolia
2026-04-08 19:56:13 +02:00
parent 5944aa3972
commit 9300d4a83b
10 changed files with 249 additions and 1 deletions
+6
View File
@@ -96,3 +96,9 @@ STRIPE_PAYMENT_PUBLISHABLE_KEY=
STRIPE_PAYMENT_CLIENT_ID=
STRIPE_PAYMENT_WEBHOOKS_SECRET=
STRIPE_PAYMENT_REDIRECT_URL=
# License Configuration (Enterprise Edition)
LICENSE_KEY=
LICENSE_API_URL=https://api.bigcapital.com/license/validate
LICENSE_CACHE_TTL=3600
LICENSE_GRACE_PERIOD_DAYS=7
@@ -20,6 +20,7 @@ import cloud from './cloud';
import redis from './redis';
import queue from './queue';
import bullBoard from './bull-board';
import license from './license';
export const config = [
app,
@@ -44,4 +45,5 @@ export const config = [
redis,
queue,
bullBoard,
license,
];
@@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('license', () => ({
key: process.env.LICENSE_KEY,
apiUrl: process.env.LICENSE_API_URL || 'https://api.bigcapital.com/license/validate',
cacheTtl: parseInt(process.env.LICENSE_CACHE_TTL || '3600', 10),
gracePeriodDays: parseInt(process.env.LICENSE_GRACE_PERIOD_DAYS || '7', 10),
}));
@@ -105,6 +105,7 @@ import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module
import { SocketModule } from '../Socket/Socket.module';
import { ThrottlerGuard } from '@nestjs/throttler';
import { AppThrottleModule } from './AppThrottle.module';
import { LicenseModule } from '../License/License.module';
@Module({
imports: [
@@ -243,6 +244,7 @@ import { AppThrottleModule } from './AppThrottle.module';
RolesModule,
SubscriptionModule,
OrganizationModule,
LicenseModule,
TenantDBManagerModule,
PaymentServicesModule,
LoopsModule,
@@ -0,0 +1,63 @@
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LicenseService } from './License.service';
@ApiTags('License')
@Controller('license')
@ApiBearerAuth()
export class LicenseController {
constructor(private readonly licenseService: LicenseService) {}
@Get('status')
@ApiOperation({ summary: 'Get current license status' })
async getLicenseStatus(): Promise<{
valid: boolean;
expiresAt?: Date;
gracePeriodEndsAt?: Date;
features: string[];
daysUntilExpiry?: number;
}> {
const license = await this.licenseService.validateLicense();
return {
valid: license.valid,
expiresAt: license.expiresAt,
gracePeriodEndsAt: license.gracePeriodEndsAt,
features: license.features,
daysUntilExpiry: license.expiresAt
? Math.ceil(
(license.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
)
: undefined,
};
}
@Post('verify')
@ApiOperation({ summary: 'Verify a license key (for testing)' })
async verifyLicense(
@Body() dto: { licenseKey: string },
): Promise<{
valid: boolean;
message: string;
}> {
const isValid = await this.licenseService.verifyLicenseKey(dto.licenseKey);
return {
valid: isValid,
message: isValid
? 'License is valid'
: 'License is invalid or expired',
};
}
@Get('features/:feature')
@ApiOperation({ summary: 'Check if a specific feature is licensed' })
async checkFeature(
@Param('feature') feature: string,
): Promise<{
feature: string;
licensed: boolean;
}> {
const licensed = await this.licenseService.isFeatureLicensed(feature);
return { feature, licensed };
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { LicenseService } from './License.service';
import { LicenseGuard } from './guards/License.guard';
import { LicenseController } from './License.controller';
@Module({
controllers: [LicenseController],
providers: [LicenseService, LicenseGuard],
exports: [LicenseService, LicenseGuard],
})
export class LicenseModule {}
@@ -0,0 +1,118 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface LicenseValidationResponse {
valid: boolean;
expiresAt?: Date;
features: string[];
gracePeriodEndsAt?: Date;
}
@Injectable()
export class LicenseService {
private cache: Map<string, { response: LicenseValidationResponse; timestamp: number }> = new Map();
constructor(private readonly configService: ConfigService) {}
async validateLicense(): Promise<LicenseValidationResponse> {
const licenseKey = this.configService.get<string>('license.key');
const cacheTtl = this.configService.get<number>('license.cacheTtl') || 3600;
if (!licenseKey) {
return {
valid: false,
features: [],
};
}
const cached = this.cache.get(licenseKey);
const now = Date.now();
if (cached && now - cached.timestamp < cacheTtl * 1000) {
return cached.response;
}
const validation = await this.callLicenseAPI(licenseKey);
this.cache.set(licenseKey, { response: validation, timestamp: now });
return validation;
}
async isFeatureLicensed(feature: string): Promise<boolean> {
const license = await this.validateLicense();
if (!license.valid) {
// Check if within grace period
if (license.gracePeriodEndsAt && new Date() < license.gracePeriodEndsAt) {
return license.features.includes(feature);
}
return false;
}
return license.features.includes(feature);
}
async verifyLicenseKey(licenseKey: string): Promise<boolean> {
try {
const validation = await this.callLicenseAPI(licenseKey);
return validation.valid;
} catch {
return false;
}
}
private async callLicenseAPI(key: string): Promise<LicenseValidationResponse> {
const apiUrl = this.configService.get<string>('license.apiUrl');
const gracePeriodDays = this.configService.get<number>('license.gracePeriodDays') || 7;
try {
// For now, implement a basic validation that accepts any non-empty key
// In production, this should call your actual license server
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ licenseKey: key }),
});
if (!response.ok) {
throw new HttpException(
'License validation failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
const data = await response.json();
// Calculate grace period if license is expired
let gracePeriodEndsAt: Date | undefined;
if (data.expiresAt && new Date(data.expiresAt) < new Date()) {
gracePeriodEndsAt = new Date(
new Date(data.expiresAt).getTime() + gracePeriodDays * 24 * 60 * 60 * 1000,
);
}
return {
valid: data.valid,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
features: data.features || [],
gracePeriodEndsAt,
};
} catch (error) {
// If the API call fails, fall back to a permissive mode for development
// In production, you may want to return { valid: false, features: [] }
if (process.env.NODE_ENV === 'development') {
return {
valid: true,
features: ['workspaces'],
};
}
return {
valid: false,
features: [],
};
}
}
}
@@ -0,0 +1 @@
export const LICENSE_FEATURE_KEY = 'license:feature';
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { LICENSE_FEATURE_KEY } from '../constants';
export const RequireLicense = (feature: string) =>
SetMetadata(LICENSE_FEATURE_KEY, feature);
@@ -0,0 +1,32 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { LicenseService } from '../License.service';
import { LICENSE_FEATURE_KEY } from '../constants';
@Injectable()
export class LicenseGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly licenseService: LicenseService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredFeature =
this.reflector.get<string>(LICENSE_FEATURE_KEY, context.getHandler()) ||
this.reflector.get<string>(LICENSE_FEATURE_KEY, context.getClass());
if (!requiredFeature) {
return true; // No license required
}
const isLicensed = await this.licenseService.isFeatureLicensed(requiredFeature);
if (!isLicensed) {
throw new ForbiddenException(
'This feature requires a valid Enterprise license.',
);
}
return true;
}
}