Add download stats, key deletion, batch creation, and newest-first listing.

Track landing downloads per platform/month, support admin delete and bulk key issue, and sort licenses by createdAtSec.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 00:21:21 +08:00
parent 4b568ceb56
commit 6d4b74d410
8 changed files with 349 additions and 32 deletions
+6 -6
View File
@@ -62,13 +62,13 @@ npm test
- `POST /v1/activate``{ "productKey": "...", "deviceId": "..." }``{ token, sub }`.
- `GET /v1/status?sub=...``{ revoked: boolean }`.
- `POST /v1/admin/revoke``Authorization: Bearer <LICENSE_ADMIN_TOKEN>`, тело `{ "key": "TTRPG-..." }` (один продуктовый ключ) или `{ "sub": "..." }` (все ключи с этим sub, legacy).
- `POST /v1/track/download``{ "platform": "windows"|"macos"|"linux" }` → учёт скачиваний в `data.json``downloadStats.byMonth`.
- `POST /v1/admin/revoke``Authorization: Bearer <LICENSE_ADMIN_TOKEN>`, тело `{ "key": "TTRPG-..." }` или `{ "sub": "..." }`.
- `POST /v1/admin/product-keys/delete` — hard delete ключа `{ "key": "..." }`.
- `POST /v1/admin/issue` — админская выдача (`sub`, `pid`, `iat`, `exp`, `did`).
- `GET /v1/admin/licenses` — список продуктовых ключей с активациями и флагом `revoked`.
- `POST /v1/admin/product-keys` — создание нового продуктового ключа (`pid`, `maxDevices`, опционально `key`, `sub`).
- **По умолчанию** — period-лицензия: `validDays: 365` (отсчёт с первой активации).
- **Fixed** (фиксированная дата): явно передайте `expiresAtSec` (unix-секунды).
- Нельзя указывать `expiresAtSec` и `validDays` одновременно.
- `GET /v1/admin/licenses` — список ключей (новые сверху), поля `createdAtSec`, `expiryMode`, `validDays`, …
- `GET /v1/admin/stats/downloads` — обзор `{ months, currentMonth }`; `?month=YYYY-MM` — статистика за месяц.
- `POST /v1/admin/product-keys` — создание ключа (`pid`, `maxDevices`, `validDays` или `expiresAtSec`, опционально `count` 1100).
- `GET /health``{ ok: true }`.
## Клиент
+10 -3
View File
@@ -5,17 +5,24 @@
"sub": "lic_demo_default",
"pid": "dnd_player",
"maxDevices": 3,
"expiresAtSec": 1893456000
"expiresAtSec": 1893456000,
"createdAtSec": 1700000000
},
{
"key": "TTRPG-DEMO-PERIOD-KEY",
"sub": "lic_demo_period",
"pid": "dnd_player",
"maxDevices": 3,
"validDays": 365
"validDays": 365,
"createdAtSec": 1800000000
}
],
"revokedSubs": [],
"revokedKeys": [],
"activations": {}
"activations": {},
"downloadStats": {
"byMonth": {
"2026-07": { "windows": 0, "macos": 0, "linux": 0 }
}
}
}
+42
View File
@@ -0,0 +1,42 @@
export const DOWNLOAD_PLATFORMS = ['windows', 'macos', 'linux'];
export function monthKeyFromDate(date = new Date()) {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
return `${y}-${m}`;
}
export function normalizeDownloadPlatform(platform) {
const p = String(platform ?? '').trim().toLowerCase();
if (DOWNLOAD_PLATFORMS.includes(p)) return p;
return null;
}
export function ensureDownloadStats(data) {
data.downloadStats ??= { byMonth: {} };
data.downloadStats.byMonth ??= {};
return data.downloadStats;
}
export function recordDownload(data, platform, date = new Date()) {
const normalized = normalizeDownloadPlatform(platform);
if (!normalized) return { ok: false, error: 'invalid_platform' };
const stats = ensureDownloadStats(data);
const month = monthKeyFromDate(date);
stats.byMonth[month] ??= { windows: 0, macos: 0, linux: 0 };
stats.byMonth[month][normalized] += 1;
return { ok: true, month, platform: normalized, count: stats.byMonth[month][normalized] };
}
export function getDownloadStatsForMonth(data, month) {
const stats = ensureDownloadStats(data);
const row = stats.byMonth[month] ?? { windows: 0, macos: 0, linux: 0 };
const total = row.windows + row.macos + row.linux;
return { month, windows: row.windows, macos: row.macos, linux: row.linux, total };
}
export function listDownloadMonths(data) {
const stats = ensureDownloadStats(data);
return Object.keys(stats.byMonth).sort((a, b) => b.localeCompare(a));
}
+52
View File
@@ -84,6 +84,7 @@ export function enrichLicenseEntry(pk, devices, revoked) {
sub: pk.sub,
pid: pk.pid,
maxDevices: pk.maxDevices,
createdAtSec: pk.createdAtSec ?? null,
expiryMode: getExpiryMode(pk),
expiresAtSec: pk.expiresAtSec ?? null,
validDays: pk.validDays ?? null,
@@ -95,3 +96,54 @@ export function enrichLicenseEntry(pk, devices, revoked) {
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 };
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dndgameplayer-license-server",
"private": true,
"version": "1.0.2",
"version": "1.1.0",
"type": "module",
"description": "Сервис выдачи и отзыва лицензий DNDGamePlayer (Ed25519)",
"scripts": {
+78 -18
View File
@@ -5,11 +5,19 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildProductKeyEntry,
deleteProductKey,
enrichLicenseEntry,
ensurePeriodActivated,
parseProductKeyExpiry,
resolveTokenExp,
sortLicensesNewestFirst,
} from '../lib/licenseLogic.mjs';
import {
getDownloadStatsForMonth,
listDownloadMonths,
monthKeyFromDate,
recordDownload,
} from '../lib/downloadStats.mjs';
import { signPayload } from '../lib/signPayload.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -153,39 +161,91 @@ export function createServer(options = {}) {
const revokedSubsList = revokedSubs(data);
const revokedKeysList = revokedKeys(data);
const activations = data.activations ?? {};
const licenses = (data.productKeys ?? []).map((pk) => {
const licenses = sortLicensesNewestFirst(
(data.productKeys ?? []).map((pk) => {
const devices = activations[pk.sub] ?? [];
const revoked = revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub);
return enrichLicenseEntry(pk, devices, revoked);
});
}),
);
return json(res, 200, { licenses });
}
if (req.method === 'GET' && url.pathname === '/v1/admin/stats/downloads') {
if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' });
const data = readData();
const monthParam = url.searchParams.get('month');
if (monthParam && monthParam !== 'all') {
return json(res, 200, getDownloadStatsForMonth(data, monthParam));
}
const months = listDownloadMonths(data);
return json(res, 200, {
months,
currentMonth: getDownloadStatsForMonth(data, monthKeyFromDate()),
});
}
if (req.method === 'POST' && url.pathname === '/v1/track/download') {
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const data = readData();
const result = recordDownload(data, body.platform);
if (!result.ok) return json(res, 400, { error: result.error });
writeData(data);
return json(res, 200, { ok: true, ...result });
}
if (req.method === 'POST' && url.pathname === '/v1/admin/product-keys/delete') {
if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' });
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const key = body.key;
if (!key) return json(res, 400, { error: 'missing_key' });
const data = readData();
const result = deleteProductKey(data, key);
if (!result.ok) return json(res, 404, { error: result.error });
writeData(data);
return json(res, 200, { ok: true, deleted: result.deleted });
}
if (req.method === 'POST' && url.pathname === '/v1/admin/product-keys') {
if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' });
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const expiry = parseProductKeyExpiry(body);
if (!expiry.ok) return json(res, 400, { error: expiry.error });
const now = Math.floor(Date.now() / 1000);
const countRaw = body.count ?? 1;
const count = Number(countRaw);
if (!Number.isInteger(count) || count < 1 || count > 100) {
return json(res, 400, { error: 'invalid_count' });
}
const entry = {
key: body.key ?? generateProductKey(),
sub: body.sub ?? generateSub(),
pid: body.pid ?? 'dnd_player',
maxDevices: body.maxDevices ?? 3,
...(expiry.expiresAtSec != null
? { expiresAtSec: expiry.expiresAtSec }
: { validDays: expiry.validDays }),
};
if (!entry.key || !entry.sub) return json(res, 400, { error: 'invalid_entry' });
const data = readData();
data.productKeys ??= [];
if (data.productKeys.some((x) => x.key === entry.key || x.sub === entry.sub)) {
const created = [];
for (let i = 0; i < count; i += 1) {
const itemBody = {
...body,
key: count === 1 ? body.key : undefined,
sub: count === 1 ? body.sub : undefined,
};
const built = buildProductKeyEntry(itemBody, {
generateKey: generateProductKey,
generateSub: generateSub,
nowSec: now + i,
});
if (!built.ok) return json(res, 400, { error: built.error });
if (data.productKeys.some((x) => x.key === built.entry.key || x.sub === built.entry.sub)) {
return json(res, 409, { error: 'duplicate_key_or_sub' });
}
data.productKeys.push(entry);
data.productKeys.push(built.entry);
created.push(built.entry);
}
writeData(data);
return json(res, 201, entry);
if (count === 1) return json(res, 201, created[0]);
return json(res, 201, { keys: created, count: created.length });
}
if (req.method === 'POST' && url.pathname === '/v1/admin/revoke') {
+111
View File
@@ -209,6 +209,117 @@ void test('POST /v1/admin/revoke by key revokes only that product key', async ()
}
});
void test('POST /v1/admin/product-keys/delete removes key from data', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-'));
const dataPath = path.join(tmp, 'data.json');
fs.writeFileSync(
dataPath,
JSON.stringify({
productKeys: [
{
key: 'TTRPG-DELETE-ME',
sub: 'lic_delete',
pid: 'dnd_player',
maxDevices: 1,
validDays: 30,
createdAtSec: 100,
},
],
revokedSubs: [],
revokedKeys: ['TTRPG-DELETE-ME'],
activations: { lic_delete: ['dev1'] },
}),
);
const { server, adminToken } = await makeServer(dataPath);
const port = await listen(server);
try {
const del = await request(port, 'POST', '/v1/admin/product-keys/delete', {
token: adminToken,
body: { key: 'TTRPG-DELETE-ME' },
});
assert.equal(del.status, 200);
assert.equal(del.body.ok, true);
const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
assert.equal(data.productKeys.length, 0);
assert.equal(data.activations.lic_delete, undefined);
assert.equal(data.revokedKeys.length, 0);
} finally {
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
void test('POST /v1/admin/product-keys batch creates multiple keys', 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: { count: 3, maxDevices: 2, validDays: 30 },
});
assert.equal(res.status, 201);
assert.equal(res.body.count, 3);
assert.equal(res.body.keys.length, 3);
} finally {
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
void test('GET /v1/admin/licenses sorts newest first by createdAtSec', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-'));
const dataPath = path.join(tmp, 'data.json');
fs.writeFileSync(
dataPath,
JSON.stringify({
productKeys: [
{ key: 'OLD', sub: 'lic_old', pid: 'dnd_player', maxDevices: 1, validDays: 1, createdAtSec: 100 },
{ key: 'NEW', sub: 'lic_new', pid: 'dnd_player', maxDevices: 1, validDays: 1, createdAtSec: 999 },
],
revokedSubs: [],
revokedKeys: [],
activations: {},
}),
);
const { server, adminToken } = await makeServer(dataPath);
const port = await listen(server);
try {
const list = await request(port, 'GET', '/v1/admin/licenses', { token: adminToken });
assert.equal(list.body.licenses[0].key, 'NEW');
assert.equal(list.body.licenses[1].key, 'OLD');
} finally {
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
void test('POST /v1/track/download and GET stats', 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 track = await request(port, 'POST', '/v1/track/download', { body: { platform: 'windows' } });
assert.equal(track.status, 200);
const stats = await request(port, 'GET', '/v1/admin/stats/downloads', { token: adminToken });
assert.equal(stats.status, 200);
assert.ok(stats.body.currentMonth.windows >= 1);
} finally {
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
void test('admin endpoints reject missing token', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-'));
const dataPath = path.join(tmp, 'data.json');
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getDownloadStatsForMonth,
listDownloadMonths,
monthKeyFromDate,
recordDownload,
} from '../lib/downloadStats.mjs';
void test('recordDownload increments platform counts by month', () => {
const data = {};
const r1 = recordDownload(data, 'windows', new Date('2026-07-15T12:00:00Z'));
assert.equal(r1.ok, true);
assert.equal(r1.month, '2026-07');
assert.equal(r1.count, 1);
recordDownload(data, 'macos', new Date('2026-07-20T12:00:00Z'));
recordDownload(data, 'windows', new Date('2026-07-21T12:00:00Z'));
const stats = getDownloadStatsForMonth(data, '2026-07');
assert.equal(stats.windows, 2);
assert.equal(stats.macos, 1);
assert.equal(stats.linux, 0);
assert.equal(stats.total, 3);
});
void test('recordDownload rejects invalid platform', () => {
const data = {};
const r = recordDownload(data, 'android');
assert.equal(r.ok, false);
});
void test('listDownloadMonths returns sorted keys', () => {
const data = {
downloadStats: {
byMonth: {
'2026-05': { windows: 1, macos: 0, linux: 0 },
'2026-07': { windows: 2, macos: 0, linux: 0 },
},
},
};
assert.deepEqual(listDownloadMonths(data), ['2026-07', '2026-05']);
assert.equal(monthKeyFromDate(new Date('2026-03-01T00:00:00Z')), '2026-03');
});