kundencenter/packages/platform/src/license.ts

203 lines
14 KiB
TypeScript
Raw Normal View History

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>
2026-10-01 10:48:48 +02:00
import { createPublicKey, createVerify } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { config } from './config.js';
import { one, query, run } from './db.js';
import { encrypt, decrypt } from './crypto.js';
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com (Meilenstein
* "Kundencenter lizenzierbar machen"). Nicht zu verwechseln mit dem "licensing"-Connector, der
* KUNDEN-Lizenzen verwaltet, die das Kundencenter weiterverkauft. */
const OFFLINE_GRACE_DAYS = Number(process.env.LICENSE_OFFLINE_GRACE_DAYS ?? 7);
export interface VerifyResult {
valid: boolean; message: string | null; plan: string | null; modules: string[];
expiresAt: string | null; userLimit: number | null; customerLimit: number | null;
entitlementVersion: number | null; trial: boolean; addons: unknown[]; serverTime: string | null;
signature: string | null; keyId: string | null;
}
interface SigningKey { kid: string; alg: string; n: string; e: string; status: string }
// Buffer.from(hex, 'hex') verwirft bei ungerader Länge stillschweigend die letzte Ziffer (z. B. würde der
// Exponent "10001" = 65537 fälschlich zu 0x1000 statt 0x010001) - deshalb vorher auf gerade Länge auffüllen.
const hexToB64url = (hex: string): string => Buffer.from(hex.length % 2 ? `0${hex}` : hex, 'hex').toString('base64url');
/** FLS1.<base64url(Payload)>.<base64url(Signatur)>, RSA-2048 PKCS#1 v1.5 SHA-256 über die ASCII-Bytes
* des Payload-Teils (siehe licensing INTEGRATION.md, Abschnitt "Signierte Antworten"). */
function verifyFls1(token: string, keys: SigningKey[]): { ok: boolean; payload?: Record<string, unknown> } {
const parts = token.split('.');
if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false };
const [, payloadB64, sigB64] = parts;
let payload: Record<string, unknown>;
try { payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString('utf8')); } catch { return { ok: false }; }
const key = keys.find((k) => k.kid === payload.kid);
if (!key) return { ok: false };
try {
const pub = createPublicKey({ key: { kty: 'RSA', n: hexToB64url(key.n), e: hexToB64url(key.e) }, format: 'jwk' });
const v = createVerify('RSA-SHA256'); v.update(payloadB64!, 'ascii'); v.end();
return { ok: v.verify(pub, Buffer.from(sigB64!, 'base64url')), payload };
} catch { return { ok: false }; }
}
async function loadSigningKeys(): Promise<SigningKey[]> {
const rows = await query('SELECT kid, alg, n_hex, e_hex, status FROM license_signing_keys');
return rows.map((r) => ({ kid: r.kid, alg: r.alg, n: r.n_hex, e: r.e_hex, status: r.status }));
}
/** Schlüssel nur ERGÄNZEN, nie anhand der Serverantwort löschen (sonst könnte ein vorgetäuschter
* Server die Liste auf nur noch seine eigenen Schlüssel "bereinigen"). */
async function refreshSigningKeys(): Promise<void> {
const r = await fetch(`${config.licenseApiBaseUrl}/license/signing-keys`, { signal: AbortSignal.timeout(10000) });
if (!r.ok) return;
const data = await r.json() as { format?: string; keys?: { kid: string; alg: string; n: string; e: string; status: string }[] };
if (data.format !== 'FLS1' || !Array.isArray(data.keys)) return;
for (const k of data.keys) await run('INSERT INTO license_signing_keys (kid, alg, n_hex, e_hex, status) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE alg=VALUES(alg), n_hex=VALUES(n_hex), e_hex=VALUES(e_hex), status=VALUES(status)', [k.kid, k.alg, k.n, k.e, k.status]);
}
export class LicenseCheckError extends Error {}
/** Fragt /license/verify live ab, prüft die Signatur und schreibt das Ergebnis in license_state.
* Wirft bei fehlender Konfiguration oder Netzwerkfehler (ruft NICHT selbst den gecachten Zustand ab -
* das macht getEntitlement()). */
export async function checkLicenseNow(): Promise<VerifyResult> {
if (!config.licenseProgramKey) throw new LicenseCheckError('LICENSE_PROGRAM_KEY ist nicht konfiguriert');
const row = await one('SELECT license_key_enc, instance_id FROM license_state WHERE id = 1');
if (!row) throw new LicenseCheckError('license_state fehlt (Migration nicht angewendet?)');
if (!row.license_key_enc) throw new LicenseCheckError('Kein Lizenzschlüssel hinterlegt (Einstellungen > Lizenz)');
const licenseKey = decrypt(row.license_key_enc);
await run('UPDATE license_state SET last_attempt_at = UTC_TIMESTAMP(3) WHERE id = 1');
let res: Response;
try {
res = await fetch(`${config.licenseApiBaseUrl}/license/verify`, {
method: 'POST', headers: { 'content-type': 'application/json', 'X-Program-Key': config.licenseProgramKey },
body: JSON.stringify({ licenseKey, instanceId: row.instance_id }), signal: AbortSignal.timeout(15000),
});
} catch (e) {
await run('UPDATE license_state SET last_error = ? WHERE id = 1', [`Netzwerkfehler: ${(e as Error).message}`.slice(0, 500)]);
throw new LicenseCheckError(`Lizenzserver nicht erreichbar: ${(e as Error).message}`);
}
if (res.status === 401) { await run('UPDATE license_state SET last_error = ? WHERE id = 1', ['Program-Key ungültig (LICENSE_PROGRAM_KEY)']); throw new LicenseCheckError('Program-Key ungültig'); }
if (!res.ok) { const msg = `Lizenzserver: HTTP ${res.status}`; await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]); throw new LicenseCheckError(msg); }
const data = await res.json() as VerifyResult;
if (data.signature) {
let keys = await loadSigningKeys();
let v = verifyFls1(data.signature, keys);
if (!v.ok) { await refreshSigningKeys().catch(() => undefined); keys = await loadSigningKeys(); v = verifyFls1(data.signature, keys); } // evtl. rotierter, noch unbekannter Schlüssel
if (!v.ok || v.payload?.licenseKey !== licenseKey) {
const msg = 'Signaturprüfung der Lizenzantwort fehlgeschlagen';
await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]);
throw new LicenseCheckError(msg);
}
}
await run(
`UPDATE license_state SET valid=?, message=?, plan=?, modules_json=?, expires_at=?, user_limit=?, customer_limit=?,
entitlement_version=?, is_trial=?, addons_json=?, server_time=?, checked_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id=1`,
[data.valid, data.message ?? null, data.plan ?? null, JSON.stringify(data.modules ?? []), data.expiresAt ? new Date(data.expiresAt) : null,
data.userLimit ?? null, data.customerLimit ?? null, data.entitlementVersion ?? null, !!data.trial, JSON.stringify(data.addons ?? []),
data.serverTime ? new Date(data.serverTime) : null],
);
return data;
}
export interface Entitlement {
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
trial: boolean; expiresAt: Date | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: Date | null; message: string | null;
}
/** Liefert den aktuell nutzbaren Entitlement-Stand: zuletzt geprüfter (signaturgeprüfter) Snapshot, solange
* er innerhalb des Offline-Fensters liegt (min. Ablaufdatum der Lizenz, min. eigenes Offline-Fenster ab der
* letzten erfolgreichen Prüfung) - verlängert nie eine tatsächlich abgelaufene Lizenz. Ruft NICHT selbst
* die Live-API auf (das übernimmt der Worker periodisch bzw. checkLicenseNow() explizit). */
export async function getEntitlement(): Promise<Entitlement> {
if (!config.licenseProgramKey) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'LICENSE_PROGRAM_KEY nicht konfiguriert' };
const row = await one('SELECT * FROM license_state WHERE id = 1');
if (!row || !row.license_key_enc) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'Kein Lizenzschlüssel hinterlegt' };
if (!row.checked_at) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: true, source: 'unchecked', checkedAt: null, message: 'Noch nicht geprüft' };
const checkedAt = new Date(row.checked_at as Date);
const graceUntil = new Date(checkedAt.getTime() + OFFLINE_GRACE_DAYS * 86400000);
const expiresAt = row.expires_at ? new Date(row.expires_at as Date) : null;
const now = new Date();
const withinOfflineGrace = now <= graceUntil && (expiresAt === null || now <= expiresAt);
const modules = (typeof row.modules_json === 'string' ? JSON.parse(row.modules_json) : row.modules_json) ?? [];
return {
valid: !!row.valid && withinOfflineGrace, plan: row.plan, modules, customerLimit: row.customer_limit, userLimit: row.user_limit,
trial: !!row.is_trial, expiresAt, configured: true, source: withinOfflineGrace ? 'live' : 'offline-grace', checkedAt, message: row.message,
};
}
// ---- Edition: Testmodus ohne Lizenz, voller Umfang mit Lizenz ---------------------------------------------------
/** Funktionen, die eine Lizenz voraussetzen. Modul-Schlüssel im Lizenzsystem (Produkt/Lizenz "modules"). */
export const LICENSED_FEATURES = ['discord', 'imap', 'datev', 'backup_remote'] as const;
export type LicensedFeature = (typeof LICENSED_FEATURES)[number];
export const FEATURE_LABEL: Record<LicensedFeature, string> = { discord: 'Discord-Bot', imap: 'E-Mail-Posteingang (IMAP)', datev: 'DATEV-Export', backup_remote: 'Backups auf externe Ziele' };
/** Grenzen des Testmodus (ohne gültige Lizenz). Bestehende Daten bleiben unangetastet, nur Neuanlagen werden begrenzt. */
export const TRIAL_LIMITS = { staff: 1, customers: 2 } as const;
export interface Edition {
mode: 'licensed' | 'trial'; features: LicensedFeature[]; customerLimit: number | null; staffLimit: number | null;
/** Warum Testmodus (z. B. kein Schlüssel, abgelaufen); bei Lizenz der Plan. */ reason: string | null;
}
/** Maßgebliche Edition dieser Installation. Gültige Lizenz: deren Grenzen; eine Lizenz ohne Modulliste schaltet alle
* Funktionen frei (unbeschränkter Plan). Ohne gültige Lizenz (kein Schlüssel, ungeprüft, ungültig, abgelaufen oder
* länger als die Offline-Frist nicht geprüft): Testmodus mit TRIAL_LIMITS und ohne LICENSED_FEATURES. */
export async function getEdition(): Promise<Edition> {
// Integrationstests: voller Umfang (wie die abgeschalteten Rate-Limits im Testmodus)
if (config.env === 'test') return { mode: 'licensed', features: [...LICENSED_FEATURES], customerLimit: null, staffLimit: null, reason: 'test' };
const e = await getEntitlement();
if (e.valid) {
const mods = e.modules.filter((m): m is LicensedFeature => (LICENSED_FEATURES as readonly string[]).includes(m));
return { mode: 'licensed', features: e.modules.length ? mods : [...LICENSED_FEATURES], customerLimit: e.customerLimit, staffLimit: e.userLimit, reason: e.plan };
}
const reason = e.source === 'unconfigured' ? 'Kein Lizenzschlüssel hinterlegt' : e.source === 'unchecked' ? 'Lizenz noch nicht geprüft' : (e.message ?? 'Lizenz ungültig oder abgelaufen');
return { mode: 'trial', features: [], customerLimit: TRIAL_LIMITS.customers, staffLimit: TRIAL_LIMITS.staff, reason };
}
export async function hasFeature(f: LicensedFeature): Promise<boolean> { return (await getEdition()).features.includes(f); }
/** Fehlertext für gesperrte Funktionen (einheitlich in API und Worker). */
export const featureRequiresLicense = (f: LicensedFeature) => `${FEATURE_LABEL[f]} ist nur mit Lizenz verfügbar (Einstellungen → Lizenz).`;
/** Modul aus der Lizenz (allgemein, z. B. für künftige Zusatzmodule). */
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>
2026-10-01 10:48:48 +02:00
export async function hasModule(key: string): Promise<boolean> {
const e = await getEntitlement();
return e.valid && (e.modules.length === 0 || e.modules.includes(key));
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>
2026-10-01 10:48:48 +02:00
}
export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null }
/** Transaktionale Kundengrenze: in DERSELBEN DB-Verbindung/Transaktion wie die Kundenanlage/-reaktivierung
* aufrufen. GET_LOCK serialisiert gegen gleichzeitige Anfragen auf den letzten freien Platz. Zählt aktive
* und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr. Grenze aus der Edition
* (Lizenz oder Testmodus). */
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>
2026-10-01 10:48:48 +02:00
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
const ed = await getEdition();
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>
2026-10-01 10:48:48 +02:00
await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']);
try {
const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c);
const count = Number(row?.n ?? 0);
if (ed.customerLimit !== null && count >= ed.customerLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.customerLimit} Kunden. Für weitere Kunden bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Kundengrenze der Lizenz erreicht (${count}/${ed.customerLimit}). Bitte Lizenz upgraden.`;
return { allowed: false, reason, count, limit: ed.customerLimit };
}
return { allowed: true, count, limit: ed.customerLimit };
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>
2026-10-01 10:48:48 +02:00
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']);
}
}
/** Grenze für Personal-Konten (aktive und eingeladene Mitarbeiter). */
export async function assertStaffCapacity(): Promise<CapacityCheck> {
const ed = await getEdition();
const row = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
const count = Number(row?.n ?? 0);
if (ed.staffLimit !== null && count >= ed.staffLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.staffLimit} Personal-Konto. Für weitere Mitarbeiter bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Benutzergrenze der Lizenz erreicht (${count}/${ed.staffLimit}).`;
return { allowed: false, reason, count, limit: ed.staffLimit };
}
return { allowed: true, count, limit: ed.staffLimit };
}
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>
2026-10-01 10:48:48 +02:00
export async function setLicenseKey(licenseKey: string): Promise<void> {
await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]);
}