feat: lizenz aendern - PATCH /api/v1/licenses/:key

- Aendert max_activations (z.B. -1 unbegrenzt), email, note, expires_at, status.
- Partielles Update, validiert; nur erlaubte Felder. Tests + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-07 15:53:31 +02:00
parent e691b675cd
commit b03f18a794
3 changed files with 57 additions and 0 deletions

View File

@@ -74,6 +74,7 @@ Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime).
|---|---|---| |---|---|---|
| POST | `/api/v1/licenses` | generate a key | | POST | `/api/v1/licenses` | generate a key |
| GET | `/api/v1/licenses/:key` | inspect (status + bound domains) | | 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/disable` | revoke |
| POST | `/api/v1/licenses/:key/enable` | un-revoke | | POST | `/api/v1/licenses/:key/enable` | un-revoke |
| GET | `/api/v1/products` | list products | | GET | `/api/v1/products` | list products |

View File

@@ -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). // Disable a license (revokes it everywhere on next check).
app.post('/api/v1/licenses/:key/disable', adminOnly, (req, res) => { app.post('/api/v1/licenses/:key/disable', adminOnly, (req, res) => {
const license = Q.licenseByKey.get(req.params.key); const license = Q.licenseByKey.get(req.params.key);

View File

@@ -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' }); 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)); 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) { } catch (e) {
console.error(e); console.error(e);
fail++; fail++;