export const DEFAULT_VALID_DAYS = 365; export const SECONDS_PER_DAY = 86400; export function isPeriodKey(pk) { return pk.validDays != null && pk.validDays > 0; } export function isFixedKey(pk) { return pk.expiresAtSec != null && Number.isFinite(pk.expiresAtSec) && !isPeriodKey(pk); } /** @returns {{ ok: true, expiresAtSec?: number, validDays?: number } | { ok: false, error: string }} */ export function parseProductKeyExpiry(body, nowSec = Math.floor(Date.now() / 1000)) { const hasExpiresAtSec = body.expiresAtSec != null && body.expiresAtSec !== ''; const hasValidDays = body.validDays != null && body.validDays !== ''; if (hasExpiresAtSec && hasValidDays) { return { ok: false, error: 'conflicting_expiry' }; } if (hasExpiresAtSec) { const expiresAtSec = Number(body.expiresAtSec); if (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0) { return { ok: false, error: 'invalid_expiresAtSec' }; } return { ok: true, expiresAtSec }; } if (hasValidDays) { const validDays = Number(body.validDays); if (!Number.isInteger(validDays) || validDays < 1) { return { ok: false, error: 'invalid_validDays' }; } return { ok: true, validDays }; } return { ok: true, validDays: DEFAULT_VALID_DAYS }; } export function getExpiryMode(pk) { return isPeriodKey(pk) ? 'period' : 'fixed'; } export function getEffectiveExpSec(pk) { if (isFixedKey(pk)) return pk.expiresAtSec; if (isPeriodKey(pk) && pk.licenseExpSec != null) return pk.licenseExpSec; return null; } /** * On first activation of a period key, sets activatedAtSec and licenseExpSec on pk. * @returns {{ changed: boolean, exp: number }} */ export function ensurePeriodActivated(pk, nowSec) { if (isFixedKey(pk)) { return { changed: false, exp: pk.expiresAtSec }; } if (!isPeriodKey(pk)) { throw new Error('invalid_product_key_expiry'); } if (pk.licenseExpSec != null && pk.activatedAtSec != null) { return { changed: false, exp: pk.licenseExpSec }; } pk.activatedAtSec = nowSec; pk.licenseExpSec = nowSec + pk.validDays * SECONDS_PER_DAY; return { changed: true, exp: pk.licenseExpSec }; } export function resolveTokenExp(pk, nowSec) { if (isFixedKey(pk)) return pk.expiresAtSec; if (isPeriodKey(pk)) { if (pk.licenseExpSec != null) return pk.licenseExpSec; return nowSec + pk.validDays * SECONDS_PER_DAY; } throw new Error('invalid_product_key_expiry'); } export function enrichLicenseEntry(pk, devices, revoked) { return { key: pk.key, sub: pk.sub, pid: pk.pid, maxDevices: pk.maxDevices, createdAtSec: pk.createdAtSec ?? null, expiryMode: getExpiryMode(pk), expiresAtSec: pk.expiresAtSec ?? null, validDays: pk.validDays ?? null, activatedAtSec: pk.activatedAtSec ?? null, licenseExpSec: pk.licenseExpSec ?? null, effectiveExpSec: getEffectiveExpSec(pk), revoked, activatedDevices: devices, activatedCount: devices.length, }; } export function sortLicensesNewestFirst(licenses) { return [...licenses].sort((a, b) => { const aSec = a.createdAtSec ?? a.activatedAtSec ?? 0; const bSec = b.createdAtSec ?? b.activatedAtSec ?? 0; return bSec - aSec; }); } export function buildProductKeyEntry(body, { generateKey, generateSub, nowSec }) { const expiry = parseProductKeyExpiry(body, nowSec); if (!expiry.ok) return expiry; const entry = { key: body.key ?? generateKey(), sub: body.sub ?? generateSub(), pid: body.pid ?? 'dnd_player', maxDevices: body.maxDevices ?? 3, createdAtSec: nowSec, ...(expiry.expiresAtSec != null ? { expiresAtSec: expiry.expiresAtSec } : { validDays: expiry.validDays }), }; if (!entry.key || !entry.sub) return { ok: false, error: 'invalid_entry' }; return { ok: true, entry }; } export function deleteProductKey(data, key) { const idx = data.productKeys?.findIndex((x) => x.key === key) ?? -1; if (idx < 0) return { ok: false, error: 'unknown_product_key' }; const pk = data.productKeys[idx]; data.productKeys.splice(idx, 1); if (Array.isArray(data.revokedKeys)) { data.revokedKeys = data.revokedKeys.filter((k) => k !== key); } const subStillUsed = data.productKeys.some((x) => x.sub === pk.sub); if (!subStillUsed) { if (data.activations && pk.sub in data.activations) { delete data.activations[pk.sub]; } if (Array.isArray(data.revokedSubs)) { data.revokedSubs = data.revokedSubs.filter((s) => s !== pk.sub); } } return { ok: true, deleted: pk.key, sub: pk.sub }; }