diff --git a/license-backend/README.md b/license-backend/README.md index 21c9948..9f06332 100644 --- a/license-backend/README.md +++ b/license-backend/README.md @@ -74,6 +74,7 @@ Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime). |---|---|---| | POST | `/api/v1/licenses` | generate a key | | GET | `/api/v1/licenses/:key` | inspect (status + bound domains) | +| PATCH | `/api/v1/licenses/:key` | modify (seat limit, email, note, expiry, status) | | POST | `/api/v1/licenses/:key/disable` | revoke | | POST | `/api/v1/licenses/:key/enable` | un-revoke | | GET | `/api/v1/products` | list products | diff --git a/license-backend/src/server.js b/license-backend/src/server.js index 53929c7..c77b19c 100644 --- a/license-backend/src/server.js +++ b/license-backend/src/server.js @@ -594,6 +594,49 @@ app.get('/api/v1/licenses/:key', adminOnly, (req, res) => { }); }); +// Modify a license (e.g. change the seat limit). Partial update; only the +// provided fields are changed. Body (JSON), any of: +// max_activations (>=1 or -1), email, note, expires_at (ISO|null), status (active|disabled) +app.patch('/api/v1/licenses/:key', adminOnly, (req, res) => { + const license = Q.licenseByKey.get(req.params.key); + if (!license) return fail(res, 404, 'not found'); + + const fields = {}; + + if ('max_activations' in req.body) { + const max = Number(req.body.max_activations); + if (!Number.isInteger(max) || (max < 1 && max !== -1)) { + return fail(res, 400, 'max_activations must be a positive integer or -1 (unlimited)'); + } + fields.max_activations = max; + } + if ('email' in req.body) fields.email = req.body.email ? String(req.body.email).trim() : null; + if ('note' in req.body) fields.note = req.body.note ? String(req.body.note).trim() : null; + if ('expires_at' in req.body) fields.expires_at = req.body.expires_at ? String(req.body.expires_at).trim() : null; + if ('status' in req.body) { + const st = String(req.body.status); + if (!['active', 'disabled'].includes(st)) return fail(res, 400, 'status must be active or disabled'); + fields.status = st; + } + + const cols = Object.keys(fields); // fixed allow-list above → safe to interpolate + if (!cols.length) return fail(res, 400, 'no updatable fields provided'); + + const setClause = cols.map((c) => `${c} = @${c}`).join(', '); + db.prepare(`UPDATE licenses SET ${setClause} WHERE id = @id`).run({ ...fields, id: license.id }); + + const u = Q.licenseByKey.get(req.params.key); + return res.json({ + ok: true, + key: u.key, + max_activations: u.max_activations, + status: u.status, + email: u.email, + note: u.note, + expires_at: u.expires_at, + }); +}); + // Disable a license (revokes it everywhere on next check). app.post('/api/v1/licenses/:key/disable', adminOnly, (req, res) => { const license = Q.licenseByKey.get(req.params.key); diff --git a/license-backend/test/integration.mjs b/license-backend/test/integration.mjs index 9b835d1..47275d0 100644 --- a/license-backend/test/integration.mjs +++ b/license-backend/test/integration.mjs @@ -215,6 +215,19 @@ try { const fuLocal = await admin('POST', '/api/v1/releases/from-url', { product: P, version: '1.2.0', zip_url: 'http://127.0.0.1/a.zip' }); ok('from-url private host → 400', fuLocal.status === 400, JSON.stringify(fuLocal.json)); + // ── Modify a license (PATCH) ── + // key1 is max=1 (1 domain bound). Switch it to unlimited and prove it works. + const patch = await admin('PATCH', `/api/v1/licenses/${key1}`, { max_activations: -1 }); + ok('patch to unlimited', patch.status === 200 && patch.json.max_activations === -1, JSON.stringify(patch.json)); + const extra = await pub('/api/v1/activate', { key: key1, product: P, domain: 'extra.de' }); + ok('after unlimited, extra domain activates', extra.json.status === 'valid', JSON.stringify(extra.json)); + + const patchBad = await admin('PATCH', `/api/v1/licenses/${key1}`, { max_activations: 0 }); + ok('patch bad value → 400', patchBad.status === 400, JSON.stringify(patchBad.json)); + + const patchUnknown = await admin('PATCH', '/api/v1/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ', { max_activations: 3 }); + ok('patch unknown key → 404', patchUnknown.status === 404, JSON.stringify(patchUnknown.json)); + } catch (e) { console.error(e); fail++;