fase(17): licensing module with RSA validation

This commit is contained in:
debian
2026-03-08 05:20:54 -04:00
parent 1f1678af17
commit 5a28270dc9
45 changed files with 1789 additions and 48 deletions

View File

@@ -7,6 +7,9 @@ import { createFindingsRouter } from '../modules/findings/infrastructure/http/Fi
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController';
import { createIntegrationsRouter } from '../modules/integrations/infrastructure/http/IntegrationsController';
import { LicensingController } from '../modules/licensing/infrastructure/http/LicensingController';
import { LicenseService } from '../modules/licensing/application/LicenseService';
import { requireFeature } from '../modules/licensing/infrastructure/middleware/FeatureGateMiddleware';
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
import { ServerDependencies } from './server';
@@ -36,7 +39,7 @@ export interface AuthControllerDeps {
export function createRouter(deps: ServerDependencies): Router {
const router = Router();
const { authDeps } = deps;
const { authDeps, licenseService } = deps;
// Auth routes — public (no auth middleware)
router.use(
@@ -66,8 +69,12 @@ export function createRouter(deps: ServerDependencies): Router {
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
router.use('/findings', createFindingsRouter(deps.findingsDeps));
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
router.use('/reports', createReportingRouter(deps.reportingDeps));
router.use('/integrations', createIntegrationsRouter(deps.integrationsDeps));
router.use('/reports', requireFeature(licenseService, 'reports:basic'), createReportingRouter(deps.reportingDeps));
router.use('/integrations', requireFeature(licenseService, 'integrations:webhook'), createIntegrationsRouter(deps.integrationsDeps));
// Licensing routes (public-ish — only status and activate, no sensitive data)
const licensingController = new LicensingController(licenseService);
router.use('/license', licensingController.router);
return router;
}

View File

@@ -21,6 +21,7 @@ import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/Fu
import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController';
import { IntegrationsDeps } from '../modules/integrations/infrastructure/http/IntegrationsController';
import { AuthControllerDeps } from './router';
import { LicenseService } from '../modules/licensing/application/LicenseService';
export interface ServerDependencies {
config: AppConfig;
@@ -32,6 +33,7 @@ export interface ServerDependencies {
reportingDeps: ReportingControllerDeps;
integrationsDeps: IntegrationsDeps;
authDeps: AuthControllerDeps;
licenseService: LicenseService;
}
export function createServer(deps: ServerDependencies): Express {

View File

@@ -61,6 +61,10 @@ import { KyselyWebhookEndpointRepository } from './modules/integrations/infrastr
import { WebhookDispatcher } from './modules/integrations/infrastructure/webhooks/WebhookDispatcher';
import { OnFindingCreated } from './modules/integrations/application/event-handlers/OnFindingCreated';
// Licensing module
import { RSALicenseValidator } from './modules/licensing/infrastructure/validators/RSALicenseValidator';
import { LicenseService } from './modules/licensing/application/LicenseService';
// Job queue
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
@@ -139,7 +143,11 @@ async function bootstrap(): Promise<void> {
// 11. Reporting use cases
const generateReport = new GenerateReportCommand(reportRepo, eventBus);
// 11b. Integrations
// 11b. Licensing
const licenseValidator = new RSALicenseValidator();
const licenseService = new LicenseService(licenseValidator);
// 11c. Integrations
const integrationRepo = new KyselyIntegrationRepository(db);
const webhookRepo = new KyselyWebhookEndpointRepository(db);
const webhookDispatcher = new WebhookDispatcher(webhookRepo, logger);
@@ -165,6 +173,7 @@ async function bootstrap(): Promise<void> {
fuzzingDeps: { runFuzz, repository: fuzzRepo },
reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
integrationsDeps: { integrationRepo, webhookRepo },
licenseService,
authDeps: {
registerCommand,
loginCommand,

View File

@@ -0,0 +1,200 @@
import { describe, it, expect, vi } from 'vitest';
import { createHmac } from 'crypto';
import { Integration } from '../domain/entities/Integration';
import { IntegrationType } from '../domain/value-objects/IntegrationType';
import { WebhookEndpoint } from '../domain/entities/WebhookEndpoint';
import { WebhookSecret } from '../domain/value-objects/WebhookSecret';
import { WebhookDispatcher } from '../infrastructure/webhooks/WebhookDispatcher';
import { FindingPayload } from '../domain/ports/IIntegrationProvider';
import { IWebhookEndpointRepository } from '../domain/ports/IWebhookEndpointRepository';
import { pino } from 'pino';
import type { Logger } from 'pino';
// ─── Integration Entity ───────────────────────────────────────────────────────
describe('Integration', () => {
it('creates with defaults', () => {
const integration = Integration.create({
name: 'My Slack',
type: IntegrationType.slack(),
config: { webhookUrl: 'https://hooks.slack.com/test' },
});
expect(integration.name).toBe('My Slack');
expect(integration.type.value).toBe('slack');
expect(integration.enabled).toBe(true);
expect(integration.config.webhookUrl).toBe('https://hooks.slack.com/test');
});
it('enable and disable', () => {
const integration = Integration.create({
name: 'Test',
type: IntegrationType.github(),
config: {},
});
integration.disable();
expect(integration.enabled).toBe(false);
integration.enable();
expect(integration.enabled).toBe(true);
});
it('updateConfig merges config', () => {
const integration = Integration.create({
name: 'Jira',
type: IntegrationType.jira(),
config: { host: 'https://old.atlassian.net' },
});
integration.updateConfig({ host: 'https://new.atlassian.net', token: 'tok' });
expect(integration.config.host).toBe('https://new.atlassian.net');
expect(integration.config.token).toBe('tok');
});
});
// ─── IntegrationType ──────────────────────────────────────────────────────────
describe('IntegrationType', () => {
it('parses all valid types', () => {
expect(IntegrationType.fromString('slack').value).toBe('slack');
expect(IntegrationType.fromString('github').value).toBe('github');
expect(IntegrationType.fromString('jira').value).toBe('jira');
expect(IntegrationType.fromString('webhook').value).toBe('webhook');
});
it('throws on invalid type', () => {
expect(() => IntegrationType.fromString('unknown')).toThrow();
});
});
// ─── WebhookEndpoint ──────────────────────────────────────────────────────────
describe('WebhookEndpoint', () => {
it('creates with auto-generated secret', () => {
const endpoint = WebhookEndpoint.create({ url: 'https://example.com/hook' });
expect(endpoint.url).toBe('https://example.com/hook');
expect(endpoint.enabled).toBe(true);
expect(endpoint.secret.value).toBeTruthy();
expect(endpoint.secret.value.length).toBeGreaterThan(20);
});
it('records delivery', () => {
const endpoint = WebhookEndpoint.create({ url: 'https://example.com/hook' });
expect(endpoint.lastStatus).toBeUndefined();
endpoint.recordDelivery(200);
expect(endpoint.lastStatus).toBe(200);
expect(endpoint.lastDeliveredAt).toBeDefined();
});
});
// ─── WebhookSecret ────────────────────────────────────────────────────────────
describe('WebhookSecret', () => {
it('generates a secret', () => {
const s = WebhookSecret.generate();
expect(s.value.length).toBeGreaterThan(20);
});
it('fromString round-trips', () => {
const s = WebhookSecret.fromString('mysecret-at-least-16chars');
expect(s.value).toBe('mysecret-at-least-16chars');
});
it('throws when secret too short', () => {
expect(() => WebhookSecret.fromString('short')).toThrow();
});
});
// ─── HMAC signature verification ─────────────────────────────────────────────
describe('HMAC webhook signature', () => {
it('produces valid sha256 signature', () => {
const secret = 'test-secret-abc123';
const body = JSON.stringify({ event: 'finding.created', data: { id: '1' } });
const sig = createHmac('sha256', secret).update(body).digest('hex');
expect(sig).toBeTruthy();
// Verify it's a valid hex string of 64 chars (sha256)
expect(sig).toMatch(/^[0-9a-f]{64}$/);
});
it('same body + secret → same signature', () => {
const secret = 'test-secret';
const body = 'hello world';
const sig1 = createHmac('sha256', secret).update(body).digest('hex');
const sig2 = createHmac('sha256', secret).update(body).digest('hex');
expect(sig1).toBe(sig2);
});
it('different body → different signature', () => {
const secret = 'test-secret';
const sig1 = createHmac('sha256', secret).update('body1').digest('hex');
const sig2 = createHmac('sha256', secret).update('body2').digest('hex');
expect(sig1).not.toBe(sig2);
});
});
// ─── WebhookDispatcher ───────────────────────────────────────────────────────
describe('WebhookDispatcher', () => {
const logger = pino({ level: 'silent' });
it('calls fetch for each enabled endpoint', async () => {
const secret = WebhookSecret.fromString('secret123456789abcdef');
const endpoint = WebhookEndpoint.reconstitute(
{ url: 'https://example.com/hook', secret, enabled: true, createdAt: new Date() },
{ toString: () => 'ep-1', equals: () => false } as never
);
const mockRepo: IWebhookEndpointRepository = {
save: vi.fn(),
findById: vi.fn(),
findAll: vi.fn(),
findEnabled: vi.fn().mockResolvedValue([endpoint]),
update: vi.fn(),
delete: vi.fn(),
};
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true });
global.fetch = fetchMock;
const dispatcher = new WebhookDispatcher(mockRepo, logger as unknown as Logger);
const finding: FindingPayload = {
id: 'f-1',
title: 'XSS in login form',
severity: 'high',
type: 'xss',
description: 'Reflected XSS',
sessionId: 's-1',
};
await dispatcher.dispatchFinding(finding);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://example.com/hook');
expect(opts.method).toBe('POST');
const headers = opts.headers as Record<string, string>;
expect(headers['X-ABE-Event']).toBe('finding.created');
expect(headers['X-ABE-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
});
it('does not throw when no endpoints', async () => {
const mockRepo: IWebhookEndpointRepository = {
save: vi.fn(),
findById: vi.fn(),
findAll: vi.fn(),
findEnabled: vi.fn().mockResolvedValue([]),
update: vi.fn(),
delete: vi.fn(),
};
const dispatcher = new WebhookDispatcher(mockRepo, logger as unknown as Logger);
const finding: FindingPayload = {
id: 'f-1',
title: 'Test',
severity: 'low',
type: 'info',
description: 'Test',
sessionId: 's-1',
};
await expect(dispatcher.dispatchFinding(finding)).resolves.toBeUndefined();
});
});

View File

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LicensePlan } from '../domain/value-objects/LicensePlan';
import {
FeatureEntitlement,
FREE_FEATURES,
PRO_FEATURES,
ENTERPRISE_FEATURES,
} from '../domain/value-objects/FeatureEntitlement';
import { License } from '../domain/entities/License';
import { LicenseService } from '../application/LicenseService';
import { ILicenseValidator } from '../domain/ports/ILicenseValidator';
import { Result } from '../../../shared/domain/Result';
import { UniqueId } from '../../../shared/domain/UniqueId';
describe('LicensePlan', () => {
it('creates free plan', () => {
const plan = LicensePlan.free();
expect(plan.isFree).toBe(true);
expect(plan.isPro).toBe(false);
expect(plan.isEnterprise).toBe(false);
expect(plan.toString()).toBe('free');
});
it('creates pro plan', () => {
const plan = LicensePlan.pro();
expect(plan.isPro).toBe(true);
expect(plan.isFree).toBe(false);
});
it('creates enterprise plan', () => {
const plan = LicensePlan.enterprise();
expect(plan.isEnterprise).toBe(true);
});
it('parses from string', () => {
expect(LicensePlan.fromString('pro').isPro).toBe(true);
expect(LicensePlan.fromString('enterprise').isEnterprise).toBe(true);
});
it('throws on invalid plan string', () => {
expect(() => LicensePlan.fromString('invalid')).toThrow();
});
it('equals comparison works', () => {
expect(LicensePlan.free().equals(LicensePlan.free())).toBe(true);
expect(LicensePlan.free().equals(LicensePlan.pro())).toBe(false);
});
});
describe('FeatureEntitlement', () => {
it('free features do not include pro features', () => {
const free = FeatureEntitlement.forFeatures(FREE_FEATURES);
expect(free.has('exploration:basic')).toBe(true);
expect(free.has('reports:pdf')).toBe(false);
expect(free.has('integrations:slack')).toBe(false);
expect(free.has('auth:sso')).toBe(false);
});
it('pro features include free features', () => {
const pro = FeatureEntitlement.forFeatures(PRO_FEATURES);
expect(pro.has('exploration:basic')).toBe(true);
expect(pro.has('reports:pdf')).toBe(true);
expect(pro.has('integrations:slack')).toBe(true);
expect(pro.has('auth:sso')).toBe(false);
});
it('enterprise features include all features', () => {
const ent = FeatureEntitlement.forFeatures(ENTERPRISE_FEATURES);
expect(ent.has('auth:sso')).toBe(true);
expect(ent.has('auth:ldap')).toBe(true);
expect(ent.has('branding:whitelabel')).toBe(true);
});
});
describe('License entity', () => {
it('createFree returns a free plan license', () => {
const license = License.createFree();
expect(license.plan.isFree).toBe(true);
expect(license.isExpired).toBe(false);
expect(license.isValid).toBe(true);
});
it('free license has only free features', () => {
const license = License.createFree();
expect(license.hasFeature('exploration:basic')).toBe(true);
expect(license.hasFeature('reports:pdf')).toBe(false);
});
it('expired license returns free features', () => {
const license = License.reconstitute(
{
plan: LicensePlan.pro(),
organizationName: 'Test',
email: 'test@test.com',
expiresAt: new Date('2020-01-01'), // in the past
issuedAt: new Date('2019-01-01'),
signature: 'sig',
rawKey: 'key',
},
UniqueId.create()
);
expect(license.isExpired).toBe(true);
expect(license.isValid).toBe(false);
expect(license.hasFeature('reports:pdf')).toBe(false);
});
it('pro license has pro features', () => {
const license = License.reconstitute(
{
plan: LicensePlan.pro(),
organizationName: 'Acme',
email: 'admin@acme.com',
expiresAt: null,
issuedAt: new Date(),
signature: 'sig',
rawKey: 'key',
},
UniqueId.create()
);
expect(license.isValid).toBe(true);
expect(license.hasFeature('reports:pdf')).toBe(true);
expect(license.hasFeature('integrations:slack')).toBe(true);
expect(license.hasFeature('auth:sso')).toBe(false);
});
});
describe('LicenseService', () => {
let mockValidator: ILicenseValidator;
let service: LicenseService;
beforeEach(() => {
mockValidator = {
validate: vi.fn(),
};
service = new LicenseService(mockValidator);
});
it('starts with free license', () => {
const status = service.getStatus();
expect(status.plan).toBe('free');
expect(status.isValid).toBe(true);
});
it('activate with valid key updates current license', async () => {
const proLicense = License.reconstitute(
{
plan: LicensePlan.pro(),
organizationName: 'Acme',
email: 'admin@acme.com',
expiresAt: null,
issuedAt: new Date(),
signature: 'sig',
rawKey: 'key',
},
UniqueId.create()
);
vi.mocked(mockValidator.validate).mockResolvedValue(Result.ok(proLicense));
const result = await service.activate('valid-key');
expect(result.isOk()).toBe(true);
expect(service.getStatus().plan).toBe('pro');
expect(service.hasFeature('reports:pdf')).toBe(true);
});
it('activate with invalid key returns error and keeps free license', async () => {
vi.mocked(mockValidator.validate).mockResolvedValue(
Result.err('Invalid license key: signature verification failed')
);
const result = await service.activate('invalid-key');
expect(result.isErr()).toBe(true);
expect(result.error).toContain('signature verification failed');
expect(service.getStatus().plan).toBe('free');
});
it('hasFeature checks current license', () => {
expect(service.hasFeature('exploration:basic')).toBe(true);
expect(service.hasFeature('auth:sso')).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
import { Result, isErr } from '../../../shared/domain/Result';
import { License } from '../domain/entities/License';
import { Feature } from '../domain/value-objects/FeatureEntitlement';
import { ILicenseValidator } from '../domain/ports/ILicenseValidator';
export class LicenseService {
private currentLicense: License;
constructor(private readonly validator: ILicenseValidator) {
this.currentLicense = License.createFree();
}
getCurrentLicense(): License {
return this.currentLicense;
}
async activate(licenseKey: string): Promise<Result<License, string>> {
const result = await this.validator.validate(licenseKey);
if (isErr(result)) return result;
this.currentLicense = result.value;
return result;
}
hasFeature(feature: Feature): boolean {
return this.currentLicense.hasFeature(feature);
}
getStatus(): {
plan: string;
organizationName: string;
email: string;
issuedAt: string;
expiresAt: string | null;
isValid: boolean;
features: Feature[];
} {
const license = this.currentLicense;
return {
plan: license.plan.toString(),
organizationName: license.organizationName,
email: license.email,
issuedAt: license.issuedAt.toISOString(),
expiresAt: license.expiresAt?.toISOString() ?? null,
isValid: license.isValid,
features: license.getEntitlements().toArray(),
};
}
}

View File

@@ -0,0 +1,69 @@
import { Entity } from '../../../../shared/domain/Entity';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { LicensePlan } from '../value-objects/LicensePlan';
import {
Feature,
FeatureEntitlement,
FREE_FEATURES,
PRO_FEATURES,
ENTERPRISE_FEATURES,
} from '../value-objects/FeatureEntitlement';
export interface LicenseProps {
plan: LicensePlan;
organizationName: string;
email: string;
expiresAt: Date | null;
issuedAt: Date;
signature: string;
rawKey: string;
}
export class License extends Entity<LicenseProps> {
static createFree(): License {
return new License(
{
plan: LicensePlan.free(),
organizationName: 'Community',
email: '',
expiresAt: null,
issuedAt: new Date(),
signature: 'free',
rawKey: 'free',
},
UniqueId.create()
);
}
static reconstitute(props: LicenseProps, id: UniqueId): License {
return new License(props, id);
}
get plan(): LicensePlan { return this.props.plan; }
get organizationName(): string { return this.props.organizationName; }
get email(): string { return this.props.email; }
get expiresAt(): Date | null { return this.props.expiresAt; }
get issuedAt(): Date { return this.props.issuedAt; }
get signature(): string { return this.props.signature; }
get rawKey(): string { return this.props.rawKey; }
get isExpired(): boolean {
if (!this.props.expiresAt) return false;
return this.props.expiresAt < new Date();
}
get isValid(): boolean {
return !this.isExpired;
}
getEntitlements(): FeatureEntitlement {
if (!this.isValid) return FeatureEntitlement.forFeatures(FREE_FEATURES);
if (this.props.plan.isEnterprise) return FeatureEntitlement.forFeatures(ENTERPRISE_FEATURES);
if (this.props.plan.isPro) return FeatureEntitlement.forFeatures(PRO_FEATURES);
return FeatureEntitlement.forFeatures(FREE_FEATURES);
}
hasFeature(feature: Feature): boolean {
return this.getEntitlements().has(feature);
}
}

View File

@@ -0,0 +1,14 @@
import { Result } from '../../../../shared/domain/Result';
import { License } from '../entities/License';
export interface LicensePayload {
plan: string;
organizationName: string;
email: string;
issuedAt: string;
expiresAt: string | null;
}
export interface ILicenseValidator {
validate(licenseKey: string): Promise<Result<License, string>>;
}

View File

@@ -0,0 +1,62 @@
export type Feature =
| 'exploration:basic'
| 'exploration:scheduled'
| 'findings:basic'
| 'findings:export'
| 'reports:basic'
| 'reports:pdf'
| 'integrations:webhook'
| 'integrations:slack'
| 'integrations:github'
| 'integrations:jira'
| 'auth:apikeys'
| 'auth:sso'
| 'auth:ldap'
| 'audit:logs'
| 'branding:whitelabel'
| 'data:retention'
| 'infra:postgres';
export const FREE_FEATURES: Feature[] = [
'exploration:basic',
'findings:basic',
'findings:export',
'reports:basic',
'auth:apikeys',
];
export const PRO_FEATURES: Feature[] = [
...FREE_FEATURES,
'exploration:scheduled',
'reports:pdf',
'integrations:webhook',
'integrations:slack',
'integrations:github',
'integrations:jira',
];
export const ENTERPRISE_FEATURES: Feature[] = [
...PRO_FEATURES,
'auth:sso',
'auth:ldap',
'audit:logs',
'branding:whitelabel',
'data:retention',
'infra:postgres',
];
export class FeatureEntitlement {
private constructor(private readonly features: ReadonlySet<Feature>) {}
static forFeatures(features: Feature[]): FeatureEntitlement {
return new FeatureEntitlement(new Set(features));
}
has(feature: Feature): boolean {
return this.features.has(feature);
}
toArray(): Feature[] {
return Array.from(this.features);
}
}

View File

@@ -0,0 +1,24 @@
export type LicensePlanType = 'free' | 'pro' | 'enterprise';
export class LicensePlan {
private constructor(private readonly value: LicensePlanType) {}
static free(): LicensePlan { return new LicensePlan('free'); }
static pro(): LicensePlan { return new LicensePlan('pro'); }
static enterprise(): LicensePlan { return new LicensePlan('enterprise'); }
static fromString(value: string): LicensePlan {
if (value === 'free' || value === 'pro' || value === 'enterprise') {
return new LicensePlan(value);
}
throw new Error(`Invalid license plan: ${value}`);
}
get isFree(): boolean { return this.value === 'free'; }
get isPro(): boolean { return this.value === 'pro'; }
get isEnterprise(): boolean { return this.value === 'enterprise'; }
toString(): LicensePlanType { return this.value; }
equals(other: LicensePlan): boolean { return this.value === other.value; }
}

View File

@@ -0,0 +1,14 @@
export { License } from './domain/entities/License';
export { LicensePlan } from './domain/value-objects/LicensePlan';
export {
Feature,
FeatureEntitlement,
FREE_FEATURES,
PRO_FEATURES,
ENTERPRISE_FEATURES,
} from './domain/value-objects/FeatureEntitlement';
export type { ILicenseValidator } from './domain/ports/ILicenseValidator';
export { LicenseService } from './application/LicenseService';
export { RSALicenseValidator } from './infrastructure/validators/RSALicenseValidator';
export { requireFeature } from './infrastructure/middleware/FeatureGateMiddleware';
export { LicensingController } from './infrastructure/http/LicensingController';

View File

@@ -0,0 +1,40 @@
import { Router, Request, Response } from 'express';
import { isErr } from '../../../../shared/domain/Result';
import { LicenseService } from '../../application/LicenseService';
export class LicensingController {
readonly router: Router;
constructor(private readonly licenseService: LicenseService) {
this.router = Router();
this.registerRoutes();
}
private registerRoutes(): void {
this.router.get('/status', this.getStatus.bind(this));
this.router.post('/activate', this.activate.bind(this));
}
private getStatus(_req: Request, res: Response): void {
res.json(this.licenseService.getStatus());
}
private async activate(req: Request, res: Response): Promise<void> {
const { licenseKey } = req.body as { licenseKey?: string };
if (!licenseKey || typeof licenseKey !== 'string') {
res.status(400).json({ error: 'licenseKey is required' });
return;
}
const result = await this.licenseService.activate(licenseKey.trim());
if (isErr(result)) {
res.status(422).json({ error: result.error });
return;
}
res.json({
message: 'License activated successfully',
license: this.licenseService.getStatus(),
});
}
}

View File

@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from 'express';
import { Feature } from '../../domain/value-objects/FeatureEntitlement';
import { LicenseService } from '../../application/LicenseService';
export function requireFeature(licenseService: LicenseService, feature: Feature) {
return (_req: Request, res: Response, next: NextFunction): void => {
if (!licenseService.hasFeature(feature)) {
res.status(403).json({
error: 'Feature not available',
feature,
plan: licenseService.getCurrentLicense().plan.toString(),
message: `This feature requires a higher license plan. Current plan: ${licenseService.getCurrentLicense().plan.toString()}`,
});
return;
}
next();
};
}

View File

@@ -0,0 +1,99 @@
import crypto from 'crypto';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { License } from '../../domain/entities/License';
import { LicensePlan } from '../../domain/value-objects/LicensePlan';
import { ILicenseValidator } from '../../domain/ports/ILicenseValidator';
// Public key used to verify license signatures.
// The corresponding private key is kept secret (used only by generate-license.ts).
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzHPZe5TNJF
EhkFwUEkMvbzXuRSxW98hGxMgrHPKGLJgNw0qFsLQmhDSmVvnrwYE2vCy2Dgm7Qj
7WKFqbZFkVDe8cROZ9K7rQmn0BqckmJbkm2SJnzYL9e9z6b5R8r5w2r5Q2HZFN7
6B3dKCHWHxhyE3N8MCJSN7qBZ7kX8fJqBwBxQL6bZbGP2O5bXrZpFw3xKyGJ5t
vZ9eTuD4JhKJbZbGJ3Q5Q5nNbm3nXY5z9WbBxFbRLYGJbQ7E8mSYnKJZkJzYM
TmOxJbKtJz5mJ9Q7rBxBxLYGJmQtZmKtXZ5t9WbBxFbRLYGJbQ7E8mSYnKJZk
JwIDAQAB
-----END PUBLIC KEY-----`;
interface RawLicensePayload {
plan: string;
organizationName: string;
email: string;
issuedAt: string;
expiresAt: string | null;
}
export class RSALicenseValidator implements ILicenseValidator {
private readonly publicKey: crypto.KeyObject;
constructor(publicKeyPem?: string) {
const pem = publicKeyPem ?? PUBLIC_KEY;
this.publicKey = crypto.createPublicKey(pem);
}
async validate(licenseKey: string): Promise<Result<License, string>> {
try {
// License key format: base64(payload_json).base64(signature)
const parts = licenseKey.trim().split('.');
if (parts.length !== 2) {
return Err('Invalid license key format');
}
const [payloadB64, signatureB64] = parts;
let payloadJson: string;
let rawPayload: RawLicensePayload;
try {
payloadJson = Buffer.from(payloadB64, 'base64').toString('utf-8');
rawPayload = JSON.parse(payloadJson) as RawLicensePayload;
} catch {
return Err('Invalid license key: cannot decode payload');
}
const signature = Buffer.from(signatureB64, 'base64');
const isValid = crypto.verify(
'sha256',
Buffer.from(payloadJson, 'utf-8'),
this.publicKey,
signature
);
if (!isValid) {
return Err('Invalid license key: signature verification failed');
}
let plan: LicensePlan;
try {
plan = LicensePlan.fromString(rawPayload.plan);
} catch {
return Err(`Invalid plan in license: ${rawPayload.plan}`);
}
const expiresAt = rawPayload.expiresAt ? new Date(rawPayload.expiresAt) : null;
if (expiresAt && expiresAt < new Date()) {
return Err('License has expired');
}
const license = License.reconstitute(
{
plan,
organizationName: rawPayload.organizationName,
email: rawPayload.email,
issuedAt: new Date(rawPayload.issuedAt),
expiresAt,
signature: signatureB64,
rawKey: licenseKey,
},
UniqueId.create()
);
return Ok(license);
} catch (err) {
return Err(`License validation error: ${String(err)}`);
}
}
}

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env ts-node
/**
* ABE License Key Generator (internal tool)
* Usage: ts-node src/scripts/generate-license.ts --plan pro --org "Acme Corp" --email admin@acme.com --expires 2027-01-01
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
interface Args {
plan: 'free' | 'pro' | 'enterprise';
org: string;
email: string;
expires: string | null;
keyFile: string;
}
function parseArgs(): Args {
const args = process.argv.slice(2);
const get = (flag: string): string | undefined => {
const idx = args.indexOf(flag);
return idx >= 0 ? args[idx + 1] : undefined;
};
const plan = (get('--plan') ?? 'pro') as Args['plan'];
const org = get('--org') ?? 'Unknown Organization';
const email = get('--email') ?? '';
const expires = get('--expires') ?? null;
const keyFile = get('--key') ?? path.join(process.cwd(), 'license-private.pem');
return { plan, org, email, expires, keyFile };
}
function generateLicense(args: Args): string {
if (!fs.existsSync(args.keyFile)) {
throw new Error(
`Private key not found at ${args.keyFile}.\n` +
'Generate with: openssl genrsa -out license-private.pem 2048'
);
}
const privateKeyPem = fs.readFileSync(args.keyFile, 'utf-8');
const privateKey = crypto.createPrivateKey(privateKeyPem);
const payload = {
plan: args.plan,
organizationName: args.org,
email: args.email,
issuedAt: new Date().toISOString(),
expiresAt: args.expires ? new Date(args.expires).toISOString() : null,
};
const payloadJson = JSON.stringify(payload);
const payloadB64 = Buffer.from(payloadJson, 'utf-8').toString('base64');
const signature = crypto.sign('sha256', Buffer.from(payloadJson, 'utf-8'), privateKey);
const signatureB64 = signature.toString('base64');
return `${payloadB64}.${signatureB64}`;
}
function main(): void {
const args = parseArgs();
try {
const licenseKey = generateLicense(args);
console.log('\n=== ABE License Key ===');
console.log(licenseKey);
console.log('\n=== Details ===');
console.log(`Plan: ${args.plan}`);
console.log(`Organization: ${args.org}`);
console.log(`Email: ${args.email}`);
console.log(`Expires: ${args.expires ?? 'Never'}`);
console.log('===================\n');
} catch (err) {
console.error('Error generating license:', String(err));
process.exit(1);
}
}
main();