From a6a0e8adf87c3f907f2ed31a20899b323b478fec Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sun, 28 Jun 2026 12:35:40 +0800 Subject: [PATCH] Add admin API to list licenses and create product keys. Expose GET /v1/admin/licenses and POST /v1/admin/product-keys for the license manager app. Co-authored-by: Cursor --- README.md | 2 + src/server.mjs | 244 ++++++++++++++++++++++++++--------------- test/adminApi.test.mjs | 130 ++++++++++++++++++++++ 3 files changed, 285 insertions(+), 91 deletions(-) create mode 100644 test/adminApi.test.mjs diff --git a/README.md b/README.md index b0eaa24..551110a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ npm test - `GET /v1/status?sub=...` → `{ revoked: boolean }`. - `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "sub": "..." }`. - `POST /v1/admin/issue` — админская выдача (`sub`, `pid`, `iat`, `exp`, `did`). +- `GET /v1/admin/licenses` — список продуктовых ключей с активациями и флагом `revoked`. +- `POST /v1/admin/product-keys` — создание нового продуктового ключа (`pid`, `maxDevices`, `expiresAtSec`, опционально `key`, `sub`). - `GET /health` — `{ ok: true }`. ## Клиент diff --git a/src/server.mjs b/src/server.mjs index 7cf68f9..4e9ed82 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -2,9 +2,8 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -import { canonicalJson } from '../lib/canonicalJson.mjs'; import { signPayload } from '../lib/signPayload.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -39,108 +38,171 @@ function readBody(req) { }); } -const privateKeyPem = process.env.LICENSE_PRIVATE_KEY_PEM; -if (!privateKeyPem) { - console.error('Задайте LICENSE_PRIVATE_KEY_PEM (PKCS#8 PEM, Ed25519)'); - process.exit(1); +function checkAdmin(req, adminToken) { + const auth = req.headers.authorization ?? ''; + const tok = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + return tok === adminToken; } -const adminToken = process.env.LICENSE_ADMIN_TOKEN ?? 'change-me-admin'; +function generateProductKey() { + return `TTRPG-${randomUUID().toUpperCase()}`; +} -const port = Number(process.env.PORT ?? 3847); +function generateSub() { + return `lic_${randomUUID().replace(/-/g, '')}`; +} -const server = http.createServer(async (req, res) => { - try { - const url = new URL(req.url ?? '/', `http://localhost`); - if (req.method === 'GET' && url.pathname === '/v1/status') { - const sub = url.searchParams.get('sub'); - if (!sub) return json(res, 400, { error: 'missing_sub' }); - const data = readData(); - const revoked = Array.isArray(data.revokedSubs) && data.revokedSubs.includes(sub); - return json(res, 200, { revoked }); - } +export function createServer(options = {}) { + const privateKeyPem = options.privateKeyPem ?? process.env.LICENSE_PRIVATE_KEY_PEM; + const adminToken = options.adminToken ?? process.env.LICENSE_ADMIN_TOKEN ?? 'change-me-admin'; + if (!privateKeyPem) throw new Error('LICENSE_PRIVATE_KEY_PEM required'); - if (req.method === 'POST' && url.pathname === '/v1/activate') { - const raw = await readBody(req); - const body = JSON.parse(raw || '{}'); - const productKey = body.productKey; - const deviceId = body.deviceId; - if (!productKey || !deviceId) return json(res, 400, { error: 'productKey_and_deviceId_required' }); - - const data = readData(); - const pk = data.productKeys?.find((x) => x.key === productKey); - if (!pk) return json(res, 403, { error: 'unknown_product_key' }); - if (data.revokedSubs?.includes(pk.sub)) return json(res, 403, { error: 'license_revoked' }); - - data.activations ??= {}; - const list = data.activations[pk.sub] ?? []; - const already = list.includes(deviceId); - if (!already && list.length >= pk.maxDevices) { - return json(res, 403, { error: 'too_many_devices' }); + return http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', `http://localhost`); + if (req.method === 'GET' && url.pathname === '/v1/status') { + const sub = url.searchParams.get('sub'); + if (!sub) return json(res, 400, { error: 'missing_sub' }); + const data = readData(); + const revoked = Array.isArray(data.revokedSubs) && data.revokedSubs.includes(sub); + return json(res, 200, { revoked }); } - if (!already) { - list.push(deviceId); - data.activations[pk.sub] = list; + + if (req.method === 'POST' && url.pathname === '/v1/activate') { + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const productKey = body.productKey; + const deviceId = body.deviceId; + if (!productKey || !deviceId) return json(res, 400, { error: 'productKey_and_deviceId_required' }); + + const data = readData(); + const pk = data.productKeys?.find((x) => x.key === productKey); + if (!pk) return json(res, 403, { error: 'unknown_product_key' }); + if (data.revokedSubs?.includes(pk.sub)) return json(res, 403, { error: 'license_revoked' }); + + data.activations ??= {}; + const list = data.activations[pk.sub] ?? []; + const already = list.includes(deviceId); + if (!already && list.length >= pk.maxDevices) { + return json(res, 403, { error: 'too_many_devices' }); + } + if (!already) { + list.push(deviceId); + data.activations[pk.sub] = list; + writeData(data); + } + + const now = Math.floor(Date.now() / 1000); + const payload = { + v: 1, + sub: pk.sub, + pid: pk.pid, + iat: now, + exp: pk.expiresAtSec, + did: deviceId, + }; + const token = signPayload(payload, privateKeyPem); + return json(res, 200, { token, sub: pk.sub }); + } + + if (req.method === 'GET' && url.pathname === '/v1/admin/licenses') { + if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); + const data = readData(); + const revokedSubs = Array.isArray(data.revokedSubs) ? data.revokedSubs : []; + 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: revokedSubs.includes(pk.sub), + activatedDevices: devices, + activatedCount: devices.length, + }; + }); + return json(res, 200, { licenses }); + } + + 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 now = Math.floor(Date.now() / 1000); + 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, + }; + 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' }); + } + data.productKeys.push(entry); writeData(data); + return json(res, 201, entry); } - const now = Math.floor(Date.now() / 1000); - const payload = { - v: 1, - sub: pk.sub, - pid: pk.pid, - iat: now, - exp: pk.expiresAtSec, - did: deviceId, - }; - const token = signPayload(payload, privateKeyPem); - return json(res, 200, { token, sub: pk.sub }); - } + if (req.method === 'POST' && url.pathname === '/v1/admin/revoke') { + if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const sub = body.sub; + if (!sub) return json(res, 400, { error: 'missing_sub' }); + const data = readData(); + data.revokedSubs ??= []; + if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub); + writeData(data); + return json(res, 200, { ok: true }); + } - if (req.method === 'POST' && url.pathname === '/v1/admin/revoke') { - const auth = req.headers.authorization ?? ''; - const tok = auth.startsWith('Bearer ') ? auth.slice(7) : ''; - if (tok !== adminToken) return json(res, 401, { error: 'unauthorized' }); - const raw = await readBody(req); - const body = JSON.parse(raw || '{}'); - const sub = body.sub; - if (!sub) return json(res, 400, { error: 'missing_sub' }); - const data = readData(); - data.revokedSubs ??= []; - if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub); - writeData(data); - return json(res, 200, { ok: true }); - } + if (req.method === 'POST' && url.pathname === '/v1/admin/issue') { + 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 payload = { + v: 1, + sub: body.sub ?? `lic_${randomUUID()}`, + pid: body.pid ?? 'dnd_player', + iat: body.iat ?? now, + exp: body.exp ?? now + 86400 * 365, + did: body.did === undefined ? null : body.did, + }; + const token = signPayload(payload, privateKeyPem); + return json(res, 200, { token, payload }); + } - if (req.method === 'POST' && url.pathname === '/v1/admin/issue') { - const auth = req.headers.authorization ?? ''; - const tok = auth.startsWith('Bearer ') ? auth.slice(7) : ''; - if (tok !== 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 payload = { - v: 1, - sub: body.sub ?? `lic_${randomUUID()}`, - pid: body.pid ?? 'dnd_player', - iat: body.iat ?? now, - exp: body.exp ?? now + 86400 * 365, - did: body.did === undefined ? null : body.did, - }; - const token = signPayload(payload, privateKeyPem); - return json(res, 200, { token, payload }); - } + if (req.method === 'GET' && url.pathname === '/health') { + return json(res, 200, { ok: true }); + } - if (req.method === 'GET' && url.pathname === '/health') { - return json(res, 200, { ok: true }); + return json(res, 404, { error: 'not_found' }); + } catch (e) { + json(res, 500, { error: e instanceof Error ? e.message : String(e) }); } + }); +} - return json(res, 404, { error: 'not_found' }); - } catch (e) { - json(res, 500, { error: e instanceof Error ? e.message : String(e) }); +const isMain = + process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; + +if (isMain) { + const privateKeyPem = process.env.LICENSE_PRIVATE_KEY_PEM; + if (!privateKeyPem) { + console.error('Задайте LICENSE_PRIVATE_KEY_PEM (PKCS#8 PEM, Ed25519)'); + process.exit(1); } -}); -server.listen(port, () => { - console.log(`DndGamePlayerLicenseServer listening on http://localhost:${port}`); -}); + const port = Number(process.env.PORT ?? 3847); + const server = createServer(); + server.listen(port, () => { + console.log(`DndGamePlayerLicenseServer listening on http://localhost:${port}`); + }); +} diff --git a/test/adminApi.test.mjs b/test/adminApi.test.mjs new file mode 100644 index 0000000..ecc008c --- /dev/null +++ b/test/adminApi.test.mjs @@ -0,0 +1,130 @@ +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'; + +import { createServer } from '../src/server.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve(server.address().port); + }); + }); +} + +function request(port, method, pathname, { token, body } = {}) { + return new Promise((resolve, reject) => { + const headers = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + 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(); + }); +} + +function makeServer(dataPath, adminToken = 'test-admin') { + const { privateKey } = generateKeyPairSync('ed25519'); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + process.env.DND_LICENSE_DATA_PATH = dataPath; + const server = createServer({ privateKeyPem: pem, adminToken }); + return { server, adminToken }; +} + +void test('GET /v1/admin/licenses returns enriched product keys', 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-TEST-KEY', + sub: 'lic_test', + pid: 'dnd_player', + maxDevices: 2, + expiresAtSec: 1893456000, + }, + ], + revokedSubs: ['lic_test'], + activations: { lic_test: ['dev1', 'dev2'] }, + }), + ); + + const { server, adminToken } = makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'GET', '/v1/admin/licenses', { token: adminToken }); + 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].revoked, true); + assert.deepEqual(res.body.licenses[0].activatedDevices, ['dev1', 'dev2']); + assert.equal(res.body.licenses[0].activatedCount, 2); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/admin/product-keys creates a new product key', 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 } = makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/admin/product-keys', { + token: adminToken, + body: { maxDevices: 5, expiresAtSec: 2000000000, pid: 'dnd_player' }, + }); + assert.equal(res.status, 201); + assert.match(res.body.key, /^TTRPG-/); + assert.match(res.body.sub, /^lic_/); + assert.equal(res.body.maxDevices, 5); + assert.equal(res.body.expiresAtSec, 2000000000); + + const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + assert.equal(data.productKeys.length, 2); + assert.ok(data.productKeys.some((x) => x.key === res.body.key)); + } 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'); + fs.copyFileSync(path.join(root, 'data.example.json'), dataPath); + + const { server } = makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'GET', '/v1/admin/licenses'); + assert.equal(res.status, 401); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +});