From 4b568ceb565f9ab27fbfb4afcd476b52146c9f5a Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 1 Jul 2026 22:46:10 +0800 Subject: [PATCH] Add period-based license keys alongside fixed expiry dates. New keys default to validDays with countdown starting on first activation; existing expiresAtSec keys behave unchanged. Co-authored-by: Cursor --- README.md | 5 +- data.example.json | 7 ++ lib/licenseLogic.mjs | 97 ++++++++++++++++++ src/server.mjs | 50 +++++++--- test/activate.test.mjs | 199 +++++++++++++++++++++++++++++++++++++ test/adminApi.test.mjs | 50 +++++++++- test/licenseLogic.test.mjs | 67 +++++++++++++ 7 files changed, 458 insertions(+), 17 deletions(-) create mode 100644 lib/licenseLogic.mjs create mode 100644 test/activate.test.mjs create mode 100644 test/licenseLogic.test.mjs diff --git a/README.md b/README.md index cd20585..49f7435 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,10 @@ npm test - `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "key": "TTRPG-..." }` (один продуктовый ключ) или `{ "sub": "..." }` (все ключи с этим sub, legacy). - `POST /v1/admin/issue` — админская выдача (`sub`, `pid`, `iat`, `exp`, `did`). - `GET /v1/admin/licenses` — список продуктовых ключей с активациями и флагом `revoked`. -- `POST /v1/admin/product-keys` — создание нового продуктового ключа (`pid`, `maxDevices`, `expiresAtSec`, опционально `key`, `sub`). +- `POST /v1/admin/product-keys` — создание нового продуктового ключа (`pid`, `maxDevices`, опционально `key`, `sub`). + - **По умолчанию** — period-лицензия: `validDays: 365` (отсчёт с первой активации). + - **Fixed** (фиксированная дата): явно передайте `expiresAtSec` (unix-секунды). + - Нельзя указывать `expiresAtSec` и `validDays` одновременно. - `GET /health` — `{ ok: true }`. ## Клиент diff --git a/data.example.json b/data.example.json index 02b9bdb..3f0164b 100644 --- a/data.example.json +++ b/data.example.json @@ -6,6 +6,13 @@ "pid": "dnd_player", "maxDevices": 3, "expiresAtSec": 1893456000 + }, + { + "key": "TTRPG-DEMO-PERIOD-KEY", + "sub": "lic_demo_period", + "pid": "dnd_player", + "maxDevices": 3, + "validDays": 365 } ], "revokedSubs": [], diff --git a/lib/licenseLogic.mjs b/lib/licenseLogic.mjs new file mode 100644 index 0000000..ee24793 --- /dev/null +++ b/lib/licenseLogic.mjs @@ -0,0 +1,97 @@ +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, + 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, + }; +} diff --git a/src/server.mjs b/src/server.mjs index 0135642..18b1699 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -4,6 +4,12 @@ import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + enrichLicenseEntry, + ensurePeriodActivated, + parseProductKeyExpiry, + resolveTokenExp, +} from '../lib/licenseLogic.mjs'; import { signPayload } from '../lib/signPayload.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -104,19 +110,37 @@ export function createServer(options = {}) { if (!already && list.length >= pk.maxDevices) { return json(res, 403, { error: 'too_many_devices' }); } + const now = Math.floor(Date.now() / 1000); + let dataChanged = false; + + try { + const period = ensurePeriodActivated(pk, now); + if (period.changed) dataChanged = true; + } catch { + return json(res, 403, { error: 'license_misconfigured' }); + } + if (!already) { list.push(deviceId); data.activations[pk.sub] = list; - writeData(data); + dataChanged = true; + } + + if (dataChanged) writeData(data); + + let exp; + try { + exp = resolveTokenExp(pk, now); + } catch { + return json(res, 403, { error: 'license_misconfigured' }); } - const now = Math.floor(Date.now() / 1000); const payload = { v: 1, sub: pk.sub, pid: pk.pid, iat: now, - exp: pk.expiresAtSec, + exp, did: deviceId, }; const token = signPayload(payload, privateKeyPem); @@ -131,16 +155,8 @@ export function createServer(options = {}) { const activations = data.activations ?? {}; const licenses = (data.productKeys ?? []).map((pk) => { const devices = activations[pk.sub] ?? []; - return { - key: pk.key, - sub: pk.sub, - pid: pk.pid, - maxDevices: pk.maxDevices, - expiresAtSec: pk.expiresAtSec, - revoked: revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub), - activatedDevices: devices, - activatedCount: devices.length, - }; + const revoked = revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub); + return enrichLicenseEntry(pk, devices, revoked); }); return json(res, 200, { licenses }); } @@ -149,13 +165,17 @@ export function createServer(options = {}) { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const raw = await readBody(req); const body = JSON.parse(raw || '{}'); - const now = Math.floor(Date.now() / 1000); + const expiry = parseProductKeyExpiry(body); + if (!expiry.ok) return json(res, 400, { error: expiry.error }); + const entry = { key: body.key ?? generateProductKey(), sub: body.sub ?? generateSub(), pid: body.pid ?? 'dnd_player', maxDevices: body.maxDevices ?? 3, - expiresAtSec: body.expiresAtSec ?? now + 86400 * 365, + ...(expiry.expiresAtSec != null + ? { expiresAtSec: expiry.expiresAtSec } + : { validDays: expiry.validDays }), }; if (!entry.key || !entry.sub) return json(res, 400, { error: 'invalid_entry' }); const data = readData(); diff --git a/test/activate.test.mjs b/test/activate.test.mjs new file mode 100644 index 0000000..0833980 --- /dev/null +++ b/test/activate.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { generateKeyPairSync } from 'node:crypto'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SECONDS_PER_DAY = 86400; + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve(server.address().port); + }); + }); +} + +function request(port, method, pathname, { body } = {}) { + return new Promise((resolve, reject) => { + const headers = { 'Content-Type': 'application/json' }; + const req = http.request( + { hostname: '127.0.0.1', port, method, path: pathname, headers }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ status: res.statusCode, body: raw ? JSON.parse(raw) : null }); + }); + }, + ); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function makeServer(dataPath) { + process.env.SKIP_LICENSE_LISTEN = '1'; + const { createServer } = await import('../src/server.mjs'); + const { privateKey } = generateKeyPairSync('ed25519'); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + process.env.DND_LICENSE_DATA_PATH = dataPath; + return createServer({ privateKeyPem: pem, adminToken: 'test-admin' }); +} + +function writeData(dataPath, productKeys, activations = {}) { + fs.writeFileSync( + dataPath, + JSON.stringify({ + productKeys, + revokedSubs: [], + revokedKeys: [], + activations, + }), + ); +} + +function tokenPayload(token) { + return JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')); +} + +void test('POST /v1/activate fixed key sets exp from expiresAtSec', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + writeData(dataPath, [ + { + key: 'TTRPG-FIXED', + sub: 'lic_fixed', + pid: 'dnd_player', + maxDevices: 2, + expiresAtSec: 1893456000, + }, + ]); + + const server = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/activate', { + body: { productKey: 'TTRPG-FIXED', deviceId: 'dev-a' }, + }); + assert.equal(res.status, 200); + assert.equal(tokenPayload(res.body.token).exp, 1893456000); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/activate period key starts countdown on first activation', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + writeData(dataPath, [ + { + key: 'TTRPG-PERIOD', + sub: 'lic_period', + pid: 'dnd_player', + maxDevices: 2, + validDays: 30, + }, + ]); + + const server = await makeServer(dataPath); + const port = await listen(server); + try { + const before = Math.floor(Date.now() / 1000); + const res = await request(port, 'POST', '/v1/activate', { + body: { productKey: 'TTRPG-PERIOD', deviceId: 'dev-a' }, + }); + const after = Math.floor(Date.now() / 1000); + assert.equal(res.status, 200); + + const payload = tokenPayload(res.body.token); + assert.ok(payload.exp >= before + 30 * SECONDS_PER_DAY); + assert.ok(payload.exp <= after + 30 * SECONDS_PER_DAY); + + const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + const pk = data.productKeys[0]; + assert.ok(pk.activatedAtSec >= before); + assert.equal(pk.licenseExpSec, payload.exp); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/activate period key re-activation keeps same exp', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + const licenseExpSec = 1700000000 + 365 * SECONDS_PER_DAY; + writeData( + dataPath, + [ + { + key: 'TTRPG-PERIOD', + sub: 'lic_period', + pid: 'dnd_player', + maxDevices: 2, + validDays: 365, + activatedAtSec: 1700000000, + licenseExpSec, + }, + ], + { lic_period: ['dev-a'] }, + ); + + const server = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/activate', { + body: { productKey: 'TTRPG-PERIOD', deviceId: 'dev-a' }, + }); + assert.equal(res.status, 200); + assert.equal(tokenPayload(res.body.token).exp, licenseExpSec); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/activate period key second device shares license exp', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + const licenseExpSec = 1700000000 + 90 * SECONDS_PER_DAY; + writeData( + dataPath, + [ + { + key: 'TTRPG-PERIOD', + sub: 'lic_period', + pid: 'dnd_player', + maxDevices: 2, + validDays: 90, + activatedAtSec: 1700000000, + licenseExpSec, + }, + ], + { lic_period: ['dev-a'] }, + ); + + const server = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/activate', { + body: { productKey: 'TTRPG-PERIOD', deviceId: 'dev-b' }, + }); + assert.equal(res.status, 200); + const payload = tokenPayload(res.body.token); + assert.equal(payload.exp, licenseExpSec); + assert.equal(payload.did, 'dev-b'); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/test/adminApi.test.mjs b/test/adminApi.test.mjs index 1968426..036da60 100644 --- a/test/adminApi.test.mjs +++ b/test/adminApi.test.mjs @@ -77,6 +77,8 @@ void test('GET /v1/admin/licenses returns enriched product keys', async () => { assert.equal(res.status, 200); assert.equal(res.body.licenses.length, 1); assert.equal(res.body.licenses[0].key, 'TTRPG-TEST-KEY'); + assert.equal(res.body.licenses[0].expiryMode, 'fixed'); + assert.equal(res.body.licenses[0].expiresAtSec, 1893456000); assert.equal(res.body.licenses[0].revoked, true); assert.deepEqual(res.body.licenses[0].activatedDevices, ['dev1', 'dev2']); assert.equal(res.body.licenses[0].activatedCount, 2); @@ -105,7 +107,7 @@ void test('POST /v1/admin/product-keys creates a new product key', async () => { assert.equal(res.body.expiresAtSec, 2000000000); const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); - assert.equal(data.productKeys.length, 2); + assert.equal(data.productKeys.length, 3); assert.ok(data.productKeys.some((x) => x.key === res.body.key)); } finally { server.close(); @@ -113,6 +115,52 @@ void test('POST /v1/admin/product-keys creates a new product key', async () => { } }); +void test('POST /v1/admin/product-keys defaults to validDays period', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + fs.copyFileSync(path.join(root, 'data.example.json'), dataPath); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/admin/product-keys', { + token: adminToken, + body: { maxDevices: 3, pid: 'dnd_player' }, + }); + assert.equal(res.status, 201); + assert.equal(res.body.validDays, 365); + assert.equal(res.body.expiresAtSec, undefined); + + const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + const created = data.productKeys.find((x) => x.key === res.body.key); + assert.equal(created.validDays, 365); + assert.equal(created.expiresAtSec, undefined); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/admin/product-keys rejects conflicting expiry', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + fs.copyFileSync(path.join(root, 'data.example.json'), dataPath); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/admin/product-keys', { + token: adminToken, + body: { maxDevices: 3, expiresAtSec: 2000000000, validDays: 30 }, + }); + assert.equal(res.status, 400); + assert.equal(res.body.error, 'conflicting_expiry'); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + void test('POST /v1/admin/revoke by key revokes only that product key', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); const dataPath = path.join(tmp, 'data.json'); diff --git a/test/licenseLogic.test.mjs b/test/licenseLogic.test.mjs new file mode 100644 index 0000000..0a8c7b8 --- /dev/null +++ b/test/licenseLogic.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_VALID_DAYS, + ensurePeriodActivated, + enrichLicenseEntry, + getEffectiveExpSec, + isFixedKey, + isPeriodKey, + parseProductKeyExpiry, + resolveTokenExp, + SECONDS_PER_DAY, +} from '../lib/licenseLogic.mjs'; + +void test('parseProductKeyExpiry defaults to period', () => { + const r = parseProductKeyExpiry({}); + assert.equal(r.ok, true); + assert.equal(r.validDays, DEFAULT_VALID_DAYS); +}); + +void test('parseProductKeyExpiry rejects conflicting expiry fields', () => { + const r = parseProductKeyExpiry({ expiresAtSec: 100, validDays: 30 }); + assert.equal(r.ok, false); + assert.equal(r.error, 'conflicting_expiry'); +}); + +void test('ensurePeriodActivated sets exp once for period keys', () => { + const pk = { validDays: 10 }; + const first = ensurePeriodActivated(pk, 1000); + assert.equal(first.changed, true); + assert.equal(first.exp, 1000 + 10 * SECONDS_PER_DAY); + assert.equal(pk.activatedAtSec, 1000); + + const second = ensurePeriodActivated(pk, 5000); + assert.equal(second.changed, false); + assert.equal(second.exp, first.exp); +}); + +void test('resolveTokenExp for fixed and period keys', () => { + const fixed = { expiresAtSec: 9999 }; + assert.equal(resolveTokenExp(fixed, 100), 9999); + assert.ok(isFixedKey(fixed)); + + const period = { validDays: 7, licenseExpSec: 7000 }; + assert.equal(resolveTokenExp(period, 100), 7000); + assert.ok(isPeriodKey(period)); +}); + +void test('enrichLicenseEntry exposes expiry metadata', () => { + const fixed = enrichLicenseEntry( + { key: 'k', sub: 's', pid: 'p', maxDevices: 1, expiresAtSec: 123 }, + [], + false, + ); + assert.equal(fixed.expiryMode, 'fixed'); + assert.equal(fixed.effectiveExpSec, 123); + + const period = enrichLicenseEntry( + { key: 'k', sub: 's', pid: 'p', maxDevices: 1, validDays: 30 }, + [], + false, + ); + assert.equal(period.expiryMode, 'period'); + assert.equal(period.effectiveExpSec, null); + assert.equal(getEffectiveExpSec({ validDays: 30, licenseExpSec: 555 }), 555); +});