diff --git a/apps/api/src/core/license.ts b/apps/api/src/core/license.ts new file mode 100644 index 0000000..be2d85f --- /dev/null +++ b/apps/api/src/core/license.ts @@ -0,0 +1 @@ +export * from '@kc/platform/license'; diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts index e4db92e..b953353 100644 --- a/apps/api/src/modules/customers/index.ts +++ b/apps/api/src/modules/customers/index.ts @@ -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 */ } diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 06db281..fb61559 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -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]; diff --git a/apps/api/src/modules/license/index.ts b/apps/api/src/modules/license/index.ts new file mode 100644 index 0000000..b2d13c1 --- /dev/null +++ b/apps/api/src/modules/license/index.ts @@ -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; + } + }); + }, +}; diff --git a/apps/web/src/app/(app)/einstellungen/layout.tsx b/apps/web/src/app/(app)/einstellungen/layout.tsx index d12f32c..e81c34e 100644 --- a/apps/web/src/app/(app)/einstellungen/layout.tsx +++ b/apps/web/src/app/(app)/einstellungen/layout.tsx @@ -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 Keine Berechtigung.; return (<> diff --git a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx new file mode 100644 index 0000000..d6874c5 --- /dev/null +++ b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx @@ -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 = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' }; +const MODULE_LABEL: Record = { 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(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('GET', '/admin/license/status').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function saveKey(e: FormEvent) { + 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 ? {err} :

Wird geladen …

; + return (<> +

Lizenz

+

Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).

+ {err && {err}}{ok && {ok}} + {!s.configured && Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.} + {s.configured && s.source === 'unchecked' && Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.} + {s.configured && s.source !== 'unchecked' && !s.valid && Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.} + +
+

Status

+
+
Plan
{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}
+
Module
{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}
+
Kundengrenze
{s.customerLimit === null ? 'unbegrenzt' : s.customerLimit}
+
Nutzer-/Slotlimit
{s.userLimit === null ? 'unbegrenzt' : s.userLimit}
+
Gültig bis
{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}
+
Zuletzt geprüft
{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})
+
+ {w && } +
+ + {w &&
+

Lizenzschlüssel

+ + +
} +

Installations-ID: {s.instanceId} (bleibt stabil, zählt sonst als neues Gerät).

+ ); +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 395c6b8..2aa3820 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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. diff --git a/migrations/035_license_state.sql b/migrations/035_license_state.sql new file mode 100644 index 0000000..1f06b6f --- /dev/null +++ b/migrations/035_license_state.sql @@ -0,0 +1,37 @@ +-- Kundencenter-Lizenzierung (Meilenstein #113-#117): eigenes Lizenzsystem-Konto (licensing.flessinglabs.com), +-- nicht zu verwechseln mit dem bestehenden "licensing"-Connector (der verwaltet KUNDEN-Lizenzen, die das +-- Kundencenter weiterverkauft). Dies hier ist die Lizenz der Kundencenter-Installation selbst. +CREATE TABLE license_state ( + id TINYINT PRIMARY KEY DEFAULT 1, + license_key_enc TEXT NULL, -- verschlüsselt (wie andere Secrets), vom Betreiber selbst eingetragen + instance_id CHAR(36) NOT NULL, -- einmal erzeugt, bleibt stabil (zählt sonst als neues Gerät) + -- zuletzt erfolgreich geprüfter (und signaturgeprüfter) Entitlement-Snapshot + valid BOOLEAN NULL, + message VARCHAR(300) NULL, + plan VARCHAR(100) NULL, + modules_json JSON NULL, + expires_at DATETIME NULL, + user_limit INT NULL, + customer_limit INT NULL, + entitlement_version INT NULL, + is_trial BOOLEAN NOT NULL DEFAULT FALSE, + addons_json JSON NULL, + server_time DATETIME NULL, + checked_at DATETIME(3) NULL, -- Zeitpunkt der letzten ERFOLGREICHEN Online-Prüfung (Basis fürs Offline-Fenster) + last_attempt_at DATETIME(3) NULL, + last_error VARCHAR(500) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +); +INSERT INTO license_state (id, instance_id) VALUES (1, UUID()); + +-- Öffentliche Signaturschlüssel (GET /license/signing-keys), lokal zwischengespeichert statt bei jeder Prüfung +-- abgerufen - sonst könnte ein kompromittierter/vorgetäuschter Server zu einer gefälschten Antwort auch gleich +-- einen passenden Schlüssel mitliefern. Werden nur ergänzt, nie anhand der Liste selbst gelöscht. +CREATE TABLE license_signing_keys ( + kid VARCHAR(100) PRIMARY KEY, + alg VARCHAR(20) NOT NULL, + n_hex TEXT NOT NULL, + e_hex VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + fetched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +); diff --git a/packages/platform/src/config.ts b/packages/platform/src/config.ts index 935f186..ca54f85 100644 --- a/packages/platform/src/config.ts +++ b/packages/platform/src/config.ts @@ -27,6 +27,11 @@ export const config = { /** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */ allowedOrigins: new Set([process.env.KC_BASE_URL ?? 'http://localhost:4101', ...(process.env.KC_ALLOWED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean)]), secretKey: Buffer.from(req('KC_SECRET_KEY'), 'base64'), + // Identifiziert das Programm "Kundencenter" selbst gegenüber licensing.flessinglabs.com (kein echtes + // Geheimnis, steckt in jeder Installation gleich). Der je Installation unterschiedliche Lizenzschlüssel + // liegt dagegen verschlüsselt in der DB (Einstellungen > Lizenz), nicht hier. + licenseProgramKey: process.env.LICENSE_PROGRAM_KEY || null, + licenseApiBaseUrl: process.env.LICENSE_API_BASE_URL || 'https://licensing.flessinglabs.com/api', db: { host: process.env.DB_HOST ?? '127.0.0.1', port: Number(process.env.DB_PORT ?? 3306), diff --git a/packages/platform/src/license.ts b/packages/platform/src/license.ts new file mode 100644 index 0000000..a52ffc7 --- /dev/null +++ b/packages/platform/src/license.ts @@ -0,0 +1,163 @@ +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.., 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 } { + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false }; + const [, payloadB64, sigB64] = parts; + let payload: Record; + 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 { + 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 { + 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 { + 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 { + 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, + }; +} +/** Wie assertCustomerCapacity: solange Lizenzierung nicht konfiguriert/geprüft ist, nichts einschränken. */ +export async function hasModule(key: string): Promise { + const e = await getEntitlement(); + if (e.source === 'unconfigured' || e.source === 'unchecked') return true; + return e.valid && e.modules.includes(key); +} + +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. + * + * Solange die Lizenzierung gar nicht konfiguriert ist (kein LICENSE_PROGRAM_KEY/-schlüssel) oder noch nie + * erfolgreich geprüft wurde, wird NICHT durchgesetzt - sonst wäre jede frische Installation (und jede Test- + * umgebung) ab dem ersten Kunden blockiert. Erst eine tatsächlich geprüfte, ungültige/überschrittene Lizenz + * sperrt neue Kunden. */ +export async function assertCustomerCapacity(c: PoolConnection): Promise { + const ent = await getEntitlement(); + if (ent.source === 'unconfigured' || ent.source === 'unchecked') return { allowed: true, count: 0, limit: null }; + if (!ent.valid) return { allowed: false, reason: ent.message ?? 'Keine gültige Lizenz', count: 0, limit: null }; + 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 (ent.customerLimit !== null && count >= ent.customerLimit) return { allowed: false, reason: `Kundengrenze erreicht (${count}/${ent.customerLimit}). Bitte Lizenz upgraden.`, count, limit: ent.customerLimit }; + return { allowed: true, count, limit: ent.customerLimit }; + } finally { + await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']); + } +} + +export async function setLicenseKey(licenseKey: string): Promise { + await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]); +}