From e299cfc7dd268f5ac93ab6b905e022f61bc4abe4 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 1 Jul 2026 16:44:16 +0800 Subject: [PATCH] Revoke licenses by product key instead of shared sub. Add revokedKeys storage and accept key in admin revoke API so DND/TTRPG keys with the same sub can be revoked independently. Co-authored-by: Cursor --- README.md | 2 +- data.example.json | 1 + package.json | 2 +- src/server.mjs | 41 ++++++++++++++++++++++++++++++------ test/adminApi.test.mjs | 48 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index df8e74e..cd20585 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ npm test - `POST /v1/activate` — `{ "productKey": "...", "deviceId": "..." }` → `{ token, sub }`. - `GET /v1/status?sub=...` → `{ revoked: boolean }`. -- `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "sub": "..." }`. +- `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`). diff --git a/data.example.json b/data.example.json index 3e68684..02b9bdb 100644 --- a/data.example.json +++ b/data.example.json @@ -9,5 +9,6 @@ } ], "revokedSubs": [], + "revokedKeys": [], "activations": {} } diff --git a/package.json b/package.json index 5c21076..743c0c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dndgameplayer-license-server", "private": true, - "version": "1.0.1", + "version": "1.0.2", "type": "module", "description": "Сервис выдачи и отзыва лицензий DNDGamePlayer (Ed25519)", "scripts": { diff --git a/src/server.mjs b/src/server.mjs index a07f7d7..0135642 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -52,6 +52,24 @@ function generateSub() { return `lic_${randomUUID().replace(/-/g, '')}`; } +function revokedKeys(data) { + return Array.isArray(data.revokedKeys) ? data.revokedKeys : []; +} + +function revokedSubs(data) { + return Array.isArray(data.revokedSubs) ? data.revokedSubs : []; +} + +function isProductKeyRevoked(data, productKey) { + const pk = data.productKeys?.find((x) => x.key === productKey); + if (!pk) return false; + return revokedKeys(data).includes(pk.key) || revokedSubs(data).includes(pk.sub); +} + +function isSubRevoked(data, sub) { + return revokedSubs(data).includes(sub); +} + 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'; @@ -64,7 +82,7 @@ export function createServer(options = {}) { 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); + const revoked = isSubRevoked(data, sub); return json(res, 200, { revoked }); } @@ -78,7 +96,7 @@ export function createServer(options = {}) { 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' }); + if (isProductKeyRevoked(data, productKey)) return json(res, 403, { error: 'license_revoked' }); data.activations ??= {}; const list = data.activations[pk.sub] ?? []; @@ -108,7 +126,8 @@ export function createServer(options = {}) { 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 revokedSubsList = revokedSubs(data); + const revokedKeysList = revokedKeys(data); const activations = data.activations ?? {}; const licenses = (data.productKeys ?? []).map((pk) => { const devices = activations[pk.sub] ?? []; @@ -118,7 +137,7 @@ export function createServer(options = {}) { pid: pk.pid, maxDevices: pk.maxDevices, expiresAtSec: pk.expiresAtSec, - revoked: revokedSubs.includes(pk.sub), + revoked: revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub), activatedDevices: devices, activatedCount: devices.length, }; @@ -153,11 +172,19 @@ 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 key = body.key; const sub = body.sub; - if (!sub) return json(res, 400, { error: 'missing_sub' }); + if (!key && !sub) return json(res, 400, { error: 'missing_key_or_sub' }); const data = readData(); - data.revokedSubs ??= []; - if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub); + if (key) { + const pk = data.productKeys?.find((x) => x.key === key); + if (!pk) return json(res, 404, { error: 'unknown_product_key' }); + data.revokedKeys ??= []; + if (!data.revokedKeys.includes(key)) data.revokedKeys.push(key); + } else { + data.revokedSubs ??= []; + if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub); + } writeData(data); return json(res, 200, { ok: true }); } diff --git a/test/adminApi.test.mjs b/test/adminApi.test.mjs index c82109e..1968426 100644 --- a/test/adminApi.test.mjs +++ b/test/adminApi.test.mjs @@ -113,6 +113,54 @@ void test('POST /v1/admin/product-keys creates a new product key', async () => { } }); +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'); + fs.writeFileSync( + dataPath, + JSON.stringify({ + productKeys: [ + { + key: 'DND-SHARED-SUB', + sub: 'lic_shared', + pid: 'dnd_player', + maxDevices: 2, + expiresAtSec: 1893456000, + }, + { + key: 'TTRPG-SHARED-SUB', + sub: 'lic_shared', + pid: 'dnd_player', + maxDevices: 2, + expiresAtSec: 1893456000, + }, + ], + revokedSubs: [], + revokedKeys: [], + activations: {}, + }), + ); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const revoke = await request(port, 'POST', '/v1/admin/revoke', { + token: adminToken, + body: { key: 'DND-SHARED-SUB' }, + }); + assert.equal(revoke.status, 200); + + const list = await request(port, 'GET', '/v1/admin/licenses', { token: adminToken }); + assert.equal(list.status, 200); + const byKey = Object.fromEntries(list.body.licenses.map((x) => [x.key, x.revoked])); + assert.equal(byKey['DND-SHARED-SUB'], true); + assert.equal(byKey['TTRPG-SHARED-SUB'], false); + } 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');