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
This commit is contained in:
saravanakumardb1 2026-02-15 14:42:58 -08:00
parent 17772ed42a
commit 5e38342930

View File

@ -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<string, { count: number; firstFailedAt: number }>();
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