feat(license): Kundencenter-Lizenzierung gegen licensing.flessinglabs.com (#116)

Milestone "Kundencenter lizenzierbar machen": die Installation prüft jetzt
ihre eigene Lizenz beim Lizenzsystem (licensing.flessinglabs.com, Programm
"Kundencenter") und setzt die Kundengrenze serverseitig durch - nicht zu
verwechseln mit dem bestehenden "licensing"-Connector, der KUNDEN-Lizenzen
verwaltet, die das Kundencenter weiterverkauft.

- Migration 035: license_state (ein Lizenzschlüssel, verschlüsselt wie
  andere Secrets, stabile instance_id, zuletzt geprüfter Entitlement-
  Snapshot), license_signing_keys (öffentliche Signaturschlüssel, werden
  nur ergänzt, nie anhand einer Serverantwort gelöscht).
- @kc/platform/license: checkLicenseNow() ruft POST /license/verify mit
  X-Program-Key auf, prüft die FLS1-Signatur (RSA-2048, PKCS#1 v1.5,
  SHA-256) und schreibt das Ergebnis in license_state. getEntitlement()
  liefert den nutzbaren Stand inkl. Offline-Fenster (Standard 7 Tage,
  verlängert nie eine tatsächlich abgelaufene Lizenz). assertCustomerCapacity()
  sperrt neue/reaktivierte Kunden transaktional (GET_LOCK gegen den letzten
  freien Platz) bei erreichter Kundengrenze oder ungültiger Lizenz - aber
  NICHT, solange die Lizenzierung noch gar nicht konfiguriert oder noch nie
  geprüft wurde (sonst wäre jede frische Installation von Anfang an blockiert).
- Durchsetzung an allen drei Stellen, die einen neuen Kundenplatz belegen:
  Kundenanlage, Reaktivierung (closed→active) und KeyHelp-Import neuer
  (nicht verknüpfter) Kunden.
- Worker prüft alle 12 h automatisch (zusätzlich zur Prüfung beim Speichern
  eines neuen Schlüssels). API: GET/PUT /admin/license/settings,
  POST /admin/license/check. UI: Einstellungen → Lizenz (Status, Module,
  Kundengrenze, Schlüssel eingeben, manuell prüfen).

Beim Testen einen echten Bug gefunden und behoben: Buffer.from(hex,'hex')
verwirft bei ungerader Hex-Länge stillschweigend die letzte Ziffer (der
Exponent "10001"/65537 wurde fälschlich zu 0x1000) - hätte jede Signatur-
prüfung unbemerkt scheitern lassen. Mit geradem Padding gegen echte
Serverantworten (gültig und manipuliert) verifiziert.

Gegen die echte Produktions-API getestet (Program-Key aus LICENSE_PROGRAM_KEY):
Status/Settings/Check-Endpunkte, Signaturprüfung mit echten Schlüsseln
erfolgreich, Kundenanlage korrekt blockiert bei geprüfter ungültiger Lizenz,
danach wieder sauber in den unkonfigurierten (nicht blockierenden) Zustand
zurückgesetzt, da noch kein echter Lizenzschlüssel für diese Installation
vorliegt. Testsuite 58/59 (eine vorbestehende, unabhängige datumsabhängige
Flakiness in orders.test.ts, auch ohne diese Änderungen reproduzierbar).
Audit-Kette und Backup+Wiederherstellungstest danach weiterhin grün.

Offen: Lizenzschlüssel für diese Installation selbst anlegen (Admin-Panel
licensing.flessinglabs.com) und unter Einstellungen > Lizenz eintragen.
Modul-Gates (billing/domains/provisioning/discord/automation) an den
einzelnen Funktionsbereichen sowie die Lizenzübersicht-Admin-UI (#117)
sind noch zu bauen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 10:48:48 +02:00
parent 3240e6df79
commit d033088223
10 changed files with 340 additions and 1 deletions

View file

@ -0,0 +1 @@
export * from '@kc/platform/license';

View file

@ -5,6 +5,7 @@ import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { assertCustomerCapacity } from '../../core/license.js';
import { canInOrg } from '../../core/policy.js';
import { createInvitedUser, createToken, inviteLink, mailInvite } from '../../core/accounts.js';
import { enqueue } from '../../core/jobs.js';
@ -84,6 +85,8 @@ export const customersModule: KcModule = {
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
const orgId = randomUUID();
const res = await tx(async (c) => {
const cap = await assertCustomerCapacity(c);
if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
const number = await nextCustomerNumber(c);
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
const bp = b.billing;
@ -145,6 +148,10 @@ export const customersModule: KcModule = {
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
await tx(async (c) => {
if (b.status === 'active' && before.status === 'closed') {
const cap = await assertCustomerCapacity(c);
if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
}
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
const keys = Object.keys(patch);
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
@ -221,6 +228,8 @@ export const customersModule: KcModule = {
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
} else {
const cap = await assertCustomerCapacity(cn);
if (!cap.allowed) throw badRequest(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }

View file

@ -13,6 +13,7 @@ import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/index.js';
import { mailModule } from './mail/index.js';
import { discordModule } from './discord/index.js';
import { licenseModule } from './license/index.js';
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule];
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule];

View file

@ -0,0 +1,47 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { checkLicenseNow, getEntitlement, setLicenseKey, LicenseCheckError } from '../../core/license.js';
import type { KcModule } from '../../core/module.js';
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit
* dem "licensing"-Connector (Einstellungen > Verbindungen), der KUNDEN-Lizenzen verwaltet. */
export const licenseModule: KcModule = {
name: 'license',
permissions: {
staff: { admin: ['license.read'], superadmin: ['license.read', 'settings.write'] },
},
register(app: FastifyInstance) {
app.get('/admin/license/status', async (req) => {
requirePermission(req, 'license.read');
const ent = await getEntitlement();
const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1');
return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null };
});
app.put('/admin/license/settings', async (req) => {
const a = requirePermission(req, 'settings.write');
const b = z.object({ licenseKey: z.string().trim().min(8).max(200) }).parse(req.body);
await setLicenseKey(b.licenseKey);
await audit({ actorType: 'user', actorId: a.user.id, action: 'license.settings.update', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { licenseKey: '***' } });
let checked = false; let error: string | undefined;
try { await checkLicenseNow(); checked = true; } catch (e) { error = (e as Error).message; }
return { ok: true, checked, error };
});
app.post('/admin/license/check', async (req) => {
const a = requirePermission(req, 'settings.write');
try {
const r = await checkLicenseNow();
await audit({ actorType: 'user', actorId: a.user.id, action: 'license.check', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { valid: r.valid, plan: r.plan } });
return r;
} catch (e) {
if (e instanceof LicenseCheckError) throw badRequest(e.message, 'LICENSE_CHECK_FAILED');
throw e;
}
});
},
};

View file

@ -14,6 +14,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
{ href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') },
{ href: '/einstellungen/discord', label: 'Discord', show: can('discord.read') },
{ href: '/einstellungen/lizenz', label: 'Lizenz', show: can('license.read') },
].filter((t) => t.show);
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
return (<>

View file

@ -0,0 +1,64 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Status {
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
}
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
export default function LizenzPage() {
const { can } = useSession(); const w = can('settings.write');
const [s, setS] = useState<Status | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<Status>('GET', '/admin/license/status').then(setS).catch((e) => setErr(errMsg(e))), []);
useEffect(() => { void load(); }, [load]);
async function saveKey(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk(''); setBusy(true);
const f = new FormData(e.currentTarget);
try {
const r = await api<{ ok: boolean; checked: boolean; error?: string }>('PUT', '/admin/license/settings', { licenseKey: String(f.get('licenseKey') ?? '').trim() });
setOk(r.checked ? 'Lizenzschlüssel gespeichert und erfolgreich geprüft.' : `Lizenzschlüssel gespeichert, Prüfung fehlgeschlagen: ${r.error}`);
e.currentTarget.reset(); void load();
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
async function checkNow() {
setErr(''); setOk(''); setBusy(true);
try { await api('POST', '/admin/license/check'); setOk('Lizenz geprüft.'); void load(); }
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
return (<>
<h2>Lizenz</h2>
<p className="muted">Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>}
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
<div className="card">
<h3>Status</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
<div><dt className="small muted">Plan</dt><dd>{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}</dd></div>
<div><dt className="small muted">Module</dt><dd>{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}</dd></div>
<div><dt className="small muted">Kundengrenze</dt><dd>{s.customerLimit === null ? 'unbegrenzt' : s.customerLimit}</dd></div>
<div><dt className="small muted">Nutzer-/Slotlimit</dt><dd>{s.userLimit === null ? 'unbegrenzt' : s.userLimit}</dd></div>
<div><dt className="small muted">Gültig bis</dt><dd>{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}</dd></div>
<div><dt className="small muted">Zuletzt geprüft</dt><dd>{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})</dd></div>
</dl>
{w && <button className="btn" disabled={busy || !s.hasKey} onClick={checkNow}>Jetzt prüfen</button>}
</div>
{w && <form className="card" onSubmit={saveKey}>
<h3>Lizenzschlüssel</h3>
<Field id="licenseKey" label="Lizenzschlüssel" hint="vom Lizenzsystem (licensing.flessinglabs.com), z. B. nach einem Plan-Wechsel"><input id="licenseKey" name="licenseKey" placeholder={s.hasKey ? '•••••••• (hinterlegt, zum Ändern neu eingeben)' : ''} /></Field>
<button className="btn primary" type="submit" disabled={busy}>Speichern &amp; prüfen</button>
</form>}
<p className="muted small">Installations-ID: <span className="mono">{s.instanceId}</span> (bleibt stabil, zählt sonst als neues Gerät).</p>
</>);
}

View file

@ -42,6 +42,17 @@ const checkBackup = async () => {
};
setInterval(() => void checkBackup(), 3600_000);
// Eigene Lizenz (licensing.flessinglabs.com): "beim Start prüfen, dann periodisch alle 6-24h" laut
// INTEGRATION.md (Rate-Limit 30/min erlaubt deutlich mehr, aber unnötig). checkLicenseNow() ist ein No-Op-
// Fehlschlag, solange kein Programm-Key/Lizenzschlüssel hinterlegt ist (siehe LicenseCheckError) - dann
// einfach weiter versuchen, keine Alarmierung (Betreiber hat die Lizenzierung schlicht noch nicht eingerichtet).
import { checkLicenseNow, LicenseCheckError } from '@kc/platform/license';
const checkLicense = async () => {
try { await checkLicenseNow(); }
catch (e) { if (!(e instanceof LicenseCheckError)) log(`Lizenzprüfung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense();
// Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default
// spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch,
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.