From 5e383429304cd1ce3feb92b7708e04a9b37e9e67 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 15 Feb 2026 14:42:58 -0800 Subject: [PATCH] feat(platform-service): licenses/activate issues JWT tokens + IP lockout - /licenses/activate now enforces in-memory IP lockout window for failed attempts - Device limit enforcement now reads from product config by plan (deviceLimits) - Successful activation returns { license, accessToken, refreshToken } - Re-activation on existing device also returns tokens - Keeps existing license validity checks (status, expiry) - Verified: tsc --noEmit clean, 19 test files / 178 tests passing --- .../src/modules/licenses/routes.ts | 89 +++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/services/platform-service/src/modules/licenses/routes.ts b/services/platform-service/src/modules/licenses/routes.ts index 8342c04e..b7f89932 100644 --- a/services/platform-service/src/modules/licenses/routes.ts +++ b/services/platform-service/src/modules/licenses/routes.ts @@ -11,6 +11,8 @@ import type { FastifyInstance } from 'fastify'; import { getRequestProductId, getRequestProductConfig } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import * as authRepo from '../auth/repository.js'; +import * as jwt from '../auth/jwt.js'; import * as repo from './repository.js'; import { GenerateLicenseSchema, @@ -19,6 +21,37 @@ import { type LicenseDoc, } from './types.js'; +const LOCKOUT_WINDOW_MS = Number(process.env.LICENSE_ACTIVATE_LOCKOUT_WINDOW_MS || 15 * 60 * 1000); +const MAX_FAILED_ATTEMPTS = Number(process.env.LICENSE_ACTIVATE_MAX_FAILED_ATTEMPTS || 5); + +const failedAttemptsByIp = new Map(); + +function assertNotLockedOut(ip: string): void { + const entry = failedAttemptsByIp.get(ip); + if (!entry) return; + if (Date.now() - entry.firstFailedAt > LOCKOUT_WINDOW_MS) { + failedAttemptsByIp.delete(ip); + return; + } + if (entry.count >= MAX_FAILED_ATTEMPTS) { + throw new BadRequestError('Too many failed activation attempts. Please try again later.'); + } +} + +function recordActivationFailure(ip: string): void { + const now = Date.now(); + const entry = failedAttemptsByIp.get(ip); + if (!entry || now - entry.firstFailedAt > LOCKOUT_WINDOW_MS) { + failedAttemptsByIp.set(ip, { count: 1, firstFailedAt: now }); + return; + } + failedAttemptsByIp.set(ip, { count: entry.count + 1, firstFailedAt: entry.firstFailedAt }); +} + +function clearActivationFailures(ip: string): void { + failedAttemptsByIp.delete(ip); +} + export async function licenseRoutes(app: FastifyInstance) { // Generate license app.post('/licenses/generate', async (req, reply) => { @@ -53,33 +86,77 @@ export async function licenseRoutes(app: FastifyInstance) { // Activate app.post('/licenses/activate', async req => { + assertNotLockedOut(req.ip); const parsed = ActivateLicenseSchema.safeParse(req.body); if (!parsed.success) { + recordActivationFailure(req.ip); throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { key, deviceId } = parsed.data; const productId = getRequestProductId(req); + const productConfig = getRequestProductConfig(req); const license = await repo.getByKey(key, productId); - if (!license) throw new NotFoundError('License not found'); - if (license.status !== 'active') throw new BadRequestError('License is not active'); + if (!license) { + recordActivationFailure(req.ip); + throw new NotFoundError('License not found'); + } + if (license.status !== 'active') { + recordActivationFailure(req.ip); + throw new BadRequestError('License is not active'); + } if (license.expiresAt && new Date(license.expiresAt) < new Date()) { + recordActivationFailure(req.ip); throw new BadRequestError('License has expired'); } if (license.deviceIds.includes(deviceId)) { - return license; // Already activated on this device + const user = await authRepo.getById(license.userId); + if (!user || user.status !== 'active') { + recordActivationFailure(req.ip); + throw new BadRequestError('License owner account is missing or disabled'); + } + clearActivationFailures(req.ip); + const accessToken = await jwt.createAccessToken({ + sub: user.id, + email: user.email, + role: user.role, + productId, + plan: license.plan, + }); + const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId }); + return { license, accessToken, refreshToken }; // Already activated on this device } - if (license.deviceIds.length >= license.maxDevices) { + const planDeviceLimit = productConfig.deviceLimits[license.plan] ?? license.maxDevices; + if (license.deviceIds.length >= planDeviceLimit) { + recordActivationFailure(req.ip); throw new BadRequestError( - `Maximum devices (${license.maxDevices}) reached. Deactivate a device first.` + `Maximum devices (${planDeviceLimit}) reached for ${license.plan} plan. Deactivate a device first.` ); } const updated = await repo.update(license.id, license.userId, { deviceIds: [...license.deviceIds, deviceId], + maxDevices: planDeviceLimit, activatedAt: license.activatedAt ?? new Date().toISOString(), }); if (!updated) throw new NotFoundError('License update failed'); - return updated; + + const user = await authRepo.getById(license.userId); + if (!user || user.status !== 'active') { + recordActivationFailure(req.ip); + throw new BadRequestError('License owner account is missing or disabled'); + } + + clearActivationFailures(req.ip); + const accessToken = await jwt.createAccessToken({ + sub: user.id, + email: user.email, + role: user.role, + productId, + plan: updated.plan, + }); + const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId }); + + return { license: updated, accessToken, refreshToken }; }); // Deactivate device