From f19b50b2ad1d55e0ff31b629fc568138bebfa989 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 23 Jul 2026 10:39:46 +0800 Subject: [PATCH] feat(activate): allow retireDeviceId to free a device slot on migration Supports client migration from per-user UUID to machine fingerprint without consuming an extra maxDevices slot. Co-authored-by: Cursor --- README.md | 2 +- src/server.mjs | 17 ++++++++++++---- test/activate.test.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c4f6770..9e5308d 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ npm test ## API -- `POST /v1/activate` — `{ "productKey": "...", "deviceId": "..." }` → `{ token, sub }`. +- `POST /v1/activate` — `{ "productKey": "...", "deviceId": "...", "retireDeviceId?": "..." }` → `{ token, sub }`. `retireDeviceId` (опционально) удаляет старый слот устройства при миграции (не занимает лишний `maxDevices`). - `GET /v1/status?sub=...` → `{ revoked: boolean }`. - `POST /v1/track/download` — `{ "platform": "windows"|"macos"|"linux" }` → учёт скачиваний в `data.json` → `downloadStats.byMonth`. - `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "key": "TTRPG-..." }` или `{ "sub": "..." }`. diff --git a/src/server.mjs b/src/server.mjs index 8e3cf29..1a14cd6 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -175,6 +175,8 @@ export function createServer(options = {}) { const body = JSON.parse(raw || '{}'); const productKey = body.productKey; const deviceId = body.deviceId; + const retireDeviceId = + typeof body.retireDeviceId === 'string' ? body.retireDeviceId.trim() : ''; if (!productKey || !deviceId) return json(res, 400, { error: 'productKey_and_deviceId_required' }); const data = readData(); @@ -184,8 +186,13 @@ export function createServer(options = {}) { data.activations ??= {}; const list = data.activations[pk.sub] ?? []; - const already = list.includes(deviceId); - if (!already && list.length >= pk.maxDevices) { + // Миграция со старого per-user UUID: освобождаем слот retireDeviceId перед проверкой лимита. + let next = [...list]; + if (retireDeviceId && retireDeviceId !== deviceId) { + next = next.filter((d) => d !== retireDeviceId); + } + const already = next.includes(deviceId); + if (!already && next.length >= pk.maxDevices) { return json(res, 403, { error: 'too_many_devices' }); } const now = Math.floor(Date.now() / 1000); @@ -199,8 +206,10 @@ export function createServer(options = {}) { } if (!already) { - list.push(deviceId); - data.activations[pk.sub] = list; + next.push(deviceId); + } + if (next.length !== list.length || next.some((d, i) => d !== list[i])) { + data.activations[pk.sub] = next; dataChanged = true; } diff --git a/test/activate.test.mjs b/test/activate.test.mjs index 0833980..981359b 100644 --- a/test/activate.test.mjs +++ b/test/activate.test.mjs @@ -162,6 +162,50 @@ void test('POST /v1/activate period key re-activation keeps same exp', async () } }); +void test('POST /v1/activate retireDeviceId освобождает слот при миграции', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + writeData( + dataPath, + [ + { + key: 'TTRPG-MIGRATE', + sub: 'lic_migrate', + pid: 'dnd_player', + maxDevices: 1, + expiresAtSec: 1893456000, + }, + ], + { lic_migrate: ['legacy-user-uuid'] }, + ); + + const server = await makeServer(dataPath); + const port = await listen(server); + try { + const blocked = await request(port, 'POST', '/v1/activate', { + body: { productKey: 'TTRPG-MIGRATE', deviceId: 'machine-fp-1' }, + }); + assert.equal(blocked.status, 403); + assert.equal(blocked.body.error, 'too_many_devices'); + + const res = await request(port, 'POST', '/v1/activate', { + body: { + productKey: 'TTRPG-MIGRATE', + deviceId: 'machine-fp-1', + retireDeviceId: 'legacy-user-uuid', + }, + }); + assert.equal(res.status, 200); + assert.equal(tokenPayload(res.body.token).did, 'machine-fp-1'); + + const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + assert.deepEqual(data.activations.lic_migrate, ['machine-fp-1']); + } 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');