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:
@@ -95,4 +95,10 @@ STRIPE_PAYMENT_SECRET_KEY=
|
||||
STRIPE_PAYMENT_PUBLISHABLE_KEY=
|
||||
STRIPE_PAYMENT_CLIENT_ID=
|
||||
STRIPE_PAYMENT_WEBHOOKS_SECRET=
|
||||
STRIPE_PAYMENT_REDIRECT_URL=
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user