From 9300d4a83b03fec8c46b1b39cedd26110cb49779 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 8 Apr 2026 19:56:13 +0200 Subject: [PATCH] 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) --- packages/server/.env.example | 8 +- packages/server/src/common/config/index.ts | 2 + packages/server/src/common/config/license.ts | 8 ++ packages/server/src/modules/App/App.module.ts | 2 + .../src/modules/License/License.controller.ts | 63 ++++++++++ .../src/modules/License/License.module.ts | 11 ++ .../src/modules/License/License.service.ts | 118 ++++++++++++++++++ .../server/src/modules/License/constants.ts | 1 + .../decorators/RequireLicense.decorator.ts | 5 + .../modules/License/guards/License.guard.ts | 32 +++++ 10 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/common/config/license.ts create mode 100644 packages/server/src/modules/License/License.controller.ts create mode 100644 packages/server/src/modules/License/License.module.ts create mode 100644 packages/server/src/modules/License/License.service.ts create mode 100644 packages/server/src/modules/License/constants.ts create mode 100644 packages/server/src/modules/License/decorators/RequireLicense.decorator.ts create mode 100644 packages/server/src/modules/License/guards/License.guard.ts diff --git a/packages/server/.env.example b/packages/server/.env.example index 2a48adf5a..2429b9f8d 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -95,4 +95,10 @@ STRIPE_PAYMENT_SECRET_KEY= STRIPE_PAYMENT_PUBLISHABLE_KEY= STRIPE_PAYMENT_CLIENT_ID= STRIPE_PAYMENT_WEBHOOKS_SECRET= -STRIPE_PAYMENT_REDIRECT_URL= \ No newline at end of file +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 \ No newline at end of file diff --git a/packages/server/src/common/config/index.ts b/packages/server/src/common/config/index.ts index d8c5b7347..bbbe02ab8 100644 --- a/packages/server/src/common/config/index.ts +++ b/packages/server/src/common/config/index.ts @@ -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, ]; diff --git a/packages/server/src/common/config/license.ts b/packages/server/src/common/config/license.ts new file mode 100644 index 000000000..5c23d2f49 --- /dev/null +++ b/packages/server/src/common/config/license.ts @@ -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), +})); diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..c7fd5d89b 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -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, diff --git a/packages/server/src/modules/License/License.controller.ts b/packages/server/src/modules/License/License.controller.ts new file mode 100644 index 000000000..9aa5dc4cc --- /dev/null +++ b/packages/server/src/modules/License/License.controller.ts @@ -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 }; + } +} diff --git a/packages/server/src/modules/License/License.module.ts b/packages/server/src/modules/License/License.module.ts new file mode 100644 index 000000000..d292950f4 --- /dev/null +++ b/packages/server/src/modules/License/License.module.ts @@ -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 {} diff --git a/packages/server/src/modules/License/License.service.ts b/packages/server/src/modules/License/License.service.ts new file mode 100644 index 000000000..ffa88a1c4 --- /dev/null +++ b/packages/server/src/modules/License/License.service.ts @@ -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 = new Map(); + + constructor(private readonly configService: ConfigService) {} + + async validateLicense(): Promise { + const licenseKey = this.configService.get('license.key'); + const cacheTtl = this.configService.get('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 { + 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 { + try { + const validation = await this.callLicenseAPI(licenseKey); + return validation.valid; + } catch { + return false; + } + } + + private async callLicenseAPI(key: string): Promise { + const apiUrl = this.configService.get('license.apiUrl'); + const gracePeriodDays = this.configService.get('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: [], + }; + } + } +} diff --git a/packages/server/src/modules/License/constants.ts b/packages/server/src/modules/License/constants.ts new file mode 100644 index 000000000..6bf616d96 --- /dev/null +++ b/packages/server/src/modules/License/constants.ts @@ -0,0 +1 @@ +export const LICENSE_FEATURE_KEY = 'license:feature'; diff --git a/packages/server/src/modules/License/decorators/RequireLicense.decorator.ts b/packages/server/src/modules/License/decorators/RequireLicense.decorator.ts new file mode 100644 index 000000000..5712e9d34 --- /dev/null +++ b/packages/server/src/modules/License/decorators/RequireLicense.decorator.ts @@ -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); diff --git a/packages/server/src/modules/License/guards/License.guard.ts b/packages/server/src/modules/License/guards/License.guard.ts new file mode 100644 index 000000000..20fe18a6f --- /dev/null +++ b/packages/server/src/modules/License/guards/License.guard.ts @@ -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 { + const requiredFeature = + this.reflector.get(LICENSE_FEATURE_KEY, context.getHandler()) || + this.reflector.get(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; + } +}