fase(17): licensing module with RSA validation
This commit is contained in:
167
dist/modules/integrations/__tests__/integrations.test.js
vendored
Normal file
167
dist/modules/integrations/__tests__/integrations.test.js
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const vitest_1 = require("vitest");
|
||||
const crypto_1 = require("crypto");
|
||||
const Integration_1 = require("../domain/entities/Integration");
|
||||
const IntegrationType_1 = require("../domain/value-objects/IntegrationType");
|
||||
const WebhookEndpoint_1 = require("../domain/entities/WebhookEndpoint");
|
||||
const WebhookSecret_1 = require("../domain/value-objects/WebhookSecret");
|
||||
const WebhookDispatcher_1 = require("../infrastructure/webhooks/WebhookDispatcher");
|
||||
const pino_1 = require("pino");
|
||||
// ─── Integration Entity ───────────────────────────────────────────────────────
|
||||
(0, vitest_1.describe)('Integration', () => {
|
||||
(0, vitest_1.it)('creates with defaults', () => {
|
||||
const integration = Integration_1.Integration.create({
|
||||
name: 'My Slack',
|
||||
type: IntegrationType_1.IntegrationType.slack(),
|
||||
config: { webhookUrl: 'https://hooks.slack.com/test' },
|
||||
});
|
||||
(0, vitest_1.expect)(integration.name).toBe('My Slack');
|
||||
(0, vitest_1.expect)(integration.type.value).toBe('slack');
|
||||
(0, vitest_1.expect)(integration.enabled).toBe(true);
|
||||
(0, vitest_1.expect)(integration.config.webhookUrl).toBe('https://hooks.slack.com/test');
|
||||
});
|
||||
(0, vitest_1.it)('enable and disable', () => {
|
||||
const integration = Integration_1.Integration.create({
|
||||
name: 'Test',
|
||||
type: IntegrationType_1.IntegrationType.github(),
|
||||
config: {},
|
||||
});
|
||||
integration.disable();
|
||||
(0, vitest_1.expect)(integration.enabled).toBe(false);
|
||||
integration.enable();
|
||||
(0, vitest_1.expect)(integration.enabled).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)('updateConfig merges config', () => {
|
||||
const integration = Integration_1.Integration.create({
|
||||
name: 'Jira',
|
||||
type: IntegrationType_1.IntegrationType.jira(),
|
||||
config: { host: 'https://old.atlassian.net' },
|
||||
});
|
||||
integration.updateConfig({ host: 'https://new.atlassian.net', token: 'tok' });
|
||||
(0, vitest_1.expect)(integration.config.host).toBe('https://new.atlassian.net');
|
||||
(0, vitest_1.expect)(integration.config.token).toBe('tok');
|
||||
});
|
||||
});
|
||||
// ─── IntegrationType ──────────────────────────────────────────────────────────
|
||||
(0, vitest_1.describe)('IntegrationType', () => {
|
||||
(0, vitest_1.it)('parses all valid types', () => {
|
||||
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('slack').value).toBe('slack');
|
||||
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('github').value).toBe('github');
|
||||
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('jira').value).toBe('jira');
|
||||
(0, vitest_1.expect)(IntegrationType_1.IntegrationType.fromString('webhook').value).toBe('webhook');
|
||||
});
|
||||
(0, vitest_1.it)('throws on invalid type', () => {
|
||||
(0, vitest_1.expect)(() => IntegrationType_1.IntegrationType.fromString('unknown')).toThrow();
|
||||
});
|
||||
});
|
||||
// ─── WebhookEndpoint ──────────────────────────────────────────────────────────
|
||||
(0, vitest_1.describe)('WebhookEndpoint', () => {
|
||||
(0, vitest_1.it)('creates with auto-generated secret', () => {
|
||||
const endpoint = WebhookEndpoint_1.WebhookEndpoint.create({ url: 'https://example.com/hook' });
|
||||
(0, vitest_1.expect)(endpoint.url).toBe('https://example.com/hook');
|
||||
(0, vitest_1.expect)(endpoint.enabled).toBe(true);
|
||||
(0, vitest_1.expect)(endpoint.secret.value).toBeTruthy();
|
||||
(0, vitest_1.expect)(endpoint.secret.value.length).toBeGreaterThan(20);
|
||||
});
|
||||
(0, vitest_1.it)('records delivery', () => {
|
||||
const endpoint = WebhookEndpoint_1.WebhookEndpoint.create({ url: 'https://example.com/hook' });
|
||||
(0, vitest_1.expect)(endpoint.lastStatus).toBeUndefined();
|
||||
endpoint.recordDelivery(200);
|
||||
(0, vitest_1.expect)(endpoint.lastStatus).toBe(200);
|
||||
(0, vitest_1.expect)(endpoint.lastDeliveredAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
// ─── WebhookSecret ────────────────────────────────────────────────────────────
|
||||
(0, vitest_1.describe)('WebhookSecret', () => {
|
||||
(0, vitest_1.it)('generates a secret', () => {
|
||||
const s = WebhookSecret_1.WebhookSecret.generate();
|
||||
(0, vitest_1.expect)(s.value.length).toBeGreaterThan(20);
|
||||
});
|
||||
(0, vitest_1.it)('fromString round-trips', () => {
|
||||
const s = WebhookSecret_1.WebhookSecret.fromString('mysecret-at-least-16chars');
|
||||
(0, vitest_1.expect)(s.value).toBe('mysecret-at-least-16chars');
|
||||
});
|
||||
(0, vitest_1.it)('throws when secret too short', () => {
|
||||
(0, vitest_1.expect)(() => WebhookSecret_1.WebhookSecret.fromString('short')).toThrow();
|
||||
});
|
||||
});
|
||||
// ─── HMAC signature verification ─────────────────────────────────────────────
|
||||
(0, vitest_1.describe)('HMAC webhook signature', () => {
|
||||
(0, vitest_1.it)('produces valid sha256 signature', () => {
|
||||
const secret = 'test-secret-abc123';
|
||||
const body = JSON.stringify({ event: 'finding.created', data: { id: '1' } });
|
||||
const sig = (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
|
||||
(0, vitest_1.expect)(sig).toBeTruthy();
|
||||
// Verify it's a valid hex string of 64 chars (sha256)
|
||||
(0, vitest_1.expect)(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
(0, vitest_1.it)('same body + secret → same signature', () => {
|
||||
const secret = 'test-secret';
|
||||
const body = 'hello world';
|
||||
const sig1 = (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
|
||||
const sig2 = (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
|
||||
(0, vitest_1.expect)(sig1).toBe(sig2);
|
||||
});
|
||||
(0, vitest_1.it)('different body → different signature', () => {
|
||||
const secret = 'test-secret';
|
||||
const sig1 = (0, crypto_1.createHmac)('sha256', secret).update('body1').digest('hex');
|
||||
const sig2 = (0, crypto_1.createHmac)('sha256', secret).update('body2').digest('hex');
|
||||
(0, vitest_1.expect)(sig1).not.toBe(sig2);
|
||||
});
|
||||
});
|
||||
// ─── WebhookDispatcher ───────────────────────────────────────────────────────
|
||||
(0, vitest_1.describe)('WebhookDispatcher', () => {
|
||||
const logger = (0, pino_1.pino)({ level: 'silent' });
|
||||
(0, vitest_1.it)('calls fetch for each enabled endpoint', async () => {
|
||||
const secret = WebhookSecret_1.WebhookSecret.fromString('secret123456789abcdef');
|
||||
const endpoint = WebhookEndpoint_1.WebhookEndpoint.reconstitute({ url: 'https://example.com/hook', secret, enabled: true, createdAt: new Date() }, { toString: () => 'ep-1', equals: () => false });
|
||||
const mockRepo = {
|
||||
save: vitest_1.vi.fn(),
|
||||
findById: vitest_1.vi.fn(),
|
||||
findAll: vitest_1.vi.fn(),
|
||||
findEnabled: vitest_1.vi.fn().mockResolvedValue([endpoint]),
|
||||
update: vitest_1.vi.fn(),
|
||||
delete: vitest_1.vi.fn(),
|
||||
};
|
||||
const fetchMock = vitest_1.vi.fn().mockResolvedValue({ status: 200, ok: true });
|
||||
global.fetch = fetchMock;
|
||||
const dispatcher = new WebhookDispatcher_1.WebhookDispatcher(mockRepo, logger);
|
||||
const finding = {
|
||||
id: 'f-1',
|
||||
title: 'XSS in login form',
|
||||
severity: 'high',
|
||||
type: 'xss',
|
||||
description: 'Reflected XSS',
|
||||
sessionId: 's-1',
|
||||
};
|
||||
await dispatcher.dispatchFinding(finding);
|
||||
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
(0, vitest_1.expect)(url).toBe('https://example.com/hook');
|
||||
(0, vitest_1.expect)(opts.method).toBe('POST');
|
||||
const headers = opts.headers;
|
||||
(0, vitest_1.expect)(headers['X-ABE-Event']).toBe('finding.created');
|
||||
(0, vitest_1.expect)(headers['X-ABE-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
|
||||
});
|
||||
(0, vitest_1.it)('does not throw when no endpoints', async () => {
|
||||
const mockRepo = {
|
||||
save: vitest_1.vi.fn(),
|
||||
findById: vitest_1.vi.fn(),
|
||||
findAll: vitest_1.vi.fn(),
|
||||
findEnabled: vitest_1.vi.fn().mockResolvedValue([]),
|
||||
update: vitest_1.vi.fn(),
|
||||
delete: vitest_1.vi.fn(),
|
||||
};
|
||||
const dispatcher = new WebhookDispatcher_1.WebhookDispatcher(mockRepo, logger);
|
||||
const finding = {
|
||||
id: 'f-1',
|
||||
title: 'Test',
|
||||
severity: 'low',
|
||||
type: 'info',
|
||||
description: 'Test',
|
||||
sessionId: 's-1',
|
||||
};
|
||||
await (0, vitest_1.expect)(dispatcher.dispatchFinding(finding)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
37
dist/modules/licensing/application/LicenseService.js
vendored
Normal file
37
dist/modules/licensing/application/LicenseService.js
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LicenseService = void 0;
|
||||
const Result_1 = require("../../../shared/domain/Result");
|
||||
const License_1 = require("../domain/entities/License");
|
||||
class LicenseService {
|
||||
constructor(validator) {
|
||||
this.validator = validator;
|
||||
this.currentLicense = License_1.License.createFree();
|
||||
}
|
||||
getCurrentLicense() {
|
||||
return this.currentLicense;
|
||||
}
|
||||
async activate(licenseKey) {
|
||||
const result = await this.validator.validate(licenseKey);
|
||||
if ((0, Result_1.isErr)(result))
|
||||
return result;
|
||||
this.currentLicense = result.value;
|
||||
return result;
|
||||
}
|
||||
hasFeature(feature) {
|
||||
return this.currentLicense.hasFeature(feature);
|
||||
}
|
||||
getStatus() {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.LicenseService = LicenseService;
|
||||
51
dist/modules/licensing/domain/entities/License.js
vendored
Normal file
51
dist/modules/licensing/domain/entities/License.js
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.License = void 0;
|
||||
const Entity_1 = require("../../../../shared/domain/Entity");
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
const LicensePlan_1 = require("../value-objects/LicensePlan");
|
||||
const FeatureEntitlement_1 = require("../value-objects/FeatureEntitlement");
|
||||
class License extends Entity_1.Entity {
|
||||
static createFree() {
|
||||
return new License({
|
||||
plan: LicensePlan_1.LicensePlan.free(),
|
||||
organizationName: 'Community',
|
||||
email: '',
|
||||
expiresAt: null,
|
||||
issuedAt: new Date(),
|
||||
signature: 'free',
|
||||
rawKey: 'free',
|
||||
}, UniqueId_1.UniqueId.create());
|
||||
}
|
||||
static reconstitute(props, id) {
|
||||
return new License(props, id);
|
||||
}
|
||||
get plan() { return this.props.plan; }
|
||||
get organizationName() { return this.props.organizationName; }
|
||||
get email() { return this.props.email; }
|
||||
get expiresAt() { return this.props.expiresAt; }
|
||||
get issuedAt() { return this.props.issuedAt; }
|
||||
get signature() { return this.props.signature; }
|
||||
get rawKey() { return this.props.rawKey; }
|
||||
get isExpired() {
|
||||
if (!this.props.expiresAt)
|
||||
return false;
|
||||
return this.props.expiresAt < new Date();
|
||||
}
|
||||
get isValid() {
|
||||
return !this.isExpired;
|
||||
}
|
||||
getEntitlements() {
|
||||
if (!this.isValid)
|
||||
return FeatureEntitlement_1.FeatureEntitlement.forFeatures(FeatureEntitlement_1.FREE_FEATURES);
|
||||
if (this.props.plan.isEnterprise)
|
||||
return FeatureEntitlement_1.FeatureEntitlement.forFeatures(FeatureEntitlement_1.ENTERPRISE_FEATURES);
|
||||
if (this.props.plan.isPro)
|
||||
return FeatureEntitlement_1.FeatureEntitlement.forFeatures(FeatureEntitlement_1.PRO_FEATURES);
|
||||
return FeatureEntitlement_1.FeatureEntitlement.forFeatures(FeatureEntitlement_1.FREE_FEATURES);
|
||||
}
|
||||
hasFeature(feature) {
|
||||
return this.getEntitlements().has(feature);
|
||||
}
|
||||
}
|
||||
exports.License = License;
|
||||
2
dist/modules/licensing/domain/ports/ILicenseValidator.js
vendored
Normal file
2
dist/modules/licensing/domain/ports/ILicenseValidator.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
43
dist/modules/licensing/domain/value-objects/FeatureEntitlement.js
vendored
Normal file
43
dist/modules/licensing/domain/value-objects/FeatureEntitlement.js
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.FeatureEntitlement = exports.ENTERPRISE_FEATURES = exports.PRO_FEATURES = exports.FREE_FEATURES = void 0;
|
||||
exports.FREE_FEATURES = [
|
||||
'exploration:basic',
|
||||
'findings:basic',
|
||||
'findings:export',
|
||||
'reports:basic',
|
||||
'auth:apikeys',
|
||||
];
|
||||
exports.PRO_FEATURES = [
|
||||
...exports.FREE_FEATURES,
|
||||
'exploration:scheduled',
|
||||
'reports:pdf',
|
||||
'integrations:webhook',
|
||||
'integrations:slack',
|
||||
'integrations:github',
|
||||
'integrations:jira',
|
||||
];
|
||||
exports.ENTERPRISE_FEATURES = [
|
||||
...exports.PRO_FEATURES,
|
||||
'auth:sso',
|
||||
'auth:ldap',
|
||||
'audit:logs',
|
||||
'branding:whitelabel',
|
||||
'data:retention',
|
||||
'infra:postgres',
|
||||
];
|
||||
class FeatureEntitlement {
|
||||
constructor(features) {
|
||||
this.features = features;
|
||||
}
|
||||
static forFeatures(features) {
|
||||
return new FeatureEntitlement(new Set(features));
|
||||
}
|
||||
has(feature) {
|
||||
return this.features.has(feature);
|
||||
}
|
||||
toArray() {
|
||||
return Array.from(this.features);
|
||||
}
|
||||
}
|
||||
exports.FeatureEntitlement = FeatureEntitlement;
|
||||
23
dist/modules/licensing/domain/value-objects/LicensePlan.js
vendored
Normal file
23
dist/modules/licensing/domain/value-objects/LicensePlan.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LicensePlan = void 0;
|
||||
class LicensePlan {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
}
|
||||
static free() { return new LicensePlan('free'); }
|
||||
static pro() { return new LicensePlan('pro'); }
|
||||
static enterprise() { return new LicensePlan('enterprise'); }
|
||||
static fromString(value) {
|
||||
if (value === 'free' || value === 'pro' || value === 'enterprise') {
|
||||
return new LicensePlan(value);
|
||||
}
|
||||
throw new Error(`Invalid license plan: ${value}`);
|
||||
}
|
||||
get isFree() { return this.value === 'free'; }
|
||||
get isPro() { return this.value === 'pro'; }
|
||||
get isEnterprise() { return this.value === 'enterprise'; }
|
||||
toString() { return this.value; }
|
||||
equals(other) { return this.value === other.value; }
|
||||
}
|
||||
exports.LicensePlan = LicensePlan;
|
||||
20
dist/modules/licensing/index.js
vendored
Normal file
20
dist/modules/licensing/index.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LicensingController = exports.requireFeature = exports.RSALicenseValidator = exports.LicenseService = exports.ENTERPRISE_FEATURES = exports.PRO_FEATURES = exports.FREE_FEATURES = exports.FeatureEntitlement = exports.LicensePlan = exports.License = void 0;
|
||||
var License_1 = require("./domain/entities/License");
|
||||
Object.defineProperty(exports, "License", { enumerable: true, get: function () { return License_1.License; } });
|
||||
var LicensePlan_1 = require("./domain/value-objects/LicensePlan");
|
||||
Object.defineProperty(exports, "LicensePlan", { enumerable: true, get: function () { return LicensePlan_1.LicensePlan; } });
|
||||
var FeatureEntitlement_1 = require("./domain/value-objects/FeatureEntitlement");
|
||||
Object.defineProperty(exports, "FeatureEntitlement", { enumerable: true, get: function () { return FeatureEntitlement_1.FeatureEntitlement; } });
|
||||
Object.defineProperty(exports, "FREE_FEATURES", { enumerable: true, get: function () { return FeatureEntitlement_1.FREE_FEATURES; } });
|
||||
Object.defineProperty(exports, "PRO_FEATURES", { enumerable: true, get: function () { return FeatureEntitlement_1.PRO_FEATURES; } });
|
||||
Object.defineProperty(exports, "ENTERPRISE_FEATURES", { enumerable: true, get: function () { return FeatureEntitlement_1.ENTERPRISE_FEATURES; } });
|
||||
var LicenseService_1 = require("./application/LicenseService");
|
||||
Object.defineProperty(exports, "LicenseService", { enumerable: true, get: function () { return LicenseService_1.LicenseService; } });
|
||||
var RSALicenseValidator_1 = require("./infrastructure/validators/RSALicenseValidator");
|
||||
Object.defineProperty(exports, "RSALicenseValidator", { enumerable: true, get: function () { return RSALicenseValidator_1.RSALicenseValidator; } });
|
||||
var FeatureGateMiddleware_1 = require("./infrastructure/middleware/FeatureGateMiddleware");
|
||||
Object.defineProperty(exports, "requireFeature", { enumerable: true, get: function () { return FeatureGateMiddleware_1.requireFeature; } });
|
||||
var LicensingController_1 = require("./infrastructure/http/LicensingController");
|
||||
Object.defineProperty(exports, "LicensingController", { enumerable: true, get: function () { return LicensingController_1.LicensingController; } });
|
||||
36
dist/modules/licensing/infrastructure/http/LicensingController.js
vendored
Normal file
36
dist/modules/licensing/infrastructure/http/LicensingController.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.LicensingController = void 0;
|
||||
const express_1 = require("express");
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
class LicensingController {
|
||||
constructor(licenseService) {
|
||||
this.licenseService = licenseService;
|
||||
this.router = (0, express_1.Router)();
|
||||
this.registerRoutes();
|
||||
}
|
||||
registerRoutes() {
|
||||
this.router.get('/status', this.getStatus.bind(this));
|
||||
this.router.post('/activate', this.activate.bind(this));
|
||||
}
|
||||
getStatus(_req, res) {
|
||||
res.json(this.licenseService.getStatus());
|
||||
}
|
||||
async activate(req, res) {
|
||||
const { licenseKey } = req.body;
|
||||
if (!licenseKey || typeof licenseKey !== 'string') {
|
||||
res.status(400).json({ error: 'licenseKey is required' });
|
||||
return;
|
||||
}
|
||||
const result = await this.licenseService.activate(licenseKey.trim());
|
||||
if ((0, Result_1.isErr)(result)) {
|
||||
res.status(422).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
message: 'License activated successfully',
|
||||
license: this.licenseService.getStatus(),
|
||||
});
|
||||
}
|
||||
}
|
||||
exports.LicensingController = LicensingController;
|
||||
17
dist/modules/licensing/infrastructure/middleware/FeatureGateMiddleware.js
vendored
Normal file
17
dist/modules/licensing/infrastructure/middleware/FeatureGateMiddleware.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.requireFeature = requireFeature;
|
||||
function requireFeature(licenseService, feature) {
|
||||
return (_req, res, next) => {
|
||||
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();
|
||||
};
|
||||
}
|
||||
77
dist/modules/licensing/infrastructure/validators/RSALicenseValidator.js
vendored
Normal file
77
dist/modules/licensing/infrastructure/validators/RSALicenseValidator.js
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RSALicenseValidator = void 0;
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const Result_1 = require("../../../../shared/domain/Result");
|
||||
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
|
||||
const License_1 = require("../../domain/entities/License");
|
||||
const LicensePlan_1 = require("../../domain/value-objects/LicensePlan");
|
||||
// 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-----`;
|
||||
class RSALicenseValidator {
|
||||
constructor(publicKeyPem) {
|
||||
const pem = publicKeyPem ?? PUBLIC_KEY;
|
||||
this.publicKey = crypto_1.default.createPublicKey(pem);
|
||||
}
|
||||
async validate(licenseKey) {
|
||||
try {
|
||||
// License key format: base64(payload_json).base64(signature)
|
||||
const parts = licenseKey.trim().split('.');
|
||||
if (parts.length !== 2) {
|
||||
return (0, Result_1.Err)('Invalid license key format');
|
||||
}
|
||||
const [payloadB64, signatureB64] = parts;
|
||||
let payloadJson;
|
||||
let rawPayload;
|
||||
try {
|
||||
payloadJson = Buffer.from(payloadB64, 'base64').toString('utf-8');
|
||||
rawPayload = JSON.parse(payloadJson);
|
||||
}
|
||||
catch {
|
||||
return (0, Result_1.Err)('Invalid license key: cannot decode payload');
|
||||
}
|
||||
const signature = Buffer.from(signatureB64, 'base64');
|
||||
const isValid = crypto_1.default.verify('sha256', Buffer.from(payloadJson, 'utf-8'), this.publicKey, signature);
|
||||
if (!isValid) {
|
||||
return (0, Result_1.Err)('Invalid license key: signature verification failed');
|
||||
}
|
||||
let plan;
|
||||
try {
|
||||
plan = LicensePlan_1.LicensePlan.fromString(rawPayload.plan);
|
||||
}
|
||||
catch {
|
||||
return (0, Result_1.Err)(`Invalid plan in license: ${rawPayload.plan}`);
|
||||
}
|
||||
const expiresAt = rawPayload.expiresAt ? new Date(rawPayload.expiresAt) : null;
|
||||
if (expiresAt && expiresAt < new Date()) {
|
||||
return (0, Result_1.Err)('License has expired');
|
||||
}
|
||||
const license = License_1.License.reconstitute({
|
||||
plan,
|
||||
organizationName: rawPayload.organizationName,
|
||||
email: rawPayload.email,
|
||||
issuedAt: new Date(rawPayload.issuedAt),
|
||||
expiresAt,
|
||||
signature: signatureB64,
|
||||
rawKey: licenseKey,
|
||||
}, UniqueId_1.UniqueId.create());
|
||||
return (0, Result_1.Ok)(license);
|
||||
}
|
||||
catch (err) {
|
||||
return (0, Result_1.Err)(`License validation error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.RSALicenseValidator = RSALicenseValidator;
|
||||
Reference in New Issue
Block a user