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:
@@ -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` 1–100).
|
||||
- `GET /health` — `{ ok: true }`.
|
||||
|
||||
## Клиент
|
||||
|
||||
+10
-3
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dndgameplayer-license-server",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"description": "Сервис выдачи и отзыва лицензий DNDGamePlayer (Ed25519)",
|
||||
"scripts": {
|
||||
|
||||
+82
-22
@@ -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 devices = activations[pk.sub] ?? [];
|
||||
const revoked = revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub);
|
||||
return enrichLicenseEntry(pk, devices, revoked);
|
||||
});
|
||||
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)) {
|
||||
return json(res, 409, { error: 'duplicate_key_or_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(built.entry);
|
||||
created.push(built.entry);
|
||||
}
|
||||
data.productKeys.push(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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user