diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 81c7f2f..1bb647b 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -cffa1aeea99f01504bc6c016e12fc62ba63977c7 +1f1678af17637b190210f6a2f16acff4b0ee2427 diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 2763bec..55fa697 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -285,40 +285,40 @@ Spec: `.ralph/specs/phase-15-reporting.md` --- -## Phase 16: Integrations Module [PENDIENTE] +## Phase 16: Integrations Module [COMPLETO] Spec: `.ralph/specs/phase-16-integrations.md` -- [ ] 16.1: Instalar: `npm i @slack/web-api @octokit/rest` -- [ ] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity) -- [ ] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts` -- [ ] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding) -- [ ] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos) -- [ ] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link -- [ ] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps -- [ ] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots -- [ ] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas -- [ ] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks -- [ ] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries -- [ ] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook) -- [ ] 16.13: Tests: webhook dispatch + HMAC verification -- [ ] 16.14: Verificar build completo + commit: `fase(16): integrations module` +- [x] 16.1: Instalar: `npm i @slack/web-api @octokit/rest` +- [x] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity) +- [x] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts` +- [x] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding) +- [x] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos) +- [x] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link +- [x] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps +- [x] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots +- [x] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas +- [x] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks +- [x] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries +- [x] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook) +- [x] 16.13: Tests: webhook dispatch + HMAC verification +- [x] 16.14: Verificar build completo + commit: `fase(16): integrations module` --- -## Phase 17: Licensing Module [PENDIENTE] +## Phase 17: Licensing Module [COMPLETO] Spec: `.ralph/specs/phase-17-licensing.md` -- [ ] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts` -- [ ] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements) -- [ ] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled -- [ ] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays -- [ ] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request -- [ ] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status -- [ ] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno) -- [ ] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.) -- [ ] 17.9: Frontend: License section en Settings -- [ ] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks -- [ ] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation` +- [x] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts` +- [x] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements) +- [x] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled +- [x] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays +- [x] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request +- [x] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status +- [x] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno) +- [x] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.) +- [x] 17.9: Frontend: License section en Settings +- [x] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks +- [x] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation` --- diff --git a/.ralph/progress.json b/.ralph/progress.json index 077093e..0f85cc7 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1,7 +1,7 @@ { "status": "executing", - "indicator": "⠹", - "elapsed_seconds": 630, + "indicator": "⠋", + "elapsed_seconds": 10, "last_output": "", - "timestamp": "2026-03-06 07:21:36" + "timestamp": "2026-03-06 11:29:02" } diff --git a/data/abe.db b/data/abe.db index 159e90a..6d27ea6 100644 Binary files a/data/abe.db and b/data/abe.db differ diff --git a/data/abe.db-shm b/data/abe.db-shm index 1392448..fe9ac28 100644 Binary files a/data/abe.db-shm and b/data/abe.db-shm differ diff --git a/data/abe.db-wal b/data/abe.db-wal index eaa6382..e69de29 100644 Binary files a/data/abe.db-wal and b/data/abe.db-wal differ diff --git a/dist/api/router.js b/dist/api/router.js index 9b6f1a0..f122bf4 100644 --- a/dist/api/router.js +++ b/dist/api/router.js @@ -10,11 +10,13 @@ const FindingsController_1 = require("../modules/findings/infrastructure/http/Fi const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController"); const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController"); const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController"); +const LicensingController_1 = require("../modules/licensing/infrastructure/http/LicensingController"); +const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware"); const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController"); const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware"); function createRouter(deps) { const router = (0, express_1.Router)(); - const { authDeps } = deps; + const { authDeps, licenseService } = deps; // Auth routes — public (no auth middleware) router.use('/auth', (0, AuthController_1.createAuthController)(authDeps.registerCommand, authDeps.loginCommand, authDeps.createOrgCommand, authDeps.inviteMemberCommand, authDeps.createApiKeyCommand, authDeps.getUserQuery, authDeps.listOrgMembersQuery, authDeps.sessionRepository, authDeps.apiKeyRepository, authDeps.userRepository)); // Apply auth middleware to all routes below @@ -23,7 +25,10 @@ function createRouter(deps) { router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps)); router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps)); router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps)); - router.use('/reports', (0, ReportingController_1.createReportingRouter)(deps.reportingDeps)); - router.use('/integrations', (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps)); + router.use('/reports', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'reports:basic'), (0, ReportingController_1.createReportingRouter)(deps.reportingDeps)); + router.use('/integrations', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'integrations:webhook'), (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps)); + // Licensing routes (public-ish — only status and activate, no sensitive data) + const licensingController = new LicensingController_1.LicensingController(licenseService); + router.use('/license', licensingController.router); return router; } diff --git a/dist/main.js b/dist/main.js index 722edef..4510101 100644 --- a/dist/main.js +++ b/dist/main.js @@ -57,6 +57,9 @@ const KyselyIntegrationRepository_1 = require("./modules/integrations/infrastruc const KyselyWebhookEndpointRepository_1 = require("./modules/integrations/infrastructure/repositories/KyselyWebhookEndpointRepository"); const WebhookDispatcher_1 = require("./modules/integrations/infrastructure/webhooks/WebhookDispatcher"); const OnFindingCreated_1 = require("./modules/integrations/application/event-handlers/OnFindingCreated"); +// Licensing module +const RSALicenseValidator_1 = require("./modules/licensing/infrastructure/validators/RSALicenseValidator"); +const LicenseService_1 = require("./modules/licensing/application/LicenseService"); // Job queue const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue"); const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker"); @@ -119,7 +122,10 @@ async function bootstrap() { const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo); // 11. Reporting use cases const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus); - // 11b. Integrations + // 11b. Licensing + const licenseValidator = new RSALicenseValidator_1.RSALicenseValidator(); + const licenseService = new LicenseService_1.LicenseService(licenseValidator); + // 11c. Integrations const integrationRepo = new KyselyIntegrationRepository_1.KyselyIntegrationRepository(db); const webhookRepo = new KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository(db); const webhookDispatcher = new WebhookDispatcher_1.WebhookDispatcher(webhookRepo, logger); @@ -140,6 +146,7 @@ async function bootstrap() { fuzzingDeps: { runFuzz, repository: fuzzRepo }, reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue }, integrationsDeps: { integrationRepo, webhookRepo }, + licenseService, authDeps: { registerCommand, loginCommand, diff --git a/dist/modules/integrations/__tests__/integrations.test.js b/dist/modules/integrations/__tests__/integrations.test.js new file mode 100644 index 0000000..4986f4f --- /dev/null +++ b/dist/modules/integrations/__tests__/integrations.test.js @@ -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(); + }); +}); diff --git a/dist/modules/licensing/application/LicenseService.js b/dist/modules/licensing/application/LicenseService.js new file mode 100644 index 0000000..cf303bf --- /dev/null +++ b/dist/modules/licensing/application/LicenseService.js @@ -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; diff --git a/dist/modules/licensing/domain/entities/License.js b/dist/modules/licensing/domain/entities/License.js new file mode 100644 index 0000000..94286d4 --- /dev/null +++ b/dist/modules/licensing/domain/entities/License.js @@ -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; diff --git a/dist/modules/licensing/domain/ports/ILicenseValidator.js b/dist/modules/licensing/domain/ports/ILicenseValidator.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/licensing/domain/ports/ILicenseValidator.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/licensing/domain/value-objects/FeatureEntitlement.js b/dist/modules/licensing/domain/value-objects/FeatureEntitlement.js new file mode 100644 index 0000000..dd3ab7b --- /dev/null +++ b/dist/modules/licensing/domain/value-objects/FeatureEntitlement.js @@ -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; diff --git a/dist/modules/licensing/domain/value-objects/LicensePlan.js b/dist/modules/licensing/domain/value-objects/LicensePlan.js new file mode 100644 index 0000000..40e0e7b --- /dev/null +++ b/dist/modules/licensing/domain/value-objects/LicensePlan.js @@ -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; diff --git a/dist/modules/licensing/index.js b/dist/modules/licensing/index.js new file mode 100644 index 0000000..e481da3 --- /dev/null +++ b/dist/modules/licensing/index.js @@ -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; } }); diff --git a/dist/modules/licensing/infrastructure/http/LicensingController.js b/dist/modules/licensing/infrastructure/http/LicensingController.js new file mode 100644 index 0000000..e8e4753 --- /dev/null +++ b/dist/modules/licensing/infrastructure/http/LicensingController.js @@ -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; diff --git a/dist/modules/licensing/infrastructure/middleware/FeatureGateMiddleware.js b/dist/modules/licensing/infrastructure/middleware/FeatureGateMiddleware.js new file mode 100644 index 0000000..c761d28 --- /dev/null +++ b/dist/modules/licensing/infrastructure/middleware/FeatureGateMiddleware.js @@ -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(); + }; +} diff --git a/dist/modules/licensing/infrastructure/validators/RSALicenseValidator.js b/dist/modules/licensing/infrastructure/validators/RSALicenseValidator.js new file mode 100644 index 0000000..d71e79c --- /dev/null +++ b/dist/modules/licensing/infrastructure/validators/RSALicenseValidator.js @@ -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; diff --git a/dist/scripts/generate-license.js b/dist/scripts/generate-license.js new file mode 100644 index 0000000..4fbc839 --- /dev/null +++ b/dist/scripts/generate-license.js @@ -0,0 +1,65 @@ +#!/usr/bin/env ts-node +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * 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 + */ +const crypto_1 = __importDefault(require("crypto")); +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag) => { + const idx = args.indexOf(flag); + return idx >= 0 ? args[idx + 1] : undefined; + }; + const plan = (get('--plan') ?? 'pro'); + const org = get('--org') ?? 'Unknown Organization'; + const email = get('--email') ?? ''; + const expires = get('--expires') ?? null; + const keyFile = get('--key') ?? path_1.default.join(process.cwd(), 'license-private.pem'); + return { plan, org, email, expires, keyFile }; +} +function generateLicense(args) { + if (!fs_1.default.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_1.default.readFileSync(args.keyFile, 'utf-8'); + const privateKey = crypto_1.default.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_1.default.sign('sha256', Buffer.from(payloadJson, 'utf-8'), privateKey); + const signatureB64 = signature.toString('base64'); + return `${payloadB64}.${signatureB64}`; +} +function main() { + 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(); diff --git a/frontend/src/pages/settings/LicenseSection.tsx b/frontend/src/pages/settings/LicenseSection.tsx index 3819e25..418da8a 100644 --- a/frontend/src/pages/settings/LicenseSection.tsx +++ b/frontend/src/pages/settings/LicenseSection.tsx @@ -1,8 +1,60 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Shield } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Shield, CheckCircle2, XCircle, Loader2 } from 'lucide-react' +import { apiFetch } from '@/lib/api' + +interface LicenseStatus { + plan: 'free' | 'pro' | 'enterprise' + organizationName: string + email: string + issuedAt: string + expiresAt: string | null + isValid: boolean + features: string[] +} + +const PLAN_LABELS: Record = { + free: 'Free / OSS', + pro: 'Pro', + enterprise: 'Enterprise', +} export function LicenseSection() { + const [licenseKey, setLicenseKey] = useState('') + const [activateError, setActivateError] = useState(null) + const queryClient = useQueryClient() + + const { data: status, isLoading } = useQuery({ + queryKey: ['license-status'], + queryFn: () => apiFetch('/api/license/status'), + }) + + const { mutate: activate, isPending } = useMutation({ + mutationFn: (key: string) => + apiFetch<{ message: string; license: LicenseStatus }>('/api/license/activate', { + method: 'POST', + body: JSON.stringify({ licenseKey: key }), + }), + onSuccess: () => { + setLicenseKey('') + setActivateError(null) + void queryClient.invalidateQueries({ queryKey: ['license-status'] }) + }, + onError: (err: Error) => { + setActivateError(err.message) + }, + }) + + const handleActivate = () => { + if (!licenseKey.trim()) return + setActivateError(null) + activate(licenseKey.trim()) + } + return (
@@ -17,14 +69,92 @@ export function LicenseSection() { Current Plan
+ + {isLoading ? ( +
+ + Loading license status... +
+ ) : status ? ( + <> +
+ Plan + + {PLAN_LABELS[status.plan] ?? status.plan} + +
+ {status.plan !== 'free' && ( + <> +
+ Organization + {status.organizationName} +
+
+ Email + {status.email} +
+
+ Issued + {new Date(status.issuedAt).toLocaleDateString()} +
+
+ Expires + {status.expiresAt ? new Date(status.expiresAt).toLocaleDateString() : 'Never'} +
+
+ Status + {status.isValid ? ( + + Valid + + ) : ( + + Expired + + )} +
+
+ Features +
+ {status.features.map((f) => ( + + {f} + + ))} +
+
+ + )} + + ) : null} +
+ + + + + Activate License + -
- Plan - Free / OSS -
- - License activation will be available in Phase 17 (RSA-signed keys with feature entitlements). - +

+ Paste your license key below to unlock Pro or Enterprise features. +

+