Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,22 @@
{
"name": "@kc/connector-keyhelp",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@kc/connector-sdk": "workspace:*",
"undici": "^8.11.2"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,300 @@
import { createHash } from 'node:crypto';
import { Agent, fetch as undiciFetch } from 'undici';
import {
ConnectorError, CONTRACT_VERSION, httpJson, maskKey,
type ActionName, type Capability, type CatalogItem, type ChildAccess, type ChildKind, type Connector, type ImportableCustomer, type NewCatalogSpec, type ConnectorContext, type HttpOptions, type NormalizedChild, type NormalizedResource, type ProvisionContext,
} from '@kc/connector-sdk';
/**
* KeyHelp-Connector auf Basis der offiziellen OpenAPI-Definition "KeyHelp RESTful API 2.15" (docs/api-specs/).
* Provider-Rohmodelle bleiben in dieser Datei. Einheiten von Speicher/Traffic sind in der Definition nicht angegeben:
* angenommen wird Byte (an der Instanz zu prüfen).
*/
interface RawClient {
id: number; status?: number; username: string; email?: string; language?: string; notes?: string; id_hosting_plan?: number; created_at?: string; document_root?: string;
is_suspended?: boolean; suspend_on?: string | null; delete_on?: string | null; contact_data?: { company?: string; first_name?: string; last_name?: string };
permissions?: Record<string, boolean>; resource_limits?: Record<string, number>;
}
type Stat = { value: number; max: number };
interface RawPlan { id: number; name: string; resources?: Record<string, number>; permissions?: Record<string, boolean> }
type Row = Record<string, any>;
export interface KeyHelpDeps extends HttpOptions { now?: () => Date }
const STATUS_LABEL: Record<number, string> = { 0: 'unbekannt', 1: 'ok', 2: 'Fehler', 3: 'Konfiguration neu', 4: 'Konfiguration wird aktualisiert' };
const PERM_LABEL: Record<string, string> = { ftp: 'FTP', php: 'PHP', ssh: 'SSH', backup: 'Backup', panel_access: 'Panel-Zugang', file_manager: 'Dateimanager', dns_editor: 'DNS-Editor', certificate_management: 'Zertifikate', domain_email: 'E-Mail-Domains', applications: 'Anwendungen' };
const RES_LABEL: Record<string, string> = { disk_space: 'Speicher', traffic: 'Traffic', domains: 'Domains', subdomains: 'Subdomains', email_accounts: 'Postfächer', email_addresses: 'E-Mail-Adressen', databases: 'Datenbanken', ftp_users: 'FTP-Benutzer', scheduled_tasks: 'Cronjobs' };
// ---- Eingabeprüfung für Unterobjekte (Kundeneingaben laufen bis zum Panel: strikt prüfen) ----
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/i;
const LOCAL_RE = /^[a-z0-9._+-]{1,64}$/i;
const DBNAME_RE = /^[a-z0-9_]{1,64}$/;
const FTPUSER_RE = /^[a-z0-9._-]{1,32}$/i;
const bad = (m: string) => new ConnectorError('INVALID_INPUT', m);
const str = (v: unknown, name: string, max = 200): string => { if (typeof v !== 'string' || !v.trim() || v.length > max || /[\0\r\n]/.test(v)) throw bad(`${name} fehlt oder ist ungültig`); return v.trim(); };
const optStr = (v: unknown, name: string, max = 200) => (v === undefined || v === null || v === '' ? undefined : str(v, name, max));
const password = (s: Record<string, string> | undefined): string => { const p = s?.password; if (!p || p.length > 128 || /[\0\r\n]/.test(p)) throw bad('Passwort fehlt oder ist ungültig'); return p; };
const homeDir = (v: unknown): string | undefined => { const h = optStr(v, 'Verzeichnis', 300); if (h === undefined) return undefined; if (!/^\/(www|files)(\/|$)/.test(h) || h.split('/').includes('..')) throw bad('Das Verzeichnis muss mit /www/ oder /files beginnen'); return h; };
/** Zusammengehörigkeit prüfen: Der Schlüssel des Panels ist ein Administratorzugang, die Mandantentrennung muss deshalb HIER passieren. */
const owned = (row: Row | null | undefined, clientId: string): Row => { if (!row || String(row.id_user) !== clientId) throw new ConnectorError('NOT_FOUND'); return row; };
/** KeyHelp liefert Zeitstempel ohne Zeitzone: einheitlich als UTC gelesen (Annahme, an der Instanz zu prüfen). */
const iso = (s: string | null | undefined): string | null => { if (!s) return null; const t = /[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.trim().replace(' ', 'T')}Z`; const d = new Date(t); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
const digits = (s: string | undefined) => (s ?? '').replace(/\D/g, '');
export const usernameFor = (c: ProvisionContext | undefined, key: string): string =>
c?.contractNumber && digits(c.contractNumber) ? `kc${digits(c.contractNumber)}` : `kc${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
const noteFor = (c: ProvisionContext) => `Kundencenter ${c.customerNumber} ${c.contractNumber}`;
async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length); let i = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const k = i++; out[k] = await fn(items[k]!); } }));
return out;
}
const fmtRes = (k: string, v: number) => (v < 0 ? 'unbegrenzt' : k === 'disk_space' || k === 'traffic' ? `${Math.round((v / 1024 / 1024 / 1024) * 10) / 10} GB` : String(v));
/** Tarif → Produktvorlage (Limits und Rechte). */
function planItem(p: RawPlan): CatalogItem {
return {
externalRef: `plan:${p.id}`, name: p.name, description: null, category: 'hosting', fixed: true, providerActive: true, program: 'KeyHelp',
features: [...Object.entries(p.resources ?? {}).filter(([k]) => RES_LABEL[k]).map(([k, v]) => `${RES_LABEL[k]}: ${fmtRes(k, v)}`), ...Object.entries(p.permissions ?? {}).filter(([k, v]) => v && PERM_LABEL[k]).map(([k]) => PERM_LABEL[k]!)],
meta: Object.fromEntries(Object.entries(p.resources ?? {}).map(([k, v]) => [k, v])),
provisioning: { hostingPlanId: p.id, language: 'de', createSystemDomain: true, sendLoginCredentials: true },
};
}
export function createKeyHelpConnector(deps: KeyHelpDeps = {}): Connector {
const now = deps.now ?? (() => new Date());
const agents = new Map<string, Agent>();
const base = (ctx: ConnectorContext) => {
const u = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '').replace(/\/api\/v2$/, '');
if (!/^https:\/\//.test(u)) throw new ConnectorError('BAD_CONFIG', 'baseUrl muss mit https:// beginnen');
return u;
};
const apiKey = (ctx: ConnectorContext) => { const k = ctx.secrets.apiKey; if (!k) throw new ConnectorError('BAD_CONFIG', 'API-Schlüssel fehlt');
const bad = [...k].find((c) => c.charCodeAt(0) < 33 || c.charCodeAt(0) > 126);
if (bad !== undefined) throw new ConnectorError('BAD_CONFIG', `API-Schlüssel enthält ein ungültiges Zeichen „${bad}“ (Leerzeichen, Zeilenumbruch oder „…“ vom Kürzen?). Bitte den vollständigen Schlüssel neu kopieren.`);
return k; };
/** TLS: Standard ist volle Prüfung. Für selbstsignierte Zertifikate: Fingerabdruck festlegen (bevorzugt) oder Prüfung abschalten (unsicher). */
function fetchFor(ctx: ConnectorContext): typeof fetch {
if (deps.fetchImpl) return deps.fetchImpl;
const fp = String(ctx.config.tlsFingerprint ?? '').replace(/[:\s]/g, '').toLowerCase();
const verify = String(ctx.config.verifyTls ?? 'true').toLowerCase() !== 'false';
if (verify && !fp) return fetch;
if (fp && !/^[0-9a-f]{64}$/.test(fp)) throw new ConnectorError('BAD_CONFIG', 'tlsFingerprint muss ein SHA-256-Fingerabdruck sein (64 Hex-Zeichen)');
const key = `${base(ctx)}|${fp}|${verify}`; let a = agents.get(key);
if (!a) {
a = new Agent({ connect: { rejectUnauthorized: false, ...(fp ? { checkServerIdentity: (_h: string, cert: { fingerprint256?: string }) => ((cert.fingerprint256 ?? '').replace(/:/g, '').toLowerCase() === fp ? undefined : new Error('TLS-Fingerabdruck stimmt nicht überein')) } : {}) } });
agents.set(key, a);
}
const agent = a; return ((url: string, init: RequestInit) => undiciFetch(url, { ...(init as object), dispatcher: agent } as never)) as unknown as typeof fetch;
}
/** Aufruf mit Wiederholung bei Webserver-Neustart (500/503) für Lesen und Ziel-Zustands-Änderungen; Anlage (POST) wird nie automatisch wiederholt. */
function call<T>(ctx: ConnectorContext, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown): Promise<T> {
return httpJson<T>(method, `${base(ctx)}/api/v2${path}`, { headers: { 'x-api-key': apiKey(ctx) }, body },
{ ...deps, fetchImpl: fetchFor(ctx), retries: deps.retries ?? 3, baseDelayMs: deps.baseDelayMs ?? 1500, retryWrites: method === 'PUT' || method === 'DELETE' });
}
const optional = async <T>(p: Promise<T>): Promise<T | null> => { try { return await p; } catch (e) { if (e instanceof ConnectorError && e.code === 'NOT_FOUND') return null; throw e; } };
// ---- Normalisierung ------------------------------------------------------------------
const clientName = (c: RawClient) => `${c.contact_data?.company?.trim() || c.username} · ${c.username}`;
function mapClient(c: RawClient, stats: Record<string, Stat> | null, plans: Map<number, string>): NormalizedResource {
const usage: Record<string, number | string | null> = {}; const limits: Record<string, number | string | null> = {};
for (const [k, v] of Object.entries(stats ?? {})) { if (v && typeof v.value === 'number') usage[k] = v.value; if (v && typeof v.max === 'number') limits[k] = v.max < 0 ? null : v.max; } // negative Grenze = unbegrenzt (angenommen)
const perms = Object.entries(c.permissions ?? {}).filter(([, v]) => v).map(([k]) => PERM_LABEL[k]).filter(Boolean);
return {
externalRef: String(c.id), type: 'hosting_account', name: clientName(c), state: c.is_suspended ? 'suspended' : c.status === 2 ? 'error' : 'active',
validFrom: iso(c.created_at), validUntil: null, limits, usage,
details: { username: c.username, plan: plans.get(Number(c.id_hosting_plan)) ?? (c.id_hosting_plan ? `Tarif ${c.id_hosting_plan}` : null), planId: c.id_hosting_plan ?? null, providerStatus: STATUS_LABEL[c.status ?? 0] ?? null,
scheduledSuspendOn: c.suspend_on || null, scheduledDeleteOn: c.delete_on || null, features: perms, units: 'Speicher/Traffic in Byte (angenommen)' },
};
}
const state = (r: Row): NormalizedChild['state'] => (r.is_disabled ? 'disabled' : r.status === 2 ? 'error' : r.status === 3 || r.status === 4 ? 'pending' : 'active');
function mapChild(kind: ChildKind, r: Row): NormalizedChild {
if (kind === 'domain') return { id: String(r.id), kind, name: r.domain_utf8 || r.domain, state: state(r), details: { subdomain: !!r.is_subdomain, system: !!r.is_system_domain, phpVersion: r.php_version || 'System', letsEncrypt: !!r.security?.lets_encrypt, forceHttps: !!r.security?.force_https, hsts: !!r.security?.is_hsts, emailDomain: !!r.is_email_domain, parentId: r.id_parent_domain ? String(r.id_parent_domain) : null } };
if (kind === 'email') return { id: String(r.id), kind, name: r.email_utf8 || r.email, state: state(r), details: { sizeBytes: r.size ?? null, maxSizeBytes: r.max_size ?? null, aliases: r.aliases_utf8 ?? r.aliases ?? [], forwardings: r.forwardings_utf8 ?? r.forwardings ?? [], catchAll: !!r.catch_all, description: r.description ?? '' } };
if (kind === 'database') return { id: String(r.id), kind, name: r.database_name, state: 'active', details: { user: r.database_username, sizeBytes: r.size ?? null, description: r.description ?? '', remoteHosts: r.remote_hosts ?? [] } };
if (kind === 'ftp') return { id: String(r.id), kind, name: r.username, state: state(r), details: { home: r.home_directory, description: r.description ?? '' } };
const until = iso(r.valid_till);
return { id: String(r.id), kind, name: r.name, state: until && new Date(until) < now() ? 'expired' : 'active', validUntil: until, details: { issuer: r.issuer ?? null, securedDomains: r.secured_domains ?? [], usedByDomains: r.usage?.domain_count ?? null } };
}
const PATH: Record<ChildKind, string> = { domain: 'domains', email: 'emails', database: 'databases', ftp: 'ftp-users', certificate: 'certificates' };
const LISTKEY: Record<ChildKind, string> = { domain: 'domains', email: 'emails', database: 'databases', ftp: 'ftp_users', certificate: 'certificates' };
const NAMEKEY: Record<ChildKind, string> = { domain: 'domain', email: 'email', database: 'database_name', ftp: 'username', certificate: 'name' };
async function getClient(ctx: ConnectorContext, id: string): Promise<RawClient> { return call<RawClient>(ctx, 'GET', `/clients/${encodeURIComponent(id)}`); }
async function plansMap(ctx: ConnectorContext): Promise<Map<number, string>> { try { return new Map((await call<RawPlan[]>(ctx, 'GET', '/hosting-plans')).map((p) => [p.id, p.name])); } catch { return new Map(); } }
// ---- Unterobjekte ------------------------------------------------------------------
const children: ChildAccess = {
async kinds(ctx, parentRef) {
const c = await getClient(ctx, parentRef); const p = c.permissions ?? {};
return [{ kind: 'domain', canWrite: true }, { kind: 'email', canWrite: true }, { kind: 'database', canWrite: true }, { kind: 'ftp', canWrite: p.ftp !== false }, { kind: 'certificate', canWrite: false }];
},
async list(ctx, parentRef, kind) {
const res = await call<Record<string, Row[]>>(ctx, 'GET', `/clients/${encodeURIComponent(parentRef)}/resources`);
const rows = res?.[LISTKEY[kind]]; if (!Array.isArray(rows)) throw new ConnectorError('INVALID_RESPONSE');
return rows.filter((r) => String(r.id_user) === parentRef || r.id_user === undefined).map((r) => mapChild(kind, r)); // doppelt abgesichert: nur Objekte dieses Kunden
},
async act(ctx, req) {
const { parentRef: cid, kind, op, id } = req; const d = req.data ?? {}; const path = PATH[kind];
if (kind === 'certificate') throw new ConnectorError('UNSUPPORTED', 'Zertifikate sind nur lesbar');
const fetchOwned = async (): Promise<Row> => owned(await optional(call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(str(id, 'id', 40))}`)), cid);
if (op === 'delete') {
const row = await optional(call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(str(id, 'id', 40))}`)); if (!row) return {}; // schon weg: Ziel erreicht (idempotent)
owned(row, cid);
if (kind === 'domain' && row.is_system_domain) throw bad('Die System-Domain kann nicht gelöscht werden');
await call(ctx, 'DELETE', `/${path}/${encodeURIComponent(String(row.id))}`); return {};
}
if (op === 'update') {
const row = await fetchOwned(); const body: Row = {};
if (kind === 'domain') { if (d.phpVersion !== undefined) body.php_version = optStr(d.phpVersion, 'PHP-Version', 20) ?? ''; const sec: Row = {}; for (const [k, f] of [['letsEncrypt', 'lets_encrypt'], ['forceHttps', 'force_https'], ['hsts', 'is_hsts']] as const) if (typeof d[k] === 'boolean') sec[f] = d[k]; if (Object.keys(sec).length) body.security = sec; if (typeof d.disabled === 'boolean') body.is_disabled = d.disabled; }
if (kind === 'email') { if (req.secrets?.password) body.password = password(req.secrets); if (d.maxSizeBytes !== undefined) { const n = Number(d.maxSizeBytes); if (!Number.isInteger(n) || n < 0) throw bad('Postfachgröße ungültig'); body.max_size = n; } if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; if (typeof d.catchAll === 'boolean') body.catch_all = d.catchAll;
if (Array.isArray(d.forwardings)) body.forwardings = d.forwardings.map((x) => str(x, 'Weiterleitung', 254)); if (Array.isArray(d.aliases)) body.aliases = d.aliases.map((x) => str(x, 'Alias', 254)); }
if (kind === 'database') { if (req.secrets?.password) body.password = password(req.secrets); if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; }
if (kind === 'ftp') { if (req.secrets?.password) body.password = password(req.secrets); if (d.home !== undefined) body.home_directory = homeDir(d.home); if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; }
if (!Object.keys(body).length) throw bad('Keine Änderung angegeben');
await call(ctx, 'PUT', `/${path}/${encodeURIComponent(String(row.id))}`, body);
return { child: mapChild(kind, await call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(String(row.id))}`)) };
}
// ---- anlegen: id_user wird IMMER auf den Kunden gesetzt; existiert das Objekt schon (Wiederholung), wird es übernommen ----
const existingBy = async (name: string) => { const r = await optional(call<Row>(ctx, 'GET', `/${path}/name/${encodeURIComponent(name)}`)); if (r && String(r.id_user) !== cid) throw new ConnectorError('CONFLICT', 'Der Name ist bereits vergeben'); return r; };
let body: Row; let natural: string | undefined;
if (kind === 'domain') {
const name = str(d.domain, 'Domain', 253).toLowerCase(); if (!DOMAIN_RE.test(name)) throw bad('Ungültiger Domainname'); natural = name;
let parent = 0; if (d.parentDomainId !== undefined && d.parentDomainId !== null && d.parentDomainId !== 0) { const p = owned(await optional(call<Row>(ctx, 'GET', `/domains/${encodeURIComponent(str(String(d.parentDomainId), 'Hauptdomain', 20))}`)), cid); parent = Number(p.id); if (!name.endsWith(`.${String(p.domain).toLowerCase()}`)) throw bad('Die Subdomain muss zur Hauptdomain passen'); }
body = { id_user: Number(cid), id_parent_domain: parent, domain: name, ...(d.phpVersion ? { php_version: str(d.phpVersion, 'PHP-Version', 20) } : {}), ...(typeof d.emailDomain === 'boolean' ? { is_email_domain: d.emailDomain } : {}), ...(typeof d.letsEncrypt === 'boolean' ? { security: { lets_encrypt: d.letsEncrypt, force_https: !!d.forceHttps } } : {}) };
} else if (kind === 'email') {
const local = str(d.local, 'Postfach', 64); const dom = str(d.domain, 'Domain', 253).toLowerCase(); if (!LOCAL_RE.test(local) || !DOMAIN_RE.test(dom)) throw bad('Ungültige E-Mail-Adresse'); natural = `${local}@${dom}`.toLowerCase();
const dRow = owned(await optional(call<Row>(ctx, 'GET', `/domains/name/${encodeURIComponent(dom)}`)), cid); void dRow; // Domain muss dem Kunden gehören
body = { id_user: Number(cid), email: natural, password: password(req.secrets), ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}), ...(d.maxSizeBytes !== undefined ? { max_size: Number(d.maxSizeBytes) } : {}) };
} else if (kind === 'database') {
const n = optStr(d.name, 'Datenbankname', 64); if (n && !DBNAME_RE.test(n)) throw bad('Datenbankname: nur a-z, 0-9 und Unterstrich'); natural = n;
body = { id_user: Number(cid), ...(n ? { database_name: n, database_username: n } : {}), password: password(req.secrets), ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}) };
} else {
const u = optStr(d.username, 'FTP-Benutzer', 32); if (u && !FTPUSER_RE.test(u)) throw bad('FTP-Benutzername ungültig'); natural = u;
body = { id_user: Number(cid), ...(u ? { username: u } : {}), password: password(req.secrets), home_directory: homeDir(d.home) ?? '/www/', ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}) };
}
if (natural) { const ex = await existingBy(natural); if (ex) return { child: mapChild(kind, ex) }; }
const created = await call<{ id: number }>(ctx, 'POST', `/${path}`, body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine ID');
return { child: mapChild(kind, await call<Row>(ctx, 'GET', `/${path}/${created.id}`)) };
},
};
void NAMEKEY;
const capabilities: Capability[] = ['customers.list', 'catalog.write', 'catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.create', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.terminate', 'plan.change', 'sso.login', 'children.read', 'children.write'];
return {
contractVersion: CONTRACT_VERSION, key: 'keyhelp', displayName: 'KeyHelp (Webhosting)',
configFields: [
{ name: 'baseUrl', label: 'KeyHelp-Adresse', required: true, placeholder: 'https://keyhelp.example.de', help: 'Die Adresse, unter der Sie KeyHelp im Browser öffnen (mit https://).' },
{ name: 'apiKey', label: 'API-Schlüssel', secret: true, required: true, help: 'In KeyHelp unter Konfiguration → API anlegen und auf die IP-Adresse dieses Servers beschränken.' },
{ name: 'verifyTls', label: 'Zertifikat von KeyHelp prüfen', advanced: true, options: [{ value: 'true', label: 'Ja, prüfen (empfohlen)' }, { value: 'false', label: 'Nein, nicht prüfen (unsicher)' }], help: 'Nur ändern, wenn KeyHelp ein selbstsigniertes Zertifikat verwendet. Besser ist es, statt „nicht prüfen“ den Fingerabdruck unten einzutragen.' },
{ name: 'tlsFingerprint', label: 'Zertifikat-Fingerabdruck (SHA-256)', advanced: true, placeholder: 'AB:CD:EF:…', help: 'Nur bei selbstsigniertem Zertifikat: Das Kundencenter vertraut dann genau diesem Zertifikat. Mit „Vom Server holen“ eintragen und mit KeyHelp abgleichen.' },
],
capabilities: () => capabilities,
async healthCheck(ctx) {
const t0 = Date.now();
try {
await call(ctx, 'GET', '/ping'); // reines Erreichbarkeits-Signal (kein Wiederholen bei Ausfall)
let version: string | undefined; try { const s = await call<{ meta?: { panel_version?: string; api_version?: string } }>(ctx, 'GET', '/server'); version = s?.meta?.panel_version ? `KeyHelp ${s.meta.panel_version} (API ${s.meta.api_version ?? '?'})` : undefined; } catch { /* Version nur informativ */ }
return { ok: true, latencyMs: Date.now() - t0, version };
} catch (e) { return { ok: false, latencyMs: Date.now() - t0, message: e instanceof ConnectorError ? e.message : 'Unbekannter Fehler' }; }
},
/** Alle Kunden mit Nutzung (Statistik je Kunde, begrenzt parallel). Ausfall einzelner Statistiken bricht den Abgleich nicht ab. */
async listResources(ctx) {
const [clients, plans] = await Promise.all([call<RawClient[]>(ctx, 'GET', '/clients'), plansMap(ctx)]);
if (!Array.isArray(clients)) throw new ConnectorError('INVALID_RESPONSE');
return mapLimit(clients, 4, async (c) => { const st = await call<Record<string, Stat>>(ctx, 'GET', `/clients/${c.id}/stats`).catch(() => null); return mapClient(c, st, plans); });
},
/** Hosting-Tarife als Produktvorlagen (Limits und Rechte des Tarifs). */
async listCatalog(ctx) {
const plans = await call<RawPlan[]>(ctx, 'GET', '/hosting-plans'); if (!Array.isArray(plans)) throw new ConnectorError('INVALID_RESPONSE');
return plans.map(planItem);
},
/** Kunden (Hosting-Konten) mit Kontaktdaten und Tarif für die Übernahme. */
async listCustomers(ctx) {
const [clients, plans] = await Promise.all([call<RawClient[]>(ctx, 'GET', '/clients'), plansMap(ctx)]);
if (!Array.isArray(clients)) throw new ConnectorError('INVALID_RESPONSE');
const t = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null);
return clients.map((c): ImportableCustomer => {
const cd = (c.contact_data ?? {}) as Row; const person = [t(cd.first_name), t(cd.last_name)].filter(Boolean).join(' ');
return { externalRef: String(c.id), displayName: t(cd.company) ?? (person || c.username), company: t(cd.company), firstName: t(cd.first_name), lastName: t(cd.last_name), email: t(c.email), phone: t(cd.telephone),
address: { street: t(cd.address), zip: t(cd.zip), city: t(cd.city), state: t(cd.state), country: t(cd.country) }, legacyNumber: t(cd.client_id),
planRef: c.id_hosting_plan ? `plan:${c.id_hosting_plan}` : null, planName: plans.get(Number(c.id_hosting_plan)) ?? null, state: c.is_suspended ? 'suspended' : 'active', createdAt: iso(c.created_at) };
});
},
/**
* Neuen Hosting-Tarif anlegen. Größen werden als Byte gesendet (Annahme, an der Instanz prüfen). "Unbegrenzt" gibt es nur, wenn die Instanz
* es bereits kennt (negativer Wert in bestehenden Tarifen/Konten); sonst wird abgelehnt statt zu raten.
*/
async createCatalogItem(ctx, spec) {
const name = str(spec.name, 'Name', 100); const existing = await call<RawPlan[]>(ctx, 'GET', '/hosting-plans');
if (existing.some((p) => p.name.toLowerCase() === name.toLowerCase())) throw new ConnectorError('CONFLICT', 'Es gibt bereits einen Tarif mit diesem Namen');
const clients = await call<RawClient[]>(ctx, 'GET', '/clients').catch(() => [] as RawClient[]);
const unlimitedKnown = existing.some((p) => Object.values(p.resources ?? {}).some((v) => v < 0)) || clients.some((c) => Object.values(c.resource_limits ?? {}).some((v) => v < 0));
const GB = 1024 ** 3; const map: [keyof NewCatalogSpec['limits'], string, boolean][] = [['diskSpaceGb', 'disk_space', true], ['trafficGb', 'traffic', true], ['domains', 'domains', false], ['subdomains', 'subdomains', false], ['emailAccounts', 'email_accounts', false], ['emailAddresses', 'email_addresses', false], ['emailForwardings', 'email_forwardings', false], ['databases', 'databases', false], ['ftpUsers', 'ftp_users', false], ['scheduledTasks', 'scheduled_tasks', false]];
const resources: Record<string, number> = {};
for (const [k, api, isGb] of map) {
const v = spec.limits?.[k]; if (v === undefined) continue;
if (v === null) { if (!unlimitedKnown) throw bad(`"Unbegrenzt" (${RES_LABEL[api]}) wird von dieser Instanz noch nicht erkannt. Bitte eine Zahl angeben.`); resources[api] = -1; continue; }
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1_000_000) throw bad(`${RES_LABEL[api]}: ungültiger Wert`);
resources[api] = isGb ? Math.round(v * GB) : Math.round(v);
}
const permissions: Record<string, boolean> = {}; for (const [k, v] of Object.entries(spec.permissions ?? {})) if (/^[a-z_]{2,40}$/.test(k) && typeof v === 'boolean') permissions[k] = v;
const created = await call<{ id: number }>(ctx, 'POST', '/hosting-plans', { name, ...(Object.keys(resources).length ? { resources } : {}), ...(Object.keys(permissions).length ? { permissions } : {}) });
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine Tarif-ID');
return planItem(await call<RawPlan>(ctx, 'GET', `/hosting-plans/${created.id}`));
},
validateProvisioning(p) {
if (p.hostingPlanId !== undefined && p.hostingPlanId !== null && (!Number.isInteger(p.hostingPlanId) || (p.hostingPlanId as number) < 1)) return 'hostingPlanId muss eine ganze Zahl ≥ 1 sein';
if (p.language !== undefined && !/^[a-z]{2}(_[A-Z]{2})?$/.test(String(p.language))) return 'language muss z. B. "de" oder "de_DE" sein';
for (const k of ['createSystemDomain', 'sendLoginCredentials']) if (p[k] !== undefined && typeof p[k] !== 'boolean') return `${k} muss true oder false sein`;
return null;
},
/**
* Legt ein Hosting-Konto an. Benutzername = "kc" + Ziffern der Vertragsnummer (stabil): Existiert er schon und trägt unseren Vermerk,
* wird das Konto übernommen (sichere Wiederholung nach unklarem Ausgang). Das vom Panel erzeugte Passwort wird NIE gelesen oder gespeichert
* (KeyHelp schickt die Zugangsdaten an die Kunden-E-Mail; zusätzlich gibt es den Panel-Login).
*/
async provision(ctx, req) {
const err = this.validateProvisioning!(req.params); if (err) throw new ConnectorError('BAD_CONFIG', err);
const c = req.context; if (!c?.customerEmail) throw new ConnectorError('BAD_CONFIG', 'Für das Hosting-Konto wird die E-Mail-Adresse des Kunden benötigt');
const username = usernameFor(c, req.idempotencyKey); const plans = await plansMap(ctx);
const existing = await optional(call<RawClient>(ctx, 'GET', `/clients/name/${encodeURIComponent(username)}`));
if (existing) {
if (!String(existing.notes ?? '').includes(noteFor(c))) throw new ConnectorError('CONFLICT', `Der Benutzername ${username} ist bereits an ein anderes Konto vergeben`);
return { resource: mapClient(existing, null, plans) }; // bereits angelegt (Wiederholung)
}
const [first, ...rest] = (c.contactName ?? '').trim().split(/\s+/).filter(Boolean);
const body: Row = { username, email: c.customerEmail, language: (req.params.language as string) ?? 'de', ...(req.params.hostingPlanId ? { id_hosting_plan: req.params.hostingPlanId } : {}), create_system_domain: req.params.createSystemDomain ?? true, send_login_credentials: req.params.sendLoginCredentials ?? true,
notes: noteFor(c), contact_data: { company: c.customerName, ...(first ? { first_name: first, last_name: rest.join(' ') || first } : {}) } };
const created = await call<{ id: number }>(ctx, 'POST', '/clients', body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine Kunden-ID');
const client = await getClient(ctx, String(created.id));
if (client.username !== username) throw new ConnectorError('INVALID_RESPONSE', `Konto ${created.id} mit unerwartetem Benutzernamen angelegt`);
return { resource: mapClient(client, null, plans) };
},
/** Sperren/Entsperren setzen einen Zielzustand; Tarifwechsel setzt den Tarif; Löschen ist endgültig (nur manuell bestätigt, nie automatisch wiederholt). */
async execute(ctx, req) {
const id = encodeURIComponent(req.externalRef); const plans = await plansMap(ctx);
const act: ActionName = req.action;
if (act === 'suspend' || act === 'unsuspend') { await call(ctx, 'PUT', `/clients/${id}`, { is_suspended: act === 'suspend' }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; }
if (act === 'change_plan') { const pid = Number(req.params?.planId); if (!Number.isInteger(pid) || pid < 1) throw new ConnectorError('BAD_CONFIG', 'planId fehlt'); await call(ctx, 'PUT', `/clients/${id}`, { id_hosting_plan: pid }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; }
if (act === 'terminate') { await optional(call(ctx, 'DELETE', `/clients/${id}`)); return {}; } // 404 = schon gelöscht
throw new ConnectorError('UNSUPPORTED', 'Diese Aktion gibt es bei KeyHelp nicht');
},
/** Offizieller Login-Link (60 Minuten gültig, Brute-Force-Schutz im Panel). */
async loginUrl(ctx, ref) {
const r = await call<{ url?: string }>(ctx, 'GET', `/login/${encodeURIComponent(ref)}`);
if (!r?.url || !/^https:\/\//.test(r.url)) throw new ConnectorError('INVALID_RESPONSE', 'kein Login-Link');
return { url: r.url, validForSec: 3600 };
},
children,
};
}
export const keyhelpConnector = createKeyHelpConnector();
void maskKey;

View file

@ -0,0 +1,91 @@
/**
* Nachbau eines KeyHelp-Servers (Teilmenge) nach der Definition "KeyHelp RESTful API 2.15".
* Zweck: Vertrags- und Integrationstests ohne echte Instanz. Antwortstrukturen folgen den Schemas der Definition;
* Verhalten, das die Definition offenlässt (z. B. Text der Fehlermeldungen), ist bewusst einfach gehalten.
*/
type Row = Record<string, any>;
export interface Fake { fetch: typeof fetch; state: { clients: Row[]; domains: Row[]; emails: Row[]; databases: Row[]; ftp: Row[]; certs: Row[]; plans: Row[] }; calls: { method: string; path: string; body?: any; key?: string }[]; opts: { failGet503: number; leakForeign: boolean; slowDown?: boolean } }
export function createFake(apiKey = 'test-key'): Fake {
let seq = 100;
const state = {
plans: [{ id: 1, name: 'Starter', resources: { disk_space: 10737418240, traffic: 107374182400, domains: 2, subdomains: 10, email_accounts: 5, email_addresses: 10, email_forwardings: 10, databases: 2, ftp_users: 2, scheduled_tasks: 1 }, permissions: { ftp: true, php: true, ssh: false, file_manager: true, backup: true, panel_access: true } },
{ id: 2, name: 'Business', resources: { disk_space: -1, traffic: -1, domains: 50, subdomains: -1, email_accounts: -1, email_addresses: -1, email_forwardings: -1, databases: 20, ftp_users: 20, scheduled_tasks: 10 }, permissions: { ftp: true, php: true, ssh: true, file_manager: true } }],
clients: [
{ id: 1, status: 1, username: 'alpha', email: 'alpha@example.test', language: 'de', notes: 'manuell angelegt', id_hosting_plan: 1, created_at: '2026-01-05 10:00:00', is_suspended: false, contact_data: { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha' }, permissions: { ftp: true, panel_access: true }, password_hash: 'GEHEIM-HASH' },
{ id: 2, status: 1, username: 'beta', email: 'beta@example.test', language: 'de', notes: '', id_hosting_plan: 2, created_at: '2026-02-01 10:00:00', is_suspended: true, delete_on: '2026-12-01 00:00:00', permissions: { ftp: false }, contact_data: {} },
] as Row[],
domains: [
{ id: 10, id_user: 1, id_parent_domain: 0, domain: 'alpha.example.test', domain_utf8: 'alpha.example.test', status: 1, is_subdomain: false, is_system_domain: false, php_version: '', security: { lets_encrypt: true, force_https: true, is_hsts: false }, is_email_domain: true },
{ id: 11, id_user: 1, id_parent_domain: 0, domain: 'alpha.sys.example.test', domain_utf8: 'alpha.sys.example.test', status: 1, is_subdomain: false, is_system_domain: true, security: {} },
{ id: 20, id_user: 2, id_parent_domain: 0, domain: 'beta.example.test', domain_utf8: 'beta.example.test', status: 1, is_subdomain: false, is_system_domain: false, security: {}, is_email_domain: true },
] as Row[],
emails: [
{ id: 30, id_user: 1, email: 'info@alpha.example.test', email_utf8: 'info@alpha.example.test', status: 1, size: 1000, max_size: 5000, aliases: [], forwardings: [], catch_all: false, password_hash: 'GEHEIM-MAIL-HASH' },
{ id: 31, id_user: 2, email: 'info@beta.example.test', status: 1, size: 5, max_size: 100, aliases: [], forwardings: [] },
] as Row[],
databases: [{ id: 40, id_user: 1, database_name: 'alpha_db', database_username: 'alpha_db', size: 2048, description: '', remote_hosts: [] }, { id: 41, id_user: 2, database_name: 'beta_db', database_username: 'beta_db', size: 1, remote_hosts: [] }] as Row[],
ftp: [{ id: 50, id_user: 1, status: 1, username: 'alpha_ftp', home_directory: '/www/', description: '' }, { id: 51, id_user: 2, status: 1, username: 'beta_ftp', home_directory: '/www/' }] as Row[],
certs: [{ id: 60, id_user: 1, name: 'alpha-cert', secured_domains: ['alpha.example.test'], valid_till: '2027-01-01 00:00:00', issuer: "Let's Encrypt", usage: { domain_count: 1 } }, { id: 61, id_user: 1, name: 'alt', secured_domains: ['x'], valid_till: '2020-01-01 00:00:00', issuer: 'X', usage: { domain_count: 0 } }] as Row[],
};
const calls: Fake['calls'] = []; const opts = { failGet503: 0, leakForeign: false } as Fake['opts'];
const json = (b: unknown, status = 200) => new Response(status === 204 ? null : JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
const err = (status: number, message: string) => json({ code: String(status), message }, status);
const byId = (arr: Row[], id: string) => arr.find((x) => String(x.id) === id);
const CRUD: Record<string, { arr: Row[]; nameKey: string; idPrefix: number }> = { domains: { arr: state.domains, nameKey: 'domain', idPrefix: 0 }, emails: { arr: state.emails, nameKey: 'email', idPrefix: 0 }, databases: { arr: state.databases, nameKey: 'database_name', idPrefix: 0 }, 'ftp-users': { arr: state.ftp, nameKey: 'username', idPrefix: 0 }, certificates: { arr: state.certs, nameKey: 'name', idPrefix: 0 } };
const stats = (c: Row) => { const own = (a: Row[]) => a.filter((x) => x.id_user === c.id).length; return { disk_space: { value: 123456789, max: c.id === 2 ? -1 : 10737418240 }, files: { value: 10, max: 100000 }, traffic: { value: 5000, max: 107374182400 }, domains: { value: own(state.domains), max: 2 }, subdomains: { value: 0, max: 10 }, email_accounts: { value: own(state.emails), max: 5 }, email_addresses: { value: 0, max: 10 }, email_forwardings: { value: 0, max: 10 }, databases: { value: own(state.databases), max: 2 }, ftp_users: { value: own(state.ftp), max: 2 }, scheduled_tasks: { value: 0, max: 1 } }; };
const f = (async (url: string, init: RequestInit = {}) => {
const u = new URL(url); const method = (init.method ?? 'GET').toUpperCase(); const path = u.pathname.replace(/^\/api\/v2/, '');
const h = (init.headers ?? {}) as Record<string, string>; const body = init.body ? JSON.parse(init.body as string) : undefined;
calls.push({ method, path, body, key: h['x-api-key'] });
if (h['x-api-key'] !== apiKey) return err(401, 'Unauthorized');
if (method === 'GET' && opts.failGet503 > 0) { opts.failGet503--; return err(503, 'Webserver wird neu geladen'); }
if (path === '/ping') return json({ response: 'pong' });
if (path === '/server') return json({ meta: { hostname: 'kh.test', panel_version: '26.0', api_version: '2.15', keyhelp_pro: false } });
if (path === '/hosting-plans' && method === 'GET') return json(state.plans);
if (path === '/hosting-plans' && method === 'POST') { if (!body?.name) return err(400, 'Invalid property data for: name'); const pl = { id: ++seq, name: body.name, resources: { disk_space: 0, traffic: 0, domains: 0, subdomains: 0, email_accounts: 0, email_addresses: 0, email_forwardings: 0, databases: 0, ftp_users: 0, scheduled_tasks: 0, ...(body.resources ?? {}) }, permissions: body.permissions ?? {} }; state.plans.push(pl); return json({ id: pl.id }, 201); }
const pm = /^\/hosting-plans\/(\d+)$/.exec(path); if (pm && method === 'GET') { const pl = byId(state.plans, pm[1]!); return pl ? json(pl) : err(404, 'Not found'); }
if (path === '/clients' && method === 'GET') return json(state.clients);
if (path === '/clients' && method === 'POST') {
if (!body?.username || !body?.email) return err(400, 'Invalid property data for: username, email');
if (state.clients.some((c) => c.username === body.username)) return err(400, `Invalid property data for: username (already in use)`);
const c = { id: ++seq, status: 3, is_suspended: false, created_at: '2026-09-26 12:00:00', id_hosting_plan: body.id_hosting_plan ?? 1, permissions: { ftp: true }, ...body }; delete c.password; state.clients.push(c);
return json({ id: c.id, password: 'AUTO-GENERATED-SECRET' }, 201);
}
let m = /^\/clients\/(name\/)?([^/]+)(?:\/(stats|resources|traffic))?$/.exec(path);
if (m) {
const c = m[1] ? state.clients.find((x) => x.username === decodeURIComponent(m![2]!)) : byId(state.clients, m[2]!);
if (!c) return err(404, 'Not found');
if (m[3] === 'stats') return json(stats(c));
if (m[3] === 'resources') return json({ domains: state.domains.filter((x) => x.id_user === c.id || opts.leakForeign), emails: state.emails.filter((x) => x.id_user === c.id || opts.leakForeign), databases: state.databases.filter((x) => x.id_user === c.id), ftp_users: state.ftp.filter((x) => x.id_user === c.id), certificates: state.certs.filter((x) => x.id_user === c.id), scheduled_tasks: [], directory_protections: [] });
if (method === 'GET') return json(c);
if (method === 'PUT') { Object.assign(c, body); return json(c); }
if (method === 'DELETE') { state.clients.splice(state.clients.indexOf(c), 1); for (const a of Object.values(CRUD)) for (let i = a.arr.length - 1; i >= 0; i--) if (a.arr[i]!.id_user === c.id) a.arr.splice(i, 1); return json(null, 204); }
}
m = /^\/login\/(name\/)?([^/]+)$/.exec(path);
if (m && method === 'GET') { const c = m[1] ? state.clients.find((x) => x.username === m![2]) : byId(state.clients, m[2]!); return c ? json({ url: `https://kh.test/login?token=EINMALIG-${c.id}` }) : err(404, 'Not found'); }
m = /^\/(domains|emails|databases|ftp-users|certificates)(?:\/(name\/)?([^/]+))?$/.exec(path);
if (m) {
const crud = CRUD[m[1]!]!;
if (!m[3]) {
if (method === 'GET') return json(crud.arr);
if (method === 'POST') {
const r: Row = { id: ++seq, status: 3, ...body }; delete r.password;
if (m[1] === 'databases' && !r.database_name) { r.database_name = `db${seq}`; r.database_username = `dbu${seq}`; }
if (m[1] === 'ftp-users' && !r.username) r.username = `ftp${seq}`;
if (crud.arr.some((x) => x[crud.nameKey] === r[crud.nameKey])) return err(400, 'Invalid property data: name in use');
crud.arr.push(r); return json({ id: r.id, password: 'AUTO-GENERATED-SECRET' }, 201);
}
} else {
const row = m[2] ? crud.arr.find((x) => x[crud.nameKey] === decodeURIComponent(m![3]!)) : byId(crud.arr, m[3]!);
if (!row) return err(404, 'Not found');
if (method === 'GET') return json(row);
if (method === 'PUT') { Object.assign(row, body); delete row.password; return json(row); }
if (method === 'DELETE') { crud.arr.splice(crud.arr.indexOf(row), 1); return json(null, 204); }
}
}
return err(404, 'Unbekannter Endpunkt');
}) as unknown as typeof fetch;
return { fetch: f, state, calls, opts };
}

View file

@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import { ConnectorError, type ConnectorContext, type ProvisionContext } from '@kc/connector-sdk';
import { runContract } from '@kc/connector-sdk/dist/contract.js';
import { createKeyHelpConnector, usernameFor } from '../src/index.js';
import { createFake, type Fake } from './fake.js';
const ctx = (extra: Record<string, unknown> = {}): ConnectorContext => ({ config: { baseUrl: 'https://kh.test', ...extra }, secrets: { apiKey: 'test-key' }, correlationId: 'c' });
const mk = (fake: Fake) => createKeyHelpConnector({ fetchImpl: fake.fetch, sleep: async () => {}, now: () => new Date('2026-09-26T12:00:00Z') });
const PC: ProvisionContext = { source: 'kundencenter', customerNumber: 'K-10001', customerName: 'Muster GmbH', contactName: 'Max Muster', customerEmail: 'max@example.test', orderNumber: 'B-20001', contractNumber: 'V-30001' };
describe('KeyHelp-Connector: Lesen', () => {
it('erfüllt den Connector-Vertrag', async () => { const f = createFake(); await runContract(expect as never, mk(f), ctx()); });
it('normalisiert Konten mit Nutzung, Limits, Tarif und Zustand – ohne Geheimnisse', async () => {
const list = await mk(createFake()).listResources(ctx());
expect(list.map((r) => [r.externalRef, r.state, r.type])).toEqual([['1', 'active', 'hosting_account'], ['2', 'suspended', 'hosting_account']]);
const a = list[0]!;
expect(a.name).toBe('Alpha GmbH · alpha'); expect(a.details).toMatchObject({ username: 'alpha', plan: 'Starter', planId: 1, providerStatus: 'ok' });
expect(a.usage).toMatchObject({ disk_space: 123456789, domains: 2 }); expect(a.limits).toMatchObject({ disk_space: 10737418240, domains: 2 });
expect(list[1]!.limits!.disk_space).toBeNull(); expect(list[1]!.details.scheduledDeleteOn).toBe('2026-12-01 00:00:00'); // unbegrenzt / geplantes Löschen
expect(JSON.stringify(list)).not.toMatch(/GEHEIM|password|AUTO-GENERATED/i);
});
it('Statistik-Ausfall einzelner Konten bricht den Abgleich nicht ab', async () => {
const f = createFake(); const orig = f.fetch; (f as any).fetch = async (u: string, i: RequestInit) => (String(u).includes('/clients/2/stats') ? new Response('{}', { status: 500 }) : orig(u, i));
const list = await createKeyHelpConnector({ fetchImpl: f.fetch, sleep: async () => {}, retries: 0 }).listResources(ctx());
expect(list).toHaveLength(2); expect(list[1]!.usage).toEqual({});
});
it('liest Hosting-Tarife als Produktvorlagen (fest vorgegeben)', async () => {
const items = await mk(createFake()).listCatalog!(ctx());
expect(items.map((i) => i.name)).toEqual(['Starter', 'Business']);
expect(items[0]).toMatchObject({ externalRef: 'plan:1', category: 'hosting', fixed: true, provisioning: { hostingPlanId: 1, language: 'de', createSystemDomain: true, sendLoginCredentials: true } });
expect(items[0]!.features).toEqual(expect.arrayContaining(['Speicher: 10 GB', 'Domains: 2', 'FTP', 'Dateimanager'])); expect(items[1]!.features).toContain('Speicher: unbegrenzt');
});
it('Health: Version, Ausfall und falscher Schlüssel', async () => {
const f = createFake(); const h = await mk(f).healthCheck(ctx()); expect(h).toMatchObject({ ok: true, version: 'KeyHelp 26.0 (API 2.15)' });
const down = createKeyHelpConnector({ fetchImpl: (async () => { throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); }) as never, sleep: async () => {}, retries: 0 });
expect(await down.healthCheck(ctx())).toMatchObject({ ok: false, message: expect.stringMatching(/nicht erreichbar/) });
await expect(mk(f).listResources({ ...ctx(), secrets: { apiKey: 'falsch' } })).rejects.toMatchObject({ code: 'AUTH_FAILED' });
});
it('wiederholt Lesezugriffe beim Webserver-Neustart (503), nie das Anlegen', async () => {
const f = createFake(); f.opts.failGet503 = 2; expect((await mk(f).listResources(ctx())).length).toBe(2);
const g = createFake(); const c = mk(g); g.opts.failGet503 = 0;
const orig = g.fetch; let posts = 0; (g as any).fetch = async (u: string, i: RequestInit) => { if (i.method === 'POST') { posts++; return new Response('{}', { status: 503 }); } return orig(u, i); };
const c2 = createKeyHelpConnector({ fetchImpl: g.fetch, sleep: async () => {} }); void c;
await expect(c2.provision!(ctx(), { params: { hostingPlanId: 1 }, label: 'x', idempotencyKey: 'k', context: PC })).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', ambiguous: true });
expect(posts).toBe(1); // Anlage wird nie automatisch wiederholt
});
it('prüft Konfiguration: nur https, Fingerabdruck-Format', async () => {
await expect(createKeyHelpConnector().healthCheck({ config: { baseUrl: 'http://kh.test' }, secrets: { apiKey: 'k' }, correlationId: 'c' }).then((h) => h.ok)).resolves.toBe(false);
await expect(createKeyHelpConnector().listResources({ config: { baseUrl: 'https://kh.test', tlsFingerprint: 'zzz' }, secrets: { apiKey: 'k' }, correlationId: 'c' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
await expect(createKeyHelpConnector({ fetchImpl: createFake().fetch }).listResources({ config: { baseUrl: 'https://kh.test' }, secrets: {}, correlationId: 'c' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
});
});
describe('KeyHelp-Connector: Konto anlegen und ändern', () => {
const params = { hostingPlanId: 2, language: 'de', createSystemDomain: true, sendLoginCredentials: true };
it('legt ein Konto an, vermerkt Kundencenter, liest kein Passwort und ist wiederholbar', async () => {
const f = createFake(); const c = mk(f);
expect(usernameFor(PC, 'x')).toBe('kc30001');
const r = await c.provision!(ctx(), { params, label: 'x', idempotencyKey: 'k1', context: PC });
expect(r.resource).toMatchObject({ type: 'hosting_account', state: 'active', details: { username: 'kc30001', planId: 2, plan: 'Business' } });
const post = f.calls.find((x) => x.method === 'POST' && x.path === '/clients')!.body;
expect(post).toMatchObject({ username: 'kc30001', email: 'max@example.test', id_hosting_plan: 2, create_system_domain: true, send_login_credentials: true, notes: 'Kundencenter K-10001 V-30001', contact_data: { company: 'Muster GmbH', first_name: 'Max', last_name: 'Muster' } });
expect(post).not.toHaveProperty('password'); expect(JSON.stringify(r)).not.toContain('AUTO-GENERATED');
// Wiederholung (z. B. nach unklarem Timeout): vorhandenes Konto wird übernommen, keine Doppelanlage
const again = await c.provision!(ctx(), { params, label: 'x', idempotencyKey: 'k2', context: PC });
expect(again.resource.externalRef).toBe(r.resource.externalRef); expect(f.calls.filter((x) => x.method === 'POST' && x.path === '/clients').length).toBe(1);
expect(f.state.clients.filter((x) => x.username === 'kc30001').length).toBe(1);
});
it('lehnt fremd belegten Benutzernamen und fehlende Angaben ab', async () => {
const f = createFake(); f.state.clients.push({ id: 9, username: 'kc30001', email: 'x@y.test', notes: 'Kundencenter K-99999 V-30001x', status: 1 });
await expect(mk(f).provision!(ctx(), { params, label: 'x', idempotencyKey: 'k', context: PC })).rejects.toMatchObject({ code: 'CONFLICT' });
await expect(mk(createFake()).provision!(ctx(), { params, label: 'x', idempotencyKey: 'k', context: { ...PC, customerEmail: null } })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
await expect(mk(createFake()).provision!(ctx(), { params: { hostingPlanId: 0 }, label: 'x', idempotencyKey: 'k', context: PC })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
expect(mk(createFake()).validateProvisioning!({ hostingPlanId: 1, language: 'DE!' })).toMatch(/language/);
});
it('meldet vom Panel abgelehnte Eingaben verständlich und wiederholt sie nicht', async () => {
const f = createFake(); const orig = f.fetch; let n = 0; (f as any).fetch = async (u: string, i: RequestInit) => { if (i.method === 'POST') { n++; return new Response(JSON.stringify({ code: '400', message: 'Invalid property data for: email' }), { status: 400 }); } return orig(u, i); };
const e = await createKeyHelpConnector({ fetchImpl: f.fetch, sleep: async () => {} }).provision!(ctx(), { params, label: 'x', idempotencyKey: 'k', context: PC }).catch((x) => x) as ConnectorError;
expect(e.code).toBe('INVALID_INPUT'); expect(e.notSent).toBe(true); expect(e.message).toContain('Invalid property data'); expect(n).toBe(1);
});
it('sperrt, entsperrt, wechselt den Tarif und löscht (idempotent)', async () => {
const f = createFake(); const c = mk(f);
expect((await c.execute!(ctx(), { action: 'suspend', externalRef: '1', idempotencyKey: 'k' })).resource?.state).toBe('suspended'); expect(f.state.clients[0]!.is_suspended).toBe(true);
expect((await c.execute!(ctx(), { action: 'suspend', externalRef: '1', idempotencyKey: 'k' })).resource?.state).toBe('suspended'); // Wiederholung wirkungsgleich
expect((await c.execute!(ctx(), { action: 'unsuspend', externalRef: '1', idempotencyKey: 'k' })).resource?.state).toBe('active');
const up = await c.execute!(ctx(), { action: 'change_plan', externalRef: '1', params: { planId: 2 }, idempotencyKey: 'k' }); expect(up.resource?.details).toMatchObject({ planId: 2, plan: 'Business' });
await expect(c.execute!(ctx(), { action: 'change_plan', externalRef: '1', params: {}, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
await expect(c.execute!(ctx(), { action: 'extend', externalRef: '1', params: { until: '2027-01-01' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'UNSUPPORTED' });
await c.execute!(ctx(), { action: 'terminate', externalRef: '2', idempotencyKey: 'k' }); expect(f.state.clients.map((x) => x.id)).toEqual([1]);
await c.execute!(ctx(), { action: 'terminate', externalRef: '2', idempotencyKey: 'k' }); // schon weg: kein Fehler
});
it('liefert den Panel-Login nur als https-Link, gültig 60 Minuten', async () => {
const r = await mk(createFake()).loginUrl!(ctx(), '1'); expect(r).toEqual({ url: 'https://kh.test/login?token=EINMALIG-1', validForSec: 3600 });
const f = createFake(); const orig = f.fetch; (f as any).fetch = async (u: string, i: RequestInit) => (String(u).includes('/login/') ? new Response(JSON.stringify({ url: 'http://unsicher.test' }), { status: 200 }) : orig(u, i));
await expect(createKeyHelpConnector({ fetchImpl: f.fetch }).loginUrl!(ctx(), '1')).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
await expect(mk(createFake()).loginUrl!(ctx(), '999')).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
});
describe('KeyHelp-Connector: Unterobjekte und Mandantentrennung', () => {
it('listet alle Arten, liefert nie Passwörter und nie Objekte fremder Kunden', async () => {
const f = createFake(); f.opts.leakForeign = true; const ch = mk(f).children!;
expect((await ch.kinds(ctx(), '1')).map((k) => [k.kind, k.canWrite])).toEqual([['domain', true], ['email', true], ['database', true], ['ftp', true], ['certificate', false]]);
expect((await ch.kinds(ctx(), '2')).find((k) => k.kind === 'ftp')!.canWrite).toBe(false); // Recht "ftp" fehlt
const doms = await ch.list(ctx(), '1', 'domain'); expect(doms.map((d) => d.name)).toEqual(['alpha.example.test', 'alpha.sys.example.test']); // beta.example.test wurde trotz "Leck" gefiltert
expect(doms[0]!.details).toMatchObject({ letsEncrypt: true, forceHttps: true, emailDomain: true, system: false });
expect((await ch.list(ctx(), '1', 'email')).map((e) => e.name)).toEqual(['info@alpha.example.test']);
expect((await ch.list(ctx(), '1', 'database'))[0]!.details).toMatchObject({ user: 'alpha_db', sizeBytes: 2048 });
expect((await ch.list(ctx(), '1', 'ftp'))[0]!.details).toMatchObject({ home: '/www/' });
const certs = await ch.list(ctx(), '1', 'certificate'); expect(certs.map((c) => [c.name, c.state])).toEqual([['alpha-cert', 'active'], ['alt', 'expired']]); expect(certs[0]!.validUntil).toBe('2027-01-01T00:00:00.000Z');
expect(JSON.stringify([doms, await ch.list(ctx(), '1', 'email')])).not.toMatch(/GEHEIM|password/i);
});
it('legt Objekte für den Kunden an (Passwort getrennt, Übernahme bei Wiederholung)', async () => {
const f = createFake(); const ch = mk(f).children!;
const dom = await ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'create', data: { domain: 'Neu.Example.Test', phpVersion: '8.3', letsEncrypt: true }, idempotencyKey: 'k' });
expect(dom.child).toMatchObject({ kind: 'domain', name: 'neu.example.test' }); expect(f.calls.find((c) => c.method === 'POST' && c.path === '/domains')!.body).toMatchObject({ id_user: 1, domain: 'neu.example.test', php_version: '8.3' });
const mail = await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'create', data: { local: 'Kontakt', domain: 'alpha.example.test' }, secrets: { password: 'ein-sehr-langes-Passwort-1' }, idempotencyKey: 'k' });
expect(mail.child!.name).toBe('kontakt@alpha.example.test'); expect(f.calls.find((c) => c.path === '/emails' && c.method === 'POST')!.body).toMatchObject({ id_user: 1, password: 'ein-sehr-langes-Passwort-1' });
expect(JSON.stringify(mail)).not.toContain('ein-sehr-langes'); expect(JSON.stringify(mail)).not.toContain('AUTO-GENERATED');
const again = await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'create', data: { local: 'Kontakt', domain: 'alpha.example.test' }, secrets: { password: 'ein-sehr-langes-Passwort-1' }, idempotencyKey: 'k2' });
expect(again.child!.id).toBe(mail.child!.id); expect(f.calls.filter((c) => c.path === '/emails' && c.method === 'POST').length).toBe(1); // keine Doppelanlage
const db = await ch.act(ctx(), { parentRef: '1', kind: 'database', op: 'create', data: {}, secrets: { password: 'db-passwort-lang-123' }, idempotencyKey: 'k' }); expect(db.child!.name).toMatch(/^db\d+$/);
const ftp = await ch.act(ctx(), { parentRef: '1', kind: 'ftp', op: 'create', data: { username: 'neu_ftp', home: '/www/seite' }, secrets: { password: 'ftp-passwort-lang-123' }, idempotencyKey: 'k' }); expect(ftp.child!.details.home).toBe('/www/seite');
const up = await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'update', id: '30', data: { maxSizeBytes: 9999 }, secrets: { password: 'neues-passwort-lang-1' }, idempotencyKey: 'k' });
expect(f.state.emails.find((e) => e.id === 30)).toMatchObject({ max_size: 9999 }); expect(f.state.emails.find((e) => e.id === 30)).not.toHaveProperty('password'); void up;
const dsu = await ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'update', id: '10', data: { phpVersion: '8.2', forceHttps: false }, idempotencyKey: 'k' }); expect(dsu.child!.details).toMatchObject({ phpVersion: '8.2', forceHttps: false });
});
it('verhindert Zugriffe auf Objekte fremder Kunden (Administrator-Schlüssel!)', async () => {
const f = createFake(); const ch = mk(f).children!; const before = JSON.stringify(f.state);
for (const [kind, id] of [['domain', '20'], ['email', '31'], ['database', '41'], ['ftp', '51']] as const) {
await expect(ch.act(ctx(), { parentRef: '1', kind, op: 'delete', id, data: {}, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(ch.act(ctx(), { parentRef: '1', kind, op: 'update', id, data: { description: 'x' }, secrets: { password: 'ein-langes-passwort-1' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
}
// E-Mail auf fremder Domain anlegen, Unterdomain unter fremder Hauptdomain, Namenskonflikt mit fremdem Objekt
await expect(ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'create', data: { local: 'x', domain: 'beta.example.test' }, secrets: { password: 'ein-langes-passwort-1' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'create', data: { domain: 'www.beta.example.test', parentDomainId: 20 }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(ch.act(ctx(), { parentRef: '1', kind: 'database', op: 'create', data: { name: 'beta_db' }, secrets: { password: 'ein-langes-passwort-1' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'CONFLICT' });
expect(JSON.stringify(f.state)).toBe(before); // nichts wurde verändert
expect(f.calls.filter((c) => c.method !== 'GET').length).toBe(0);
});
it('prüft Eingaben streng und schützt Systemobjekte', async () => {
const f = createFake(); const ch = mk(f).children!; const p = { parentRef: '1', idempotencyKey: 'k' };
await expect(ch.act(ctx(), { ...p, kind: 'domain', op: 'create', data: { domain: 'kein domain name' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'email', op: 'create', data: { local: 'a b', domain: 'alpha.example.test' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'email', op: 'create', data: { local: 'ok', domain: 'alpha.example.test' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // Passwort fehlt
await expect(ch.act(ctx(), { ...p, kind: 'ftp', op: 'create', data: { home: '/etc' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'ftp', op: 'create', data: { home: '/www/../etc' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'database', op: 'create', data: { name: 'Bad Name!' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'domain', op: 'delete', id: '11', data: {} })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // System-Domain
await expect(ch.act(ctx(), { ...p, kind: 'certificate', op: 'delete', id: '60', data: {} })).rejects.toMatchObject({ code: 'UNSUPPORTED' });
await expect(ch.act(ctx(), { ...p, kind: 'email', op: 'update', id: '30', data: {} })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // keine Änderung
expect(f.calls.filter((c) => c.method !== 'GET').length).toBe(0);
});
it('Löschen eigener Objekte ist idempotent', async () => {
const f = createFake(); const ch = mk(f).children!;
await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'delete', id: '30', data: {}, idempotencyKey: 'k' }); expect(f.state.emails.map((e) => e.id)).toEqual([31]);
await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'delete', id: '30', data: {}, idempotencyKey: 'k' }); // bereits gelöscht
await ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'delete', id: '10', data: {}, idempotencyKey: 'k' }); expect(f.state.domains.some((d) => d.id === 10)).toBe(false);
});
});
describe('KeyHelp-Connector: Kunden übernehmen und Tarife anlegen', () => {
it('liest Kunden mit Kontaktdaten und Tarif (ohne Geheimnisse)', async () => {
const f = createFake(); f.state.clients[0]!.contact_data = { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha', telephone: '0123 4567', address: 'Weg 1', zip: '10115', city: 'Berlin', country: 'DE', client_id: 'K-77' };
const list = await mk(f).listCustomers!(ctx());
expect(list[0]).toEqual({ externalRef: '1', displayName: 'Alpha GmbH', company: 'Alpha GmbH', firstName: 'Anna', lastName: 'Alpha', email: 'alpha@example.test', phone: '0123 4567',
address: { street: 'Weg 1', zip: '10115', city: 'Berlin', state: null, country: 'DE' }, legacyNumber: 'K-77', planRef: 'plan:1', planName: 'Starter', state: 'active', createdAt: '2026-01-05T10:00:00.000Z' });
expect(list[1]).toMatchObject({ displayName: 'beta', company: null, planName: 'Business', state: 'suspended' });
expect(JSON.stringify(list)).not.toMatch(/GEHEIM|password/i);
expect(await mk(f).capabilities(ctx())).toEqual(expect.arrayContaining(['customers.list', 'catalog.write']));
});
it('legt neue Tarife an: Größen in Byte, Eingaben geprüft, "unbegrenzt" nur wenn die Instanz es kennt', async () => {
const f = createFake(); const c = mk(f);
const item = await c.createCatalogItem!(ctx(), { name: 'Neu 50', limits: { diskSpaceGb: 50, trafficGb: 500, domains: 5, emailAccounts: 20, databases: 5, ftpUsers: 3 }, permissions: { ftp: true, ssh: false } });
const post = f.calls.find((x) => x.method === 'POST' && x.path === '/hosting-plans')!.body;
expect(post).toMatchObject({ name: 'Neu 50', resources: { disk_space: 50 * 1024 ** 3, traffic: 500 * 1024 ** 3, domains: 5, email_accounts: 20, databases: 5, ftp_users: 3 }, permissions: { ftp: true, ssh: false } });
expect(item).toMatchObject({ name: 'Neu 50', fixed: true, provisioning: { hostingPlanId: expect.any(Number) } }); expect(item.features).toEqual(expect.arrayContaining(['Speicher: 50 GB', 'Domains: 5', 'FTP']));
await expect(c.createCatalogItem!(ctx(), { name: 'starter', limits: {} })).rejects.toMatchObject({ code: 'CONFLICT' }); // Name existiert (Groß-/Kleinschreibung egal)
await expect(c.createCatalogItem!(ctx(), { name: 'X', limits: { domains: -3 } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(c.createCatalogItem!(ctx(), { name: '', limits: {} })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
const u = await c.createCatalogItem!(ctx(), { name: 'Unbegrenzt', limits: { trafficGb: null, domains: 10 } }); // Instanz kennt -1 (Tarif "Business")
expect(f.calls.filter((x) => x.method === 'POST' && x.path === '/hosting-plans').pop()!.body.resources.traffic).toBe(-1); expect(u.features).toContain('Traffic: unbegrenzt');
const fresh = createFake(); fresh.state.plans.forEach((p) => Object.keys(p.resources).forEach((k) => ((p.resources as any)[k] = 5))); fresh.state.clients[1]!.resource_limits = {};
await expect(mk(fresh).createCatalogItem!(ctx(), { name: 'Ohne Unbegrenzt', limits: { trafficGb: null } })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // nicht raten
expect(fresh.calls.some((x) => x.method === 'POST')).toBe(false);
});
});

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }

View file

@ -0,0 +1,21 @@
{
"name": "@kc/connector-licensing",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@kc/connector-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,237 @@
import { ConnectorError, CONTRACT_VERSION, httpJson, maskKey, type ActionName, type Capability, type CatalogItem, type Connector, type ConnectorContext, type HttpOptions, type NormalizedResource, type ResourceState } from '@kc/connector-sdk';
/** Rohdaten des eigenen Lizenzsystems (FastAPI, siehe /var/www/html/licensing). Bleiben im Connector. */
interface RawActivation { hardware_id: string; ip_address?: string | null; last_seen_ip?: string | null; activated_at: string; last_seen_at: string }
interface RawLicense {
id: number; program_id: number; product_id?: number | null; license_key: string; user_limit: number | null; duration_type: string; starts_at: string | null; expires_at: string | null; is_active: boolean;
status?: string | null; blocked?: boolean | null; suspended_at?: string | null; revoked_at?: string | null; license_type?: string | null; activation_limit?: number | null;
activation?: RawActivation | null; activations?: RawActivation[] | null; product?: { name?: string | null } | null;
}
interface RawProduct { id: number; name: string; description?: string | null; price?: string | number | null; currency?: string | null; duration_type?: string | null; user_limit?: number | null; is_active?: boolean; features?: string | null; modules?: string | null; badge?: string | null }
interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
interface RawProgram { id: number; name: string; description?: string | null }
/** Das Lizenzsystem speichert naive Ortszeit (datetime.now()); wir wandeln sie in UTC um. */
export function localToUtcIso(naive: string | null | undefined, tz: string): string | null {
if (!naive) return null;
if (/[zZ]|[+-]\d\d:?\d\d$/.test(naive)) return new Date(naive).toISOString();
const [d, t = '00:00:00'] = naive.split('T');
const asUtc = new Date(`${d}T${t.split('.')[0]}Z`);
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(asUtc);
const g = (n: string) => parts.find((p) => p.type === n)!.value;
const shown = Date.UTC(+g('year'), +g('month') - 1, +g('day'), +g('hour'), +g('minute'), +g('second'));
return new Date(asUtc.getTime() - (shown - asUtc.getTime())).toISOString();
}
const toLocalNaive = (iso: string, tz: string): string => {
const p = new Intl.DateTimeFormat('en-CA', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(new Date(iso));
const g = (n: string) => p.find((x) => x.type === n)!.value;
return `${g('year')}-${g('month')}-${g('day')}T${g('hour')}:${g('minute')}:${g('second')}`;
};
/** Edition wie im Familytool: Präfix des Schlüssels (PREMIUM-, TRIAL-, LIFETIME…), sonst UNLIMITED. */
export const editionOf = (key: string): string => { const k = key.toUpperCase(); return k.startsWith('LIFETIME') ? 'LIFETIME' : k.startsWith('PREMIUM-') ? 'PREMIUM' : k.startsWith('TRIAL-') ? 'TRIAL' : 'UNLIMITED'; };
export interface LicensingDeps extends HttpOptions { now?: () => Date }
// Token-Cache hält das Promise, damit parallele Anfragen nur einen Login auslösen
const PREFIXES = ['PREMIUM', 'TRIAL', 'LIFETIME'];
const tokenCache = new Map<string, { token: Promise<string>; at: number }>();
export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
const now = deps.now ?? (() => new Date());
const featureCache = new Map<string, { features: string[]; at: number }>();
const base = (ctx: ConnectorContext) => {
const u = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '');
if (!/^https?:\/\//.test(u)) throw new ConnectorError('BAD_CONFIG', 'baseUrl fehlt');
return u;
};
const tz = (ctx: ConnectorContext) => String(ctx.config.timezone ?? 'Europe/Berlin');
/** Neuere Lizenzsysteme liefern UTC ("utc_timestamps"); sonst gilt die konfigurierte Ortszeit. */
const zoneOf = async (ctx: ConnectorContext) => ((await features(ctx)).includes('utc_timestamps') ? 'UTC' : tz(ctx));
/** Erweiterungen des Lizenzsystems (Feld "features" in GET /): key_prefix, explicit_expiry. Ältere Versionen liefern keine. */
async function features(ctx: ConnectorContext): Promise<string[]> {
const key = base(ctx); const c = featureCache.get(key);
if (c && Date.now() - c.at < 60_000) return c.features;
try {
const r = await httpJson<{ features?: unknown }>('GET', `${key}/`, {}, { ...deps, retries: 0, timeoutMs: 5000 });
const f = Array.isArray(r?.features) ? r.features.filter((x): x is string => typeof x === 'string') : [];
featureCache.set(key, { features: f, at: Date.now() }); return f;
} catch { return []; }
}
const hasToken = (ctx: ConnectorContext) => !!ctx.secrets.token;
const hasAuth = (ctx: ConnectorContext) => hasToken(ctx) || !!(ctx.secrets.username && ctx.secrets.password);
async function authHeaders(ctx: ConnectorContext, force = false): Promise<Record<string, string>> {
if (hasToken(ctx)) return { authorization: `Bearer ${ctx.secrets.token}` }; // Service-Token: kein Login nötig
if (!hasAuth(ctx)) return {};
// Cache nur im Speicher; ein Passwortwechsel entwertet ihn automatisch.
const key = `${base(ctx)}|${ctx.secrets.username}|${ctx.secrets.password}`; const c = tokenCache.get(key);
if (!force && c && Date.now() - c.at < 20 * 60_000) return { authorization: `Bearer ${await c.token}` };
const token = httpJson<{ access_token?: string }>('POST', `${base(ctx)}/token`, { form: { username: ctx.secrets.username!, password: ctx.secrets.password! } }, deps)
.then((r) => { if (!r?.access_token) throw new ConnectorError('INVALID_RESPONSE', 'kein Token'); return r.access_token; });
tokenCache.set(key, { token, at: Date.now() });
try { return { authorization: `Bearer ${await token}` }; } catch (e) { tokenCache.delete(key); throw e; }
}
/** Anfrage mit einmaligem Re-Login bei abgelaufenem Token. */
async function call<T>(ctx: ConnectorContext, method: 'GET' | 'PUT' | 'POST', path: string, body?: unknown): Promise<T> {
const go = async (force: boolean) => httpJson<T>(method, `${base(ctx)}${path}`, { headers: await authHeaders(ctx, force), body }, deps);
try { return await go(false); }
catch (e) { if (e instanceof ConnectorError && e.code === 'AUTH_FAILED' && hasAuth(ctx) && !hasToken(ctx)) return go(true); throw e; }
}
/** Zustand: gesperrt/widerrufen/blockiert erkennt auch die neuen Felder des Lizenzsystems (status, blocked, suspended_at, revoked_at). */
const stateOf = (l: RawLicense, until: string | null): ResourceState => {
const st = String(l.status ?? '').toLowerCase();
if (l.is_active === false || l.blocked || l.suspended_at || l.revoked_at || ['suspended', 'revoked', 'blocked', 'inactive', 'canceled', 'cancelled'].includes(st)) return 'suspended';
if (st === 'expired' || (until && new Date(until) < now())) return 'expired';
return 'active';
};
const map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => {
const until = localToUtcIso(l.expires_at, zone);
const program = programs.get(l.program_id) ?? `Programm ${l.program_id}`;
const act = l.activation ?? l.activations?.[0] ?? null;
return {
externalRef: String(l.id), type: 'license', name: `${program}${l.product?.name ? ` ${l.product.name}` : ''} · ${maskKey(l.license_key)}`, state: stateOf(l, until),
validFrom: localToUtcIso(l.starts_at, zone), validUntil: until,
limits: { users: l.user_limit },
details: {
program, product: l.product?.name ?? null, licenseKeyMasked: maskKey(l.license_key), durationType: l.duration_type, edition: l.product?.name ?? editionOf(l.license_key),
providerStatus: l.status ?? null, activationsUsed: l.activations?.length ?? (l.activation ? 1 : 0), activationLimit: l.activation_limit ?? null,
activation: act ? { hardwareIdMasked: maskKey(act.hardware_id), activatedAt: localToUtcIso(act.activated_at, zone), lastCheckAt: localToUtcIso(act.last_seen_at, zone), lastSeenIp: act.last_seen_ip ?? null } : null,
},
};
};
return {
contractVersion: CONTRACT_VERSION, key: 'licensing', displayName: 'Lizenzsystem',
configFields: [
{ name: 'baseUrl', label: 'Basis-URL', required: true, placeholder: 'http://127.0.0.1:8001' },
{ name: 'timezone', label: 'Zeitzone des Lizenzsystems', placeholder: 'Europe/Berlin', advanced: true, help: 'Nur bei älteren Lizenzsystemen relevant (neuere liefern UTC).' },
{ name: 'token', label: 'Service-Token (empfohlen, nur die nötigen Rechte)', secret: true },
{ name: 'username', label: 'Alternativ: API-Benutzer', secret: true, advanced: true, help: 'Nur nötig, wenn Sie keinen Service-Token verwenden.' },
{ name: 'password', label: 'Alternativ: API-Passwort', secret: true, advanced: true },
],
async capabilities(ctx) {
const c: Capability[] = ['catalog.list', 'resources.list', 'resources.get', 'status.read'];
if (hasAuth(ctx)) c.push('lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.extend', 'lifecycle.create', 'secret.reveal'); // Änderungen brauchen einen authentifizierten Benutzer
const f = await features(ctx);
if (f.includes('key_prefix')) c.push('license.key_prefix');
if (f.includes('explicit_expiry')) c.push('license.expiry');
if (f.includes('customer_info')) c.push('license.customer_info');
return c;
},
async healthCheck(ctx) {
const t0 = Date.now();
try { await httpJson('GET', `${base(ctx)}/`, {}, { ...deps, retries: 0, timeoutMs: 5000 }); return { ok: true, latencyMs: Date.now() - t0 }; }
catch (e) { return { ok: false, latencyMs: Date.now() - t0, message: e instanceof ConnectorError ? e.message : 'Unbekannter Fehler' }; }
},
/**
* Produktvorlagen aus dem Lizenzsystem: bevorzugt dessen Produktkatalog (Programm → Gruppe → Produkt mit Preis, Dauer, Features),
* sonst die Programme mit Vorschlägen aus bestehenden Lizenzen. Programm-API-Keys und Produktschlüssel werden nie übernommen.
*/
async listCatalog(ctx) {
const DUR: Record<string, string> = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'Unbegrenzt' };
const programs = await call<RawProgram[]>(ctx, 'GET', '/programs/?limit=1000');
if (!Array.isArray(programs)) throw new ConnectorError('INVALID_RESPONSE');
const names = new Map(programs.map((p) => [p.id, p.name]));
let groups: RawGroup[] | null = null;
try { const g = await call<RawGroup[]>(ctx, 'GET', '/products/groups'); groups = Array.isArray(g) ? g : null; }
catch (e) { if (!(e instanceof ConnectorError && e.code === 'NOT_FOUND')) throw e; }
const items: CatalogItem[] = [];
for (const g of groups ?? []) for (const p of g.products ?? []) {
const dur = String(p.duration_type ?? 'MONTH');
const cents = Math.round(Number(p.price ?? 0) * 100);
items.push({
externalRef: `product:${p.id}`, name: g.name === p.name ? p.name : `${g.name} – ${p.name}`, description: p.description || null, category: 'license',
group: g.name, program: names.get(g.program_id) ?? `Programm ${g.program_id}`, badge: p.badge ?? null, providerActive: p.is_active !== false && g.is_active !== false,
features: (p.features ?? '').split('\n').map((x) => x.trim()).filter(Boolean),
price: Number.isFinite(cents) ? { cents, currency: p.currency ?? 'EUR' } : undefined, interval: dur === 'MONTH' ? 'monthly' : dur === 'YEAR' ? 'yearly' : 'once',
meta: { modules: p.modules ?? null, durationType: dur, userLimit: p.user_limit ?? null },
provisioning: { programId: g.program_id, productId: p.id, durationType: dur, userLimit: p.user_limit ?? null },
});
}
if (items.length) return items;
// Ältere Lizenzsysteme ohne Produktkatalog: Programme + Vorschläge aus bestehenden Lizenzen
const licenses = await call<RawLicense[]>(ctx, 'GET', '/licenses/?limit=1000');
if (!Array.isArray(licenses)) throw new ConnectorError('INVALID_RESPONSE');
return programs.map((p): CatalogItem => {
const counts = new Map<string, { durationType: string; userLimit: number | null; count: number }>();
for (const l of licenses.filter((x) => x.program_id === p.id)) {
const k = `${l.duration_type}|${l.user_limit ?? ''}`; const e = counts.get(k) ?? { durationType: l.duration_type, userLimit: l.user_limit, count: 0 }; e.count++; counts.set(k, e);
}
const hints = [...counts.values()].sort((a, b) => b.count - a.count).slice(0, 5).map((e) => ({ label: `${DUR[e.durationType] ?? e.durationType}, ${e.userLimit === null ? 'unbegrenzt viele' : e.userLimit} Benutzer`, provisioning: { programId: p.id, durationType: e.durationType, userLimit: e.userLimit }, count: e.count }));
return { externalRef: String(p.id), name: p.name, description: p.description ?? null, category: 'license', provisioning: { programId: p.id, durationType: 'YEAR', userLimit: null }, hints };
});
},
async listResources(ctx) {
const [programs, licenses] = await Promise.all([call<RawProgram[]>(ctx, 'GET', '/programs/?limit=1000'), call<RawLicense[]>(ctx, 'GET', '/licenses/?limit=1000')]);
if (!Array.isArray(programs) || !Array.isArray(licenses)) throw new ConnectorError('INVALID_RESPONSE');
const names = new Map(programs.map((p) => [p.id, p.name])); const zone = await zoneOf(ctx);
return licenses.map((l) => map(l, names, zone));
},
/** Vollständiger Lizenzschlüssel für die berechtigte Anzeige auf Abruf (nie gespeichert, nie protokolliert). */
async reveal(ctx, ref) {
if (!hasAuth(ctx)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
const l = await call<RawLicense>(ctx, 'GET', `/licenses/${encodeURIComponent(ref)}`);
if (!l || typeof l.license_key !== 'string' || !l.license_key) throw new ConnectorError('INVALID_RESPONSE');
return [{ label: 'Lizenzschlüssel', value: l.license_key }];
},
validateProvisioning(p) {
if (!Number.isInteger(p.programId) || (p.programId as number) < 1) return 'programId (ganze Zahl) fehlt';
if (p.productId !== undefined && p.productId !== null && (!Number.isInteger(p.productId) || (p.productId as number) < 1)) return 'productId muss eine ganze Zahl ≥ 1 oder leer sein';
if (!['WEEK', 'MONTH', 'YEAR', 'UNLIMITED'].includes(String(p.durationType))) return 'durationType muss WEEK, MONTH, YEAR oder UNLIMITED sein';
if (p.userLimit !== null && p.userLimit !== undefined && (!Number.isInteger(p.userLimit) || (p.userLimit as number) < 1)) return 'userLimit muss eine ganze Zahl ≥ 1 oder leer sein';
if (p.keyPrefix !== undefined && p.keyPrefix !== null && !PREFIXES.includes(String(p.keyPrefix))) return `keyPrefix muss ${PREFIXES.join(', ')} oder leer sein`;
if (p.validityDays !== undefined && p.validityDays !== null && (!Number.isInteger(p.validityDays) || (p.validityDays as number) < 1 || (p.validityDays as number) > 3650)) return 'validityDays muss eine ganze Zahl zwischen 1 und 3650 sein';
return null;
},
/** Legt eine Lizenz an (POST /licenses/). Nicht idempotent: Aufrufer wiederholt nur bei sicher nicht gesendeten Anfragen. */
async provision(ctx, req) {
if (!hasAuth(ctx)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
const err = this.validateProvisioning!(req.params); if (err) throw new ConnectorError('BAD_CONFIG', err);
const programs = await call<RawProgram[]>(ctx, 'GET', '/programs/?limit=1000');
if (!Array.isArray(programs)) throw new ConnectorError('INVALID_RESPONSE');
if (!programs.some((p) => p.id === req.params.programId)) throw new ConnectorError('BAD_CONFIG', `Programm ${String(req.params.programId)} existiert nicht`);
const prefix = req.params.keyPrefix ? String(req.params.keyPrefix) : null; const days = req.params.validityDays ? Number(req.params.validityDays) : null;
// Edition/Ablauf dürfen NIE stillschweigend verloren gehen: sonst bekäme der Kunde eine andere Stufe als bestellt.
if (prefix || days) {
const f = await features(ctx);
if (prefix && !f.includes('key_prefix')) throw new ConnectorError('BAD_CONFIG', 'Das Lizenzsystem unterstützt noch keine Schlüssel-Präfixe (Edition). Bitte zuerst das Lizenzsystem erweitern.');
if (days && !f.includes('explicit_expiry')) throw new ConnectorError('BAD_CONFIG', 'Das Lizenzsystem unterstützt noch kein festes Ablaufdatum.');
}
const zone = await zoneOf(ctx); const productId = req.params.productId ? Number(req.params.productId) : null;
const body: Record<string, unknown> = { program_id: req.params.programId, user_limit: req.params.userLimit ?? null, duration_type: req.params.durationType, is_active: true };
if (productId) body.product_id = productId; // Produkt (Edition/Plan) des Lizenzsystems
// Kunde und Herkunft nur senden, wenn das Lizenzsystem sie kennt (Feature "customer_info"); ältere Systeme würden sie ignorieren
if (req.context && (await features(ctx)).includes('customer_info')) {
const c = req.context;
Object.assign(body, { source: c.source, customer_name: c.customerName, customer_email: c.customerEmail, customer_contact: c.contactName, customer_reference: c.customerNumber, order_ref: c.orderNumber, external_ref: c.contractNumber });
}
if (prefix) body.key_prefix = prefix;
if (days) { const end = new Date(now().getTime() + days * 86400000).toISOString(); body.expires_at = zone === 'UTC' ? end : toLocalNaive(end, zone); }
const created = await call<RawLicense>(ctx, 'POST', '/licenses/', body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE');
if (prefix && !String(created.license_key ?? '').toUpperCase().startsWith(`${prefix}-`)) throw new ConnectorError('INVALID_RESPONSE', `Lizenz ${created.id} wurde ohne das Präfix ${prefix} angelegt`);
// Das Produkt darf nie stillschweigend verloren gehen (sonst bekäme der Kunde einen anderen Plan als bestellt)
if (productId && created.product_id !== productId) throw new ConnectorError('INVALID_RESPONSE', `Lizenz ${created.id} wurde ohne das bestellte Produkt ${productId} angelegt`);
return { resource: map(created, new Map(programs.map((p) => [p.id, p.name])), zone) };
},
async execute(ctx, req) {
if (!hasAuth(ctx)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
const id = encodeURIComponent(req.externalRef); const f = await features(ctx); const zone = f.includes('utc_timestamps') ? 'UTC' : tz(ctx);
const until = req.action === 'extend' ? String(req.params?.until ?? '') : '';
if (req.action === 'extend' && Number.isNaN(Date.parse(until))) throw new ConnectorError('BAD_CONFIG', 'until fehlt');
if (!['suspend', 'unsuspend', 'extend'].includes(req.action)) throw new ConnectorError('UNSUPPORTED');
// Neuere Lizenzsysteme: eigene Lebenszyklus-Endpunkte (Status, Zeitstempel und Protokoll bleiben konsistent). Alle setzen einen Zielzustand.
if (f.includes('lifecycle')) {
const reason = 'Kundencenter';
const r = await call<{ license?: RawLicense }>(ctx, 'POST', `/licenses/${id}/${req.action}`, req.action === 'extend' ? { until: new Date(until).toISOString(), reason } : { reason });
if (!r?.license || typeof r.license.id !== 'number') throw new ConnectorError('INVALID_RESPONSE');
return { resource: map(r.license, new Map(), zone) };
}
const body = req.action === 'suspend' ? { is_active: false } : req.action === 'unsuspend' ? { is_active: true } : { expires_at: zone === 'UTC' ? new Date(until).toISOString() : toLocalNaive(until, zone) };
const l = await call<RawLicense>(ctx, 'PUT', `/licenses/${id}`, body);
return { resource: map(l, new Map(), zone) };
},
};
}
export const licensingConnector = createLicensingConnector();

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { ConnectorError } from '@kc/connector-sdk';
import { runContract } from '@kc/connector-sdk/dist/contract.js';
import { createLicensingConnector, localToUtcIso } from '../src/index.js';
// Anonymisierte Beispielantworten des Lizenzsystems (Struktur wie /licenses/ und /programs/).
const PROGRAMS = [{ id: 1, name: 'Beispiel-Tool', description: null, api_key: 'aaaaaaaa-0000-4000-8000-000000000001' }];
const LICENSES = [
{ id: 10, program_id: 1, license_key: '11111111-2222-4333-8444-555555555555', user_limit: 5, duration_type: 'YEAR', starts_at: '2026-01-01T10:00:00', expires_at: '2027-01-01T10:00:00', is_active: true, activation: { id: 1, license_id: 10, hardware_id: 'HW-ABCDEF123456', ip_address: '203.0.113.5', last_seen_ip: '203.0.113.5', activated_at: '2026-01-02T08:00:00', last_seen_at: '2026-09-25T07:00:00' } },
{ id: 11, program_id: 1, license_key: '66666666-7777-4888-8999-000000000000', user_limit: null, duration_type: 'MONTH', starts_at: '2025-01-01T10:00:00', expires_at: '2025-01-31T10:00:00', is_active: true, activation: null },
{ id: 12, program_id: 1, license_key: 'abcdefab-cdef-4abc-8def-abcdefabcdef', user_limit: 1, duration_type: 'UNLIMITED', starts_at: null, expires_at: null, is_active: false, activation: null },
];
const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
const CREATED = { id: 99, program_id: 1, license_key: 'deadbeef-dead-4bee-8fde-adbeefdeadbe', user_limit: 3, duration_type: 'YEAR', starts_at: '2026-09-26T12:00:00', expires_at: '2027-09-26T12:00:00', is_active: true, activation: null };
let FEATURES: string[] | null = null;
const fakeFetch = (log: { method: string; url: string; body?: string; auth?: string }[] = [], failFirst = 0) => {
let fails = failFirst;
return (async (url: string, init: RequestInit) => {
const u = new URL(url); const h = init.headers as Record<string, string>;
log.push({ method: init.method!, url: u.pathname, body: init.body as string, auth: h.authorization });
if (fails-- > 0) return json({}, 503);
if (u.pathname === '/') return json({ message: 'ok', ...(FEATURES ? { features: FEATURES } : {}) });
if (u.pathname === '/token') return (init.body as string).includes('secret') ? json({ access_token: 'tok' }) : json({}, 401);
if (u.pathname === '/programs/') return json(PROGRAMS);
if (u.pathname === '/licenses/' && init.method === 'POST') { const b = JSON.parse(init.body as string); return json(b.key_prefix && !(FEATURES ?? []).includes('key_prefix') ? CREATED : { ...CREATED, license_key: b.key_prefix ? `${b.key_prefix}-deadbeef-dead-4bee-8fde-adbeefdeadbe` : CREATED.license_key, expires_at: b.expires_at ?? CREATED.expires_at }, 201); }
if (u.pathname === '/licenses/') return json(LICENSES);
if (u.pathname.startsWith('/licenses/') && init.method === 'PUT') return json({ ...LICENSES[0], ...JSON.parse(init.body as string) });
return json({}, 404);
}) as unknown as typeof fetch;
};
const ctx = (extra: Record<string, string> = {}) => ({ config: { baseUrl: 'http://lic.test' }, secrets: extra, correlationId: 'c' });
const opts = (f: typeof fetch) => ({ fetchImpl: f, sleep: async () => {}, now: () => new Date('2026-09-26T12:00:00Z') });
describe('Lizenz-Connector', () => {
it('erfüllt den Connector-Vertrag', async () => { await runContract(expect as never, createLicensingConnector(opts(fakeFetch())), ctx()); });
it('normalisiert Status, Zeiten (Ortszeit → UTC) und maskiert Schlüssel', async () => {
const list = await createLicensingConnector(opts(fakeFetch())).listResources(ctx());
expect(list.map((r) => r.state)).toEqual(['active', 'expired', 'suspended']);
expect(list[0]!.validUntil).toBe('2027-01-01T09:00:00.000Z'); // Berlin Winterzeit UTC+1
expect(list[0]!.name).toBe('Beispiel-Tool · 1111…5555');
expect(JSON.stringify(list)).not.toContain('11111111-2222');
expect(JSON.stringify(list)).not.toContain('aaaaaaaa-0000'); // Program-API-Key gelangt nie in die Ausgabe
});
it('meldet Schreib-Fähigkeiten nur mit API-Benutzer', async () => {
const c = createLicensingConnector(opts(fakeFetch()));
expect(await c.capabilities(ctx())).not.toContain('lifecycle.suspend');
expect(await c.capabilities(ctx({ username: 'u', password: 'secret' }))).toContain('lifecycle.suspend');
});
it('wiederholt GET bei 503 mit Backoff und gibt dann auf', async () => {
const log: any[] = [];
expect((await createLicensingConnector(opts(fakeFetch(log, 2))).listResources(ctx())).length).toBe(3);
await expect(createLicensingConnector(opts(fakeFetch([], 99))).listResources(ctx())).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', retryable: true });
});
it('führt Aktionen als absoluten Zielzustand aus (idempotent) und wiederholt Schreibzugriffe nicht', async () => {
const log: any[] = []; const c = createLicensingConnector(opts(fakeFetch(log)));
const r = await c.execute!(ctx({ username: 'u', password: 'secret' }), { action: 'suspend', externalRef: '10', idempotencyKey: 'k' });
expect(r.resource?.state).toBe('suspended');
expect(log.find((l) => l.method === 'PUT')!.body).toBe('{"is_active":false}');
await expect(c.execute!(ctx(), { action: 'suspend', externalRef: '10', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'UNSUPPORTED' });
const w: any[] = []; const failing = createLicensingConnector(opts(fakeFetch(w, 5)));
await expect(failing.execute!(ctx({ username: 'u', password: 'secret' }), { action: 'unsuspend', externalRef: '10', idempotencyKey: 'k2' })).rejects.toBeInstanceOf(ConnectorError);
expect(w.filter((l) => l.method === 'PUT').length).toBe(1);
});
it('legt Lizenzen an, validiert Parameter und wiederholt die Anlage nie', async () => {
const log: any[] = []; const c = createLicensingConnector(opts(fakeFetch(log)));
const auth = ctx({ username: 'u', password: 'secret' });
expect(c.validateProvisioning!({ programId: 1, durationType: 'YEAR', userLimit: 3 })).toBeNull();
expect(c.validateProvisioning!({ programId: 0, durationType: 'YEAR' })).toMatch(/programId/);
expect(c.validateProvisioning!({ programId: 1, durationType: 'DAY' })).toMatch(/durationType/);
expect(await c.capabilities(ctx())).not.toContain('lifecycle.create');
const r = await c.provision!(auth, { params: { programId: 1, durationType: 'YEAR', userLimit: 3 }, label: 'X', idempotencyKey: 'k' });
expect(r.resource.externalRef).toBe('99'); expect(r.resource.name).toBe('Beispiel-Tool · dead…dbe'.replace('…dbe', '…' + 'deadbeef-dead-4bee-8fde-adbeefdeadbe'.slice(-4)));
expect(JSON.stringify(r)).not.toContain('deadbeef-dead');
expect(log.find((l) => l.method === 'POST' && l.url === '/licenses/')!.body).toBe('{"program_id":1,"user_limit":3,"duration_type":"YEAR","is_active":true}');
await expect(c.provision!(auth, { params: { programId: 5, durationType: 'YEAR' }, label: 'X', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
// Anlage bei 503: genau ein POST, kein automatischer Retry
const w: any[] = []; const f503 = (async (url: string, init: RequestInit) => { w.push(init.method + ' ' + new URL(url).pathname); const u = new URL(url); if (u.pathname === '/token') return json({ access_token: 't' }); if (u.pathname === '/programs/') return json(PROGRAMS); return json({}, 503); }) as unknown as typeof fetch;
await expect(createLicensingConnector(opts(f503)).provision!(auth, { params: { programId: 1, durationType: 'YEAR' }, label: 'X', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', ambiguous: true });
expect(w.filter((x) => x === 'POST /licenses/').length).toBe(1);
});
it('liefert Programme als Produktvorlagen mit Vorschlägen, ohne Programm-API-Key', async () => {
const items = await createLicensingConnector(opts(fakeFetch())).listCatalog!(ctx());
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ externalRef: '1', name: 'Beispiel-Tool', category: 'license', provisioning: { programId: 1, durationType: 'YEAR', userLimit: null } });
expect(items[0]!.hints!.map((h) => h.label)).toEqual(expect.arrayContaining(['Jahr, 5 Benutzer', 'Monat, unbegrenzt viele Benutzer', 'Unbegrenzt, 1 Benutzer']));
expect(JSON.stringify(items)).not.toContain('aaaaaaaa-0000'); // Program-API-Key
expect(JSON.stringify(items)).not.toContain('11111111-2222'); // Lizenzschlüssel
});
it('Edition/Ablauf: ohne Erweiterung im Lizenzsystem wird NICHT angelegt; mit Erweiterung mit Präfix und Ablaufdatum', async () => {
const auth = ctx({ username: 'u', password: 'secret' });
const params = { programId: 1, durationType: 'WEEK', userLimit: null, keyPrefix: 'TRIAL', validityDays: 14 };
// 1) altes Lizenzsystem (keine features): Anlage wird vor dem POST abgelehnt
FEATURES = null; const log1: any[] = [];
await expect(createLicensingConnector(opts(fakeFetch(log1))).provision!(auth, { params, label: 'X', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'BAD_CONFIG', notSent: true });
expect(log1.some((l) => l.method === 'POST' && l.url === '/licenses/')).toBe(false);
expect(await createLicensingConnector(opts(fakeFetch())).capabilities(auth)).not.toContain('license.key_prefix');
// 2) erweitertes Lizenzsystem
FEATURES = ['key_prefix', 'explicit_expiry']; const log2: any[] = [];
const c = createLicensingConnector({ ...opts(fakeFetch(log2)), now: () => new Date('2026-09-26T12:00:00Z') });
expect(await c.capabilities(auth)).toEqual(expect.arrayContaining(['license.key_prefix', 'license.expiry']));
const r = await c.provision!(auth, { params, label: 'X', idempotencyKey: 'k2' });
const post = JSON.parse(log2.find((l) => l.method === 'POST' && l.url === '/licenses/')!.body);
expect(post).toMatchObject({ key_prefix: 'TRIAL', duration_type: 'WEEK' }); expect(post.expires_at).toBe('2026-10-10T14:00:00'); // 14 Tage, Berlin-Ortszeit
expect(r.resource.details).toMatchObject({ edition: 'TRIAL' });
// 3) Lizenzsystem ignoriert das Präfix (meldet features, liefert aber Schlüssel ohne Präfix): als unklar/fehlerhaft melden, nie still übergehen
const ignoring = (async (url: string, init: RequestInit) => { const u = new URL(url); if (u.pathname === '/') return json({ features: ['key_prefix'] }); if (u.pathname === '/token') return json({ access_token: 't' }); if (u.pathname === '/programs/') return json(PROGRAMS); return json(CREATED, 201); }) as unknown as typeof fetch;
await expect(createLicensingConnector(opts(ignoring)).provision!(auth, { params: { programId: 1, durationType: 'YEAR', keyPrefix: 'PREMIUM' }, label: 'X', idempotencyKey: 'k3' })).rejects.toMatchObject({ code: 'INVALID_RESPONSE', ambiguous: true });
expect(c.validateProvisioning!({ programId: 1, durationType: 'YEAR', keyPrefix: 'GOLD' })).toMatch(/keyPrefix/);
expect(c.validateProvisioning!({ programId: 1, durationType: 'YEAR', validityDays: 0 })).toMatch(/validityDays/);
FEATURES = null;
});
it('erkennt falsche Zugangsdaten und Nichterreichbarkeit', async () => {
await expect(createLicensingConnector(opts(fakeFetch())).listResources(ctx({ username: 'u', password: 'falsch' }))).rejects.toMatchObject({ code: 'AUTH_FAILED' });
const down = createLicensingConnector({ ...opts(fakeFetch()), fetchImpl: (async () => { throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); }) as never });
const h = await down.healthCheck(ctx()); expect(h.ok).toBe(false); expect(h.message).toContain('nicht erreichbar');
});
it('rechnet Ortszeit korrekt um (Sommer-/Winterzeit)', () => {
expect(localToUtcIso('2026-07-01T12:00:00', 'Europe/Berlin')).toBe('2026-07-01T10:00:00.000Z');
expect(localToUtcIso('2026-01-01T12:00:00', 'Europe/Berlin')).toBe('2026-01-01T11:00:00.000Z');
});
});

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { runContract } from '@kc/connector-sdk/dist/contract.js';
import { createLicensingConnector } from '../src/index.js';
// Anonymisierte Antworten eines neueren Lizenzsystems (Produktkatalog, Lifecycle-Endpunkte, Service-Tokens, UTC).
let FEATURES_OVERRIDE: string[] | null = null;
const FEATURES = ['key_prefix', 'explicit_expiry', 'service_tokens', 'lifecycle', 'multi_activation', 'audit', 'utc_timestamps'];
const PROGRAMS = [{ id: 1, name: 'Familytool', description: null }, { id: 2, name: 'RP-Framework', description: null }];
const GROUPS = [
{ id: 2, name: 'Premium', program_id: 1, is_active: true, products: [
{ id: 3, name: 'Monatlich', description: 'Premium-Funktionen', price: '2.99', currency: 'EUR', duration_type: 'MONTH', user_limit: null, is_active: true, features: 'Stundenplan\nAufräumplan', modules: 'a,b', badge: null, product_key: 'SECRET-PRODUCT-KEY', public_key: 'SECRET-PUBLIC' },
{ id: 4, name: 'Jährlich', price: '29.90', currency: 'EUR', duration_type: 'YEAR', user_limit: null, is_active: true, features: '', modules: '' }] },
{ id: 4, name: 'Testen', program_id: 1, is_active: true, products: [{ id: 2, name: 'Testen', price: '0.00', currency: 'EUR', duration_type: 'UNLIMITED', user_limit: 2, is_active: true, features: 'Kalender' }] },
{ id: 6, name: 'Server-Lizenzen', program_id: 2, is_active: false, products: [{ id: 7, name: 'Starter', price: '0.00', currency: 'EUR', duration_type: 'MONTH', is_active: false }] },
];
const lic = (o: object = {}) => ({ id: 18, program_id: 1, product_id: 6, license_key: 'aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb', user_limit: null, duration_type: 'MONTH', starts_at: '2026-09-01T10:00:00Z', expires_at: '2026-10-01T10:00:00Z', is_active: true, status: 'active', blocked: false, suspended_at: null, revoked_at: null, product: { name: 'Premium' }, activation: null, activations: [], ...o });
const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
interface Call { method: string; path: string; auth?: string; body?: any }
const api = (opts: { licenses?: object[]; features?: string[] | null; log?: Call[] } = {}) => {
const log = opts.log ?? [];
return (async (url: string, init: RequestInit) => {
const u = new URL(url); const h = (init.headers ?? {}) as Record<string, string>; const body = init.body && !String(init.body).includes('=') ? JSON.parse(init.body as string) : init.body;
log.push({ method: init.method!, path: u.pathname, auth: h.authorization, body });
if (u.pathname === '/') return json({ message: 'ok', features: opts.features === undefined ? (FEATURES_OVERRIDE ?? FEATURES) : opts.features ?? undefined });
if (h.authorization !== 'Bearer svc-token-1' && u.pathname !== '/token') return json({}, 401);
if (u.pathname === '/programs/') return json(PROGRAMS);
if (u.pathname === '/products/groups') return json(GROUPS);
if (u.pathname === '/licenses/' && init.method === 'GET') return json(opts.licenses ?? [lic()]);
if (/^\/licenses\/\d+$/.test(u.pathname) && init.method === 'GET') return json(lic({ id: Number(u.pathname.split('/')[2]) }));
if (u.pathname === '/licenses/' && init.method === 'POST') return json(lic({ id: 99, product_id: body.product_id === 6 ? 6 : null, expires_at: body.expires_at ?? null }), 201);
const m = /^\/licenses\/(\d+)\/(suspend|unsuspend|extend)$/.exec(u.pathname);
if (m && init.method === 'POST') return json({ changed: true, action: m[2], license: lic({ id: Number(m[1]), is_active: m[2] !== 'suspend', suspended_at: m[2] === 'suspend' ? '2026-09-26T12:00:00Z' : null, status: m[2] === 'suspend' ? 'suspended' : 'active', expires_at: m[2] === 'extend' ? body.until : '2026-10-01T10:00:00Z' }) });
return json({}, 404);
}) as unknown as typeof fetch;
};
const ctx = { config: { baseUrl: 'http://lic.test', timezone: 'Europe/Berlin' }, secrets: { token: 'svc-token-1' }, correlationId: 'c' };
const mk = (o: Parameters<typeof api>[0] = {}) => createLicensingConnector({ fetchImpl: api(o), sleep: async () => {}, now: () => new Date('2026-09-26T12:00:00Z') });
describe('Lizenz-Connector (neues Lizenzsystem)', () => {
it('erfüllt den Vertrag mit Service-Token (kein Login)', async () => {
const log: Call[] = []; await runContract(expect as never, mk({ log }), ctx);
expect(log.some((l) => l.path === '/token')).toBe(false);
expect(log.filter((l) => l.path !== '/').every((l) => l.auth === 'Bearer svc-token-1')).toBe(true);
});
it('liest den Produktkatalog des Lizenzsystems (Preis, Dauer, Features) ohne Schlüssel', async () => {
const items = await mk().listCatalog!(ctx);
expect(items.map((i) => i.name)).toEqual(['Premium – Monatlich', 'Premium – Jährlich', 'Testen', 'Server-Lizenzen – Starter']);
const m = items[0]!;
expect(m).toMatchObject({ externalRef: 'product:3', group: 'Premium', program: 'Familytool', interval: 'monthly', price: { cents: 299, currency: 'EUR' }, providerActive: true, features: ['Stundenplan', 'Aufräumplan'],
provisioning: { programId: 1, productId: 3, durationType: 'MONTH', userLimit: null } });
expect(items[1]).toMatchObject({ interval: 'yearly', price: { cents: 2990 } });
expect(items[2]).toMatchObject({ interval: 'once', price: { cents: 0 }, provisioning: { userLimit: 2 } });
expect(items[3]!.providerActive).toBe(false);
expect(JSON.stringify(items)).not.toMatch(/SECRET|product_key|public_key/);
});
it('erkennt gesperrte, widerrufene und blockierte Lizenzen sowie Ablauf', async () => {
const list = await mk({ licenses: [lic({ id: 1 }), lic({ id: 2, blocked: true }), lic({ id: 3, suspended_at: '2026-09-20T00:00:00Z', status: 'suspended', is_active: true }), lic({ id: 4, revoked_at: '2026-09-20T00:00:00Z' }), lic({ id: 5, expires_at: '2026-09-01T00:00:00Z' }), lic({ id: 6, status: 'revoked' })] }).listResources(ctx);
expect(list.map((r) => r.state)).toEqual(['active', 'suspended', 'suspended', 'suspended', 'expired', 'suspended']);
expect(list[0]!.validUntil).toBe('2026-10-01T10:00:00.000Z'); // UTC bleibt UTC (keine Ortszeit-Verschiebung)
expect(list[0]!.details).toMatchObject({ product: 'Premium', edition: 'Premium' });
expect(JSON.stringify(list)).not.toContain('aaaaaaaa-1111');
});
it('nutzt die Lebenszyklus-Endpunkte für Sperren, Entsperren und Verlängern', async () => {
const log: Call[] = []; const c = mk({ log });
expect((await c.execute!(ctx, { action: 'suspend', externalRef: '18', idempotencyKey: 'k' })).resource?.state).toBe('suspended');
expect((await c.execute!(ctx, { action: 'unsuspend', externalRef: '18', idempotencyKey: 'k' })).resource?.state).toBe('active');
const r = await c.execute!(ctx, { action: 'extend', externalRef: '18', params: { until: '2027-01-01T00:00:00.000Z' }, idempotencyKey: 'k' });
expect(r.resource?.validUntil).toBe('2027-01-01T00:00:00.000Z');
const posts = log.filter((l) => l.method === 'POST').map((l) => l.path); expect(posts).toEqual(['/licenses/18/suspend', '/licenses/18/unsuspend', '/licenses/18/extend']);
expect(log.find((l) => l.path.endsWith('/extend'))!.body).toMatchObject({ until: '2027-01-01T00:00:00.000Z' });
expect(log.some((l) => l.method === 'PUT')).toBe(false);
});
it('gibt den vollständigen Lizenzschlüssel nur über reveal heraus, sonst nie', async () => {
const c = mk();
expect(await c.capabilities(ctx)).toContain('secret.reveal');
expect(await c.capabilities({ ...ctx, secrets: {} })).not.toContain('secret.reveal');
expect(await c.reveal!(ctx, '18')).toEqual([{ label: 'Lizenzschlüssel', value: 'aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb' }]);
expect(JSON.stringify(await c.listResources(ctx))).not.toContain('aaaaaaaa-1111'); // Listen enthalten den Schlüssel weiterhin nie
await expect(c.reveal!({ ...ctx, secrets: {} }, '18')).rejects.toMatchObject({ code: 'UNSUPPORTED' });
});
it('meldet Kunde und Herkunft (Kundencenter) nur, wenn das Lizenzsystem es unterstützt', async () => {
const context = { source: 'kundencenter' as const, customerNumber: 'K-10001', customerName: 'Muster GmbH', contactName: 'Max Muster', customerEmail: 'max@example.test', orderNumber: 'B-20001', contractNumber: 'V-30001' };
const params = { programId: 1, productId: 6, durationType: 'UNLIMITED', userLimit: null };
// 1) ohne Unterstützung: Felder werden NICHT gesendet
const log1: Call[] = []; await mk({ log: log1 }).provision!(ctx, { params, label: 'X', idempotencyKey: 'k', context });
const b1 = log1.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body; expect(b1).not.toHaveProperty('source'); expect(b1).not.toHaveProperty('customer_email');
expect(await mk().capabilities(ctx)).not.toContain('license.customer_info');
// 2) mit Unterstützung: alle Angaben werden gesendet
FEATURES_OVERRIDE = [...FEATURES, 'customer_info'];
try {
const log2: Call[] = []; const c = mk({ log: log2 }); expect(await c.capabilities(ctx)).toContain('license.customer_info');
await c.provision!(ctx, { params, label: 'X', idempotencyKey: 'k2', context });
expect(log2.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body).toMatchObject({ source: 'kundencenter', customer_name: 'Muster GmbH', customer_email: 'max@example.test', customer_contact: 'Max Muster', customer_reference: 'K-10001', order_ref: 'B-20001', external_ref: 'V-30001' });
} finally { FEATURES_OVERRIDE = null; }
});
it('legt Lizenzen mit Produkt an und meldet, wenn das Produkt verloren ginge', async () => {
const log: Call[] = []; const c = mk({ log });
const params = { programId: 1, productId: 6, durationType: 'UNLIMITED', userLimit: null };
expect((await c.provision!(ctx, { params, label: 'X', idempotencyKey: 'k' })).resource.externalRef).toBe('99');
expect(log.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body).toMatchObject({ program_id: 1, product_id: 6, duration_type: 'UNLIMITED' });
// Lizenzsystem ignoriert das Produkt (liefert product_id null): nie still akzeptieren
await expect(c.provision!(ctx, { params: { ...params, productId: 7 }, label: 'X', idempotencyKey: 'k2' })).rejects.toMatchObject({ code: 'INVALID_RESPONSE', ambiguous: true });
expect(c.validateProvisioning!({ ...params, productId: 0 })).toMatch(/productId/);
});
it('Testphase mit festem Ablauf sendet UTC und meldet falschen Token als Anmeldefehler', async () => {
const log: Call[] = []; const c = mk({ log });
await c.provision!(ctx, { params: { programId: 1, durationType: 'WEEK', validityDays: 14, keyPrefix: 'TRIAL' }, label: 'X', idempotencyKey: 'k' }).catch(() => undefined);
expect(log.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body.expires_at).toBe('2026-10-10T12:00:00.000Z');
await expect(mk().listResources({ ...ctx, secrets: { token: 'falsch' } })).rejects.toMatchObject({ code: 'AUTH_FAILED' });
expect(await mk().capabilities(ctx)).toEqual(expect.arrayContaining(['catalog.list', 'lifecycle.suspend', 'lifecycle.create', 'license.key_prefix']));
expect(await mk().capabilities({ ...ctx, secrets: {} })).not.toContain('lifecycle.suspend'); // ohne Zugang nur lesend
});
});

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }

View file

@ -0,0 +1,21 @@
{
"name": "@kc/connector-mock",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@kc/connector-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,60 @@
import { ConnectorError, CONTRACT_VERSION, type Connector, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk';
/**
* Mock-Connector für Entwicklung und Tests. Alle Daten sind erfundene Testdaten und klar mit "[Mock]" gekennzeichnet.
* Zustand lebt im Speicher des jeweiligen Prozesses (Worker) und geht bei Neustart verloren.
* config.simulateOutage = "true" simuliert einen Providerausfall.
*/
const state = new Map<string, Map<string, NormalizedResource>>();
const seed = (): Map<string, NormalizedResource> => new Map<string, NormalizedResource>([
['m-1', { externalRef: 'm-1', type: 'license', name: '[Mock] Beispiel-Lizenz Pro', state: 'active', validFrom: '2026-01-01T00:00:00.000Z', validUntil: '2027-01-01T00:00:00.000Z', limits: { users: 5 }, details: { mock: true } }],
['m-2', { externalRef: 'm-2', type: 'license', name: '[Mock] Beispiel-Lizenz Basic', state: 'active', validFrom: '2026-03-01T00:00:00.000Z', validUntil: '2026-12-31T00:00:00.000Z', limits: { users: 1 }, details: { mock: true } }],
['m-3', { externalRef: 'm-3', type: 'hosting_account', name: '[Mock] Hosting-Konto', state: 'active', limits: { storageMb: 10240 }, usage: { storageMb: 1234 }, details: { mock: true } }],
]);
const store = (ctx: ConnectorContext) => { const k = String(ctx.config.instance ?? 'default'); if (!state.has(k)) state.set(k, seed()); return state.get(k)!; };
const outage = (ctx: ConnectorContext) => { if (String(ctx.config.simulateOutage) === 'true') throw new ConnectorError('UNREACHABLE', 'simulierter Ausfall'); };
export const resetMock = () => state.clear();
export const mockConnector: Connector = {
contractVersion: CONTRACT_VERSION, key: 'mock', displayName: 'Mock (Testdaten)',
configFields: [{ name: 'instance', label: 'Instanzname', placeholder: 'default' }, { name: 'simulateOutage', label: 'Ausfall simulieren (true/false)', placeholder: 'false' }],
capabilities: () => ['catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.extend', 'lifecycle.terminate', 'lifecycle.create', 'license.key_prefix', 'license.expiry', 'secret.reveal'],
async healthCheck(ctx) {
try { outage(ctx); return { ok: true, latencyMs: 1, message: 'Mock', version: '1' }; } catch (e) { return { ok: false, latencyMs: 1, message: (e as ConnectorError).userMessage }; }
},
validateProvisioning(params) {
if ('failCreate' in params && typeof params.failCreate !== 'string') return 'failCreate muss Text sein';
return null;
},
/** Legt eine Mock-Ressource an. params.failCreate = "timeout" | "unreachable" simuliert Fehler (nur für Tests). */
async provision(ctx, req) {
outage(ctx);
if (req.params.failCreate === 'timeout') throw new ConnectorError('TIMEOUT');
if (req.params.failCreate === 'unreachable') throw new ConnectorError('UNREACHABLE', 'ECONNREFUSED simuliert');
const s = store(ctx);
const ref = `m-${req.idempotencyKey.slice(0, 8)}`; // gleicher Schlüssel => gleiche Ressource (Mock ist idempotent)
if (!s.has(ref)) s.set(ref, { externalRef: ref, type: 'license', name: `[Mock] ${req.label}`, state: 'active', validFrom: new Date().toISOString(), validUntil: req.params.validityDays ? new Date(Date.now() + Number(req.params.validityDays) * 86400000).toISOString() : req.params.durationType === 'YEAR' ? new Date(Date.now() + 365 * 86400000).toISOString() : req.params.durationType === 'MONTH' ? new Date(Date.now() + 30 * 86400000).toISOString() : null, limits: { users: Number(req.params.userLimit ?? 1) }, details: { mock: true, edition: String(req.params.keyPrefix ?? 'UNLIMITED'), context: req.context ?? null } });
return { resource: { ...s.get(ref)! } };
},
async reveal(ctx, ref) { outage(ctx); if (!store(ctx).has(ref)) throw new ConnectorError('NOT_FOUND'); return [{ label: 'Lizenzschlüssel', value: `MOCK-KEY-${ref}` }]; },
async listCatalog(ctx) {
outage(ctx);
return [
{ externalRef: 'mock-prog-1', name: '[Mock] Programm Alpha', description: 'Testprogramm', category: 'license', provisioning: { userLimit: 5 }, hints: [{ label: '5 Benutzer', provisioning: { userLimit: 5 }, count: 2 }] },
{ externalRef: 'mock-prog-2', name: '[Mock] Programm Beta', description: null, category: 'license', provisioning: { userLimit: 1 } },
];
},
async listResources(ctx) { outage(ctx); return [...store(ctx).values()].map((r) => ({ ...r })); },
async execute(ctx, req) {
outage(ctx);
const r = store(ctx).get(req.externalRef);
if (!r) throw new ConnectorError('NOT_FOUND');
// Zielzustand setzen: bei Wiederholung mit gleichem Schlüssel wirkungsgleich
if (req.action === 'suspend') r.state = 'suspended';
else if (req.action === 'unsuspend') r.state = 'active';
else if (req.action === 'extend') { const u = String(req.params?.until ?? ''); if (Number.isNaN(Date.parse(u))) throw new ConnectorError('BAD_CONFIG', 'until fehlt'); r.validUntil = new Date(u).toISOString(); }
else if (req.action === 'terminate') { store(ctx).delete(req.externalRef); return {}; }
else throw new ConnectorError('UNSUPPORTED');
return { resource: { ...r } };
},
};

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { runContract } from '@kc/connector-sdk/dist/contract.js';
import { mockConnector, resetMock } from '../src/index.js';
const ctx = (c: Record<string, unknown> = {}) => ({ config: { instance: 't', ...c }, secrets: {}, correlationId: 'c' });
describe('Mock-Connector', () => {
it('erfüllt den Vertrag', async () => { resetMock(); expect((await runContract(expect as never, mockConnector, ctx())).length).toBe(3); });
it('sperrt und entsperrt', async () => {
resetMock();
expect((await mockConnector.execute!(ctx(), { action: 'suspend', externalRef: 'm-1', idempotencyKey: 'a' })).resource?.state).toBe('suspended');
expect((await mockConnector.execute!(ctx(), { action: 'unsuspend', externalRef: 'm-1', idempotencyKey: 'b' })).resource?.state).toBe('active');
});
it('simuliert Ausfall', async () => {
expect((await mockConnector.healthCheck(ctx({ simulateOutage: 'true' }))).ok).toBe(false);
await expect(mockConnector.listResources(ctx({ simulateOutage: 'true' }))).rejects.toMatchObject({ code: 'UNREACHABLE', retryable: true });
});
});

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }

View file

@ -0,0 +1,18 @@
{
"name": "@kc/connector-sdk",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,25 @@
import type { Connector, ConnectorContext, NormalizedResource } from './index.js';
import { ConnectorError } from './index.js';
/**
* Vertragstest, den JEDER Connector bestehen muss (mit Mock-/Fixture-Backend).
* Wird aus den Connector-Paketen mit vitest aufgerufen: `runContract(expect, connector, ctx)`.
*/
export async function runContract(expect: (v: unknown) => any, c: Connector, ctx: ConnectorContext): Promise<NormalizedResource[]> {
expect(c.contractVersion).toBe(1);
expect(c.key).toMatch(/^[a-z][a-z0-9-]*$/);
const caps = await c.capabilities(ctx);
expect(Array.isArray(caps)).toBe(true);
const h = await c.healthCheck(ctx);
expect(typeof h.ok).toBe('boolean'); expect(typeof h.latencyMs).toBe('number');
const list = await c.listResources(ctx);
for (const r of list) {
expect(typeof r.externalRef).toBe('string'); expect(r.externalRef.length > 0).toBe(true);
expect(['active', 'suspended', 'expired', 'error', 'unknown']).toContain(r.state);
// keine Geheimnisse in Detaildaten
expect(JSON.stringify(r.details ?? {})).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
}
if (caps.includes('lifecycle.suspend')) expect(typeof c.execute).toBe('function');
expect(ConnectorError).toBeDefined();
return list;
}

View file

@ -0,0 +1,216 @@
/**
* Connector-Vertrag v1. Jeder Provider (KeyHelp, Plesk, Lizenzsystem, Mock, …) implementiert `Connector`.
* Provider-spezifische Datenmodelle dürfen den Connector nicht verlassen: nach außen gehen nur normalisierte Typen.
*/
export const CONTRACT_VERSION = 1 as const;
/** Fähigkeiten. Backend und UI bieten nur an, was der Connector meldet. */
export type Capability =
| 'customers.list' | 'catalog.write' | 'children.read' | 'children.write' | 'secret.reveal' | 'license.customer_info' | 'catalog.list' | 'license.key_prefix' | 'license.expiry' | 'resources.list' | 'resources.get' | 'status.read' | 'usage.read'
| 'lifecycle.create' | 'lifecycle.suspend' | 'lifecycle.unsuspend' | 'lifecycle.terminate' | 'lifecycle.extend'
| 'settings.update' | 'plan.change' | 'sso.login' | 'webhooks';
/** Aktionen, die über `execute` laufen (immer als persistenter Auftrag). */
export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan';
export const ACTION_CAPABILITY: Record<ActionName, Capability> = {
suspend: 'lifecycle.suspend', unsuspend: 'lifecycle.unsuspend', extend: 'lifecycle.extend', terminate: 'lifecycle.terminate', change_plan: 'plan.change',
};
/** Aktionen, die nie automatisch wiederholt werden dürfen (destruktiv). */
export const DESTRUCTIVE_ACTIONS: ReadonlySet<ActionName> = new Set(['terminate']);
export type ResourceType = 'license' | 'hosting_account' | 'domain' | 'server';
export type ResourceState = 'active' | 'suspended' | 'expired' | 'error' | 'unknown';
export interface NormalizedResource {
externalRef: string; // stabile ID beim Provider
type: ResourceType;
name: string;
state: ResourceState;
validFrom?: string | null; // ISO 8601 UTC
validUntil?: string | null;
limits?: Record<string, number | string | null>;
usage?: Record<string, number | string | null>;
/** Anzeigedaten. Geheimnisse (z. B. vollständige Lizenzschlüssel) sind bereits maskiert. */
details?: Record<string, unknown>;
}
/** Angebot/Produktvorlage beim Anbieter (z. B. ein Programm im Lizenzsystem), Grundlage für die Produktübernahme. Enthält keine Geheimnisse. */
export interface CatalogItem {
externalRef: string; name: string; description?: string | null; category: 'hosting' | 'license' | 'addon' | 'service';
/** Optionale Angaben des Anbieters, die die Übernahme vorbelegen. */
/** true: Der Anbieter legt die Provisionierung fest (nicht änderbar bei der Übernahme). */
fixed?: boolean;
group?: string; program?: string; features?: string[]; badge?: string | null; providerActive?: boolean;
price?: { cents: number; currency: string }; interval?: 'once' | 'monthly' | 'yearly'; meta?: Record<string, string | number | null>;
/** Standard-Provisionierungsparameter (Grundlage für Produkte). */
provisioning: Record<string, unknown>;
/** Vorschläge aus dem Bestand des Anbieters (z. B. übliche Laufzeit/Limit bestehender Lizenzen). */
hints?: { label: string; provisioning: Record<string, unknown>; count: number }[];
}
/** Angaben zum Kunden und Vorgang, die bei der Anlage an den Anbieter gemeldet werden können (nur wenn dieser sie unterstützt). */
export interface ProvisionContext {
source: 'kundencenter'; customerNumber: string; customerName: string; contactName: string | null; customerEmail: string | null; orderNumber: string; contractNumber: string;
}
// ---- Kunden des Anbieters (Übernahme) und neue Angebote ----
/** Ein Kunde/Konto beim Anbieter, das ins Kundencenter übernommen werden kann. Enthält keine Geheimnisse. */
export interface ImportableCustomer {
externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; phone: string | null;
address: { street: string | null; zip: string | null; city: string | null; state: string | null; country: string | null };
/** Kundennummer beim Anbieter, falls vorhanden. */ legacyNumber: string | null;
planRef: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null;
}
/** Neues Angebot (z. B. Hosting-Tarif) beim Anbieter anlegen. null = unbegrenzt. Größen in GB. */
export interface NewCatalogSpec {
name: string;
limits: { diskSpaceGb?: number | null; trafficGb?: number | null; domains?: number | null; subdomains?: number | null; emailAccounts?: number | null; emailAddresses?: number | null; emailForwardings?: number | null; databases?: number | null; ftpUsers?: number | null; scheduledTasks?: number | null };
permissions?: Record<string, boolean>;
}
// ---- Unterobjekte (z. B. Domains, Postfächer, Datenbanken eines Hosting-Kontos) ----
export type ChildKind = 'domain' | 'email' | 'database' | 'ftp' | 'certificate';
export type ChildOp = 'create' | 'update' | 'delete';
export interface NormalizedChild {
id: string; kind: ChildKind; name: string; state: 'active' | 'disabled' | 'pending' | 'error' | 'expired';
validUntil?: string | null;
/** Anzeigedaten ohne Geheimnisse (Passwörter werden nie geliefert). */
details: Record<string, unknown>;
}
export interface ChildAccess {
/** Welche Arten der Anbieter für dieses Objekt kennt und ob Schreiben möglich ist. */
kinds(ctx: ConnectorContext, parentRef: string): Promise<{ kind: ChildKind; canWrite: boolean }[]>;
/** Liest die Unterobjekte NUR des angegebenen Elternobjekts (Mandantentrennung liegt im Connector). */
list(ctx: ConnectorContext, parentRef: string, kind: ChildKind): Promise<NormalizedChild[]>;
/**
* Ändert ein Unterobjekt. Betrifft nur Objekte des Elternobjekts (Zugehörigkeit wird vor jeder Änderung geprüft).
* `secrets` (z. B. Passwörter) laufen getrennt von `data`, damit sie nie protokolliert werden.
*/
act(ctx: ConnectorContext, req: { parentRef: string; kind: ChildKind; op: ChildOp; id?: string; data: Record<string, unknown>; secrets?: Record<string, string>; idempotencyKey: string }): Promise<{ child?: NormalizedChild }>;
}
export interface Health { ok: boolean; latencyMs: number; message?: string; version?: string }
export interface ConnectorContext {
config: Record<string, unknown>;
secrets: Record<string, string>;
correlationId: string;
signal?: AbortSignal;
}
export interface Connector {
readonly contractVersion: typeof CONTRACT_VERSION;
readonly key: string;
readonly displayName: string;
/** Beschreibt Konfigurationsfelder (für die Admin-UI); secret=true wird verschlüsselt gespeichert. */
readonly configFields: { name: string; label: string; secret?: boolean; required?: boolean; placeholder?: string; /** Erklärungstext unter dem Feld */ help?: string; /** Erweiterte Einstellung (in der Oberfläche eingeklappt) */ advanced?: boolean; /** Auswahlfeld statt Freitext */ options?: { value: string; label: string }[] }[];
capabilities(ctx: ConnectorContext): Promise<Capability[]> | Capability[];
healthCheck(ctx: ConnectorContext): Promise<Health>;
listResources(ctx: ConnectorContext): Promise<NormalizedResource[]>;
/**
* Liefert Zugangsdaten/Schlüssel einer Ressource im Klartext (z. B. den vollständigen Lizenzschlüssel) für die berechtigte Anzeige auf Abruf.
* Die Werte werden nie gespeichert oder protokolliert (Capability `secret.reveal`).
*/
/** Kurzlebiger Login-Link ins Kundenpanel (Capability `sso.login`). Wird nie gespeichert. */
loginUrl?(ctx: ConnectorContext, externalRef: string): Promise<{ url: string; validForSec: number }>;
/** Unterobjekte eines Objekts (Capability `children.read`/`children.write`). */
children?: ChildAccess;
reveal?(ctx: ConnectorContext, externalRef: string): Promise<{ label: string; value: string }[]>;
/** Liest die Kunden des Anbieters für die Übernahme (Capability `customers.list`). */
listCustomers?(ctx: ConnectorContext): Promise<ImportableCustomer[]>;
/** Legt ein neues Angebot beim Anbieter an und liefert es als Vorlage zurück (Capability `catalog.write`). */
createCatalogItem?(ctx: ConnectorContext, spec: NewCatalogSpec): Promise<CatalogItem>;
/** Liest Angebote/Produktvorlagen des Anbieters aus (Capability `catalog.list`). */
listCatalog?(ctx: ConnectorContext): Promise<CatalogItem[]>;
/** Prüft Provisionierungsparameter eines Produkts (beim Speichern). Liefert eine Fehlermeldung oder null. */
validateProvisioning?(params: Record<string, unknown>): string | null;
/**
* Legt beim Provider ein neues Objekt an (Capability `lifecycle.create`). NICHT idempotent beim Provider:
* Der Aufrufer wiederholt nur bei Fehlern, bei denen die Anfrage sicher nicht verarbeitet wurde (UNREACHABLE, RATE_LIMITED).
*/
provision?(ctx: ConnectorContext, req: { params: Record<string, unknown>; label: string; idempotencyKey: string; context?: ProvisionContext }): Promise<{ resource: NormalizedResource }>;
/** Idempotent: derselbe idempotencyKey darf beim Provider nie zu einer Doppelausführung führen. */
execute?(ctx: ConnectorContext, req: { action: ActionName; externalRef: string; params?: Record<string, unknown>; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>;
}
// ---- Normalisierte Fehler --------------------------------------------------
export type ErrorCode = 'UNREACHABLE' | 'TIMEOUT' | 'AUTH_FAILED' | 'RATE_LIMITED' | 'NOT_FOUND' | 'INVALID_RESPONSE' | 'UNSUPPORTED' | 'CONFLICT' | 'UPSTREAM_ERROR' | 'BAD_CONFIG' | 'INVALID_INPUT';
/** Fehler, bei denen die Anfrage sicher NICHT beim Provider angekommen ist (Anlage darf wiederholt werden). */
export const NOT_SENT: ReadonlySet<ErrorCode> = new Set<ErrorCode>(['RATE_LIMITED']);
const RETRYABLE: ReadonlySet<ErrorCode> = new Set(['UNREACHABLE', 'TIMEOUT', 'RATE_LIMITED', 'UPSTREAM_ERROR']);
const MESSAGES: Record<ErrorCode, string> = {
UNREACHABLE: 'Der Dienst ist nicht erreichbar.', TIMEOUT: 'Der Dienst hat nicht rechtzeitig geantwortet.', AUTH_FAILED: 'Anmeldung beim Dienst fehlgeschlagen.',
RATE_LIMITED: 'Zu viele Anfragen, bitte später erneut versuchen.', NOT_FOUND: 'Objekt beim Dienst nicht gefunden.', INVALID_RESPONSE: 'Unerwartete Antwort des Dienstes.',
UNSUPPORTED: 'Diese Aktion wird nicht unterstützt.', CONFLICT: 'Konflikt mit dem Zustand beim Dienst.', UPSTREAM_ERROR: 'Der Dienst meldet einen Fehler.', BAD_CONFIG: 'Die Verbindung ist unvollständig konfiguriert.', INVALID_INPUT: 'Die Eingaben wurden vom Dienst abgelehnt.',
};
export class ConnectorError extends Error {
readonly retryable: boolean;
constructor(public code: ErrorCode, public detail?: string, public retryAfterMs?: number) {
super(detail ? `${MESSAGES[code]} (${detail})` : MESSAGES[code]);
this.retryable = RETRYABLE.has(code);
}
/** Verständliche Meldung für die Oberfläche (ohne technische Details). */
/** Die Anfrage ist sicher NICHT beim Provider verarbeitet worden (Verbindung verweigert, abgelehnt, Konfiguration falsch). */
get notSent(): boolean {
if (['RATE_LIMITED', 'AUTH_FAILED', 'BAD_CONFIG', 'UNSUPPORTED', 'NOT_FOUND', 'CONFLICT', 'INVALID_INPUT'].includes(this.code)) return true;
return this.code === 'UNREACHABLE' && /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH/.test(this.detail ?? '');
}
/** Nach diesem Fehler ist unklar, ob der Provider die Anfrage ausgeführt hat (Timeout, 5xx, ungültige Antwort). */
get ambiguous(): boolean { return !this.notSent; }
get userMessage(): string { return MESSAGES[this.code]; }
/** Wahrscheinliche Ursache und Abhilfe in einfachen Worten (aus Fehlercode und technischem Detail). */
get hint(): string {
const d = this.detail ?? '';
if (/ENOTFOUND|EAI_AGAIN/.test(d)) return 'Der Servername wird nicht aufgelöst. Adresse auf Tippfehler prüfen und ob der Name vom Kundencenter-Server aus auflösbar ist.';
if (/ECONNREFUSED/.test(d)) return 'Der Server ist erreichbar, aber an dieser Adresse/diesem Port läuft nichts. Port und https/http prüfen.';
if (/EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT/.test(d)) return 'Keine Netzwerkverbindung zum Server. Firewall/VLAN-Regeln prüfen (kann das Kundencenter den Server erreichen?).';
if (/CERT|SELF.SIGNED|UNABLE_TO_VERIFY|ERR_TLS|SSL/i.test(d)) return 'Das HTTPS-Zertifikat wird nicht als vertrauenswürdig akzeptiert. Bei selbstsigniertem Zertifikat unter „Erweiterte Einstellungen“ den Fingerabdruck eintragen.';
if (this.code === 'BAD_CONFIG') return 'Die gespeicherten Einstellungen sind unvollständig oder fehlerhaft (siehe technische Details). Unter „Zugangsdaten / Einstellungen ändern“ korrigieren.';
if (this.code === 'AUTH_FAILED' || /Anmeldung beim Dienst|HTTP 40[13]/.test(d)) return 'Der API-Schlüssel wurde abgelehnt. Schlüssel prüfen und ob er auf die IP-Adresse des Kundencenter-Servers erlaubt ist.';
if (this.code === 'NOT_FOUND' || /nicht gefunden/.test(d)) return 'Die Adresse antwortet, aber die API wurde nicht gefunden. Ist die Adresse die des Panels (ohne /api/v2)?';
if (this.code === 'TIMEOUT' || /nicht rechtzeitig/.test(d)) return 'Keine Antwort innerhalb der Wartezeit. Server oder Netzwerk überlastet, oder Firewall verwirft Pakete.';
if (this.code === 'INVALID_RESPONSE' || /kein JSON|ungültige Antwort/.test(d)) return 'Die Adresse antwortet, aber nicht wie eine API (evtl. falsche Adresse oder Weiterleitung auf eine Webseite).';
if (this.code === 'UPSTREAM_ERROR' || /HTTP 5\d\d/.test(d)) return 'Der Dienst meldet einen eigenen Fehler. Dort im Protokoll nachsehen.';
return '';
}
}
// ---- HTTP-Helfer: Timeout, Backoff, Rate-Limit -----------------------------
export interface HttpOptions { timeoutMs?: number; retries?: number; baseDelayMs?: number; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise<void> }
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** JSON-Anfrage mit Timeout. Wiederholungen mit exponentiellem Backoff nur für GET (idempotent) bzw. wenn `retryWrites`. */
export async function httpJson<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, init: { headers?: Record<string, string>; body?: unknown; form?: Record<string, string> } = {}, o: HttpOptions & { retryWrites?: boolean } = {}): Promise<T> {
const f = o.fetchImpl ?? fetch; const sleep = o.sleep ?? defaultSleep;
const attempts = (method === 'GET' || o.retryWrites ? (o.retries ?? 2) : 0) + 1;
let last: ConnectorError | undefined;
for (let i = 0; i < attempts; i++) {
const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), o.timeoutMs ?? 10_000);
try {
const headers: Record<string, string> = { accept: 'application/json', ...(init.headers ?? {}) };
let body: string | undefined;
if (init.form) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(init.form).toString(); }
else if (init.body !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(init.body); }
const res = await f(url, { method, headers, body, signal: ctl.signal });
if (res.status === 401 || res.status === 403) throw new ConnectorError('AUTH_FAILED', `HTTP ${res.status}`);
if (res.status === 404) throw new ConnectorError('NOT_FOUND');
if (res.status === 400) { const b = await res.json().catch(() => null) as { message?: string } | null; throw new ConnectorError('INVALID_INPUT', typeof b?.message === 'string' ? b.message.slice(0, 200) : undefined); }
if (res.status === 409) throw new ConnectorError('CONFLICT');
if (res.status === 429) { const ra = Number(res.headers.get('retry-after')); throw new ConnectorError('RATE_LIMITED', undefined, Number.isFinite(ra) && ra > 0 ? ra * 1000 : undefined); }
if (res.status >= 500) throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
if (!res.ok) throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
if (res.status === 204) return undefined as T;
try { return (await res.json()) as T; } catch { throw new ConnectorError('INVALID_RESPONSE', 'kein JSON'); }
} catch (e) {
last = e instanceof ConnectorError ? e
: (e as { name?: string }).name === 'AbortError' ? new ConnectorError('TIMEOUT')
: new ConnectorError('UNREACHABLE', (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message);
if (!last.retryable || i === attempts - 1) throw last;
await sleep(last.retryAfterMs ?? Math.min(10_000, (o.baseDelayMs ?? 300) * 2 ** i) + Math.floor(Math.random() * 100));
} finally { clearTimeout(timer); }
}
throw last!;
}
/** Maskiert Geheimnisse für Anzeige/Logs: "4e59…7174". */
export const maskKey = (k: string): string => (k.length <= 8 ? '****' : `${k.slice(0, 4)}…${k.slice(-4)}`);

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }

View file

@ -0,0 +1,24 @@
{
"name": "@kc/connectors",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "echo ok"
},
"dependencies": {
"@kc/connector-keyhelp": "workspace:*",
"@kc/connector-licensing": "workspace:*",
"@kc/connector-mock": "workspace:*",
"@kc/connector-sdk": "workspace:*",
"@kc/platform": "workspace:*"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2"
}
}

View file

@ -0,0 +1,253 @@
import { randomUUID } from 'node:crypto';
import { ACTION_CAPABILITY, ConnectorError, DESTRUCTIVE_ACTIONS, type ActionName, type Capability, type ChildKind, type ChildOp, type Connector, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk';
import { licensingConnector } from '@kc/connector-licensing';
import { mockConnector } from '@kc/connector-mock';
import { keyhelpConnector } from '@kc/connector-keyhelp';
import { audit } from '@kc/platform/audit';
import { decrypt, encrypt } from '@kc/platform/crypto';
import { one, query, run } from '@kc/platform/db';
import { JobFailure, enqueue } from '@kc/platform/jobs';
import { addMonths } from '@kc/platform/contractterms';
import { CONTRACT_MACHINE, ORDER_MACHINE, transition } from '@kc/platform/statemachine';
/** Registry: neue Connectoren (KeyHelp, Plesk, …) werden hier eingetragen, sonst nirgends. */
const registry = new Map<string, Connector>([licensingConnector, keyhelpConnector, mockConnector].map((c) => [c.key, c]));
export const listConnectors = (): Connector[] => [...registry.values()];
export const getConnector = (key: string): Connector => {
const c = registry.get(key);
if (!c) throw new ConnectorError('BAD_CONFIG', `Unbekannter Connector ${key}`);
return c;
};
export const encryptSecrets = (s: Record<string, string>): string | null => (Object.keys(s).length ? encrypt(JSON.stringify(s)) : null);
export const ACTIONS: readonly ActionName[] = ['suspend', 'unsuspend', 'extend', 'terminate'];
interface Loaded { inst: any; connector: Connector; ctx: ConnectorContext }
export async function loadInstance(id: string, correlationId: string): Promise<Loaded> {
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [id]);
if (!inst) throw new JobFailure('Connector-Instanz nicht gefunden', false, 'failed');
const config = typeof inst.config_json === 'string' ? JSON.parse(inst.config_json) : inst.config_json;
const secrets = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {};
return { inst, connector: getConnector(inst.connector_key), ctx: { config, secrets, correlationId } };
}
const iso = (d?: string | null) => (d ? new Date(d) : null);
/** Gleicht eine Instanz mit dem Provider ab. Bei Ausfall bleiben die zuletzt bekannten Daten erhalten (mit altem Zeitstempel). */
export async function syncInstance(instanceId: string, correlationId: string): Promise<{ ok: boolean; count?: number; error?: string }> {
const { inst, connector, ctx } = await loadInstance(instanceId, correlationId);
const wasDown = inst.health === 'down';
await run('UPDATE connector_instances SET last_sync_at = UTC_TIMESTAMP(3) WHERE id = ?', [instanceId]);
try {
const health = await connector.healthCheck(ctx);
if (!health.ok) throw new ConnectorError(/unvollständig|API-Schlüssel|Fingerabdruck/.test(health.message ?? '') ? 'BAD_CONFIG' : 'UNREACHABLE', health.message);
const [caps, list] = [await connector.capabilities(ctx), await connector.listResources(ctx)];
const seen = new Set<string>();
for (const r of list) { seen.add(r.externalRef); await upsert(instanceId, r); }
await run('UPDATE resources SET missing_since = COALESCE(missing_since, UTC_TIMESTAMP(3)) WHERE instance_id = ?' + (seen.size ? ` AND external_ref NOT IN (${[...seen].map(() => '?').join(',')})` : ''), [instanceId, ...seen]);
await run("UPDATE connector_instances SET health='ok', health_message=NULL, capabilities_json=?, last_ok_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id = ?", [JSON.stringify(caps), instanceId]);
if (wasDown) await audit({ actorType: 'system', action: 'connector.recovered', resourceType: 'connector', resourceId: instanceId, connector: inst.connector_key, correlationId });
return { ok: true, count: list.length };
} catch (e) {
const msg = e instanceof ConnectorError ? e.userMessage : 'Unerwarteter Fehler beim Abgleich';
let tech = String((e as Error).message);
for (let n = 0; n < 3; n++) { const m = /^[^()]{5,80}\. \((.*)\)$/s.exec(tech); if (!m) break; tech = m[1]!; }
tech = tech.slice(0, 250);
const hint = e instanceof ConnectorError ? e.hint : '';
await run("UPDATE connector_instances SET health='down', health_message=?, last_error=?, last_error_at=UTC_TIMESTAMP(3) WHERE id = ?", [msg, JSON.stringify({ tech, hint }).slice(0, 490), instanceId]);
if (!wasDown) await audit({ actorType: 'system', action: 'connector.down', resourceType: 'connector', resourceId: instanceId, connector: inst.connector_key, result: 'failure', errorClass: e instanceof ConnectorError ? e.code : 'unexpected', correlationId });
return { ok: false, error: msg };
}
}
async function upsert(instanceId: string, r: NormalizedResource): Promise<string> {
await run(
`INSERT INTO resources (id, instance_id, external_ref, type, name, state, valid_from, valid_until, data_json, synced_at)
VALUES (?,?,?,?,?,?,?,?,?, UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE type=VALUES(type), name=VALUES(name), state=VALUES(state), valid_from=VALUES(valid_from), valid_until=VALUES(valid_until), data_json=VALUES(data_json), synced_at=UTC_TIMESTAMP(3), missing_since=NULL`,
[randomUUID(), instanceId, r.externalRef, r.type, r.name.slice(0, 300), r.state, iso(r.validFrom), iso(r.validUntil), JSON.stringify({ limits: r.limits ?? {}, usage: r.usage ?? {}, details: r.details ?? {} })],
);
return (await one('SELECT id FROM resources WHERE instance_id = ? AND external_ref = ?', [instanceId, r.externalRef]))!.id as string;
}
export interface ExecutePayload { resourceId: string; action: ActionName; params?: Record<string, unknown>; actorUserId: string | null; destructive?: boolean }
/** Führt eine Aktion aus. Aufgerufen vom Worker im Rahmen eines persistenten Auftrags; jobId dient als Idempotenzschlüssel. */
export async function executeAction(p: ExecutePayload, jobId: string, correlationId: string): Promise<string> {
const r = await one('SELECT * FROM resources WHERE id = ?', [p.resourceId]);
if (!r) throw new JobFailure('Ressource nicht gefunden', false, 'failed');
const { inst, connector, ctx } = await loadInstance(r.instance_id, correlationId);
const caps = await connector.capabilities(ctx);
if (!caps.includes(ACTION_CAPABILITY[p.action] as Capability) || !connector.execute) throw new JobFailure('Aktion wird vom Connector nicht unterstützt', false, 'failed');
const before = { state: r.state, validUntil: r.valid_until };
try {
const out = await connector.execute(ctx, { action: p.action, externalRef: r.external_ref, params: p.params, idempotencyKey: jobId });
if (out.resource) await upsert(r.instance_id, out.resource);
const after = await one('SELECT state, valid_until FROM resources WHERE id = ?', [p.resourceId]);
await audit({ actorType: p.actorUserId ? 'user' : 'system', actorId: p.actorUserId, orgId: r.org_id, action: `resource.${p.action}`, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, correlationId, before, after: { state: after?.state, validUntil: after?.valid_until, params: p.params } });
return `${p.action}: ${after?.state ?? 'ok'}`;
} catch (e) {
if (e instanceof JobFailure) throw e;
if (e instanceof ConnectorError) {
await audit({ actorType: p.actorUserId ? 'user' : 'system', actorId: p.actorUserId, orgId: r.org_id, action: `resource.${p.action}`, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, result: 'failure', errorClass: e.code, correlationId });
// Destruktive Aktionen und nicht wiederholbare Fehler nie automatisch wiederholen.
if (DESTRUCTIVE_ACTIONS.has(p.action) || !e.retryable) throw new JobFailure(e.userMessage, false, DESTRUCTIVE_ACTIONS.has(p.action) ? 'needs_review' : 'failed');
throw new JobFailure(e.userMessage, true, 'needs_review', e.retryAfterMs ? Math.ceil(e.retryAfterMs / 1000) : undefined);
}
throw e;
}
}
/** Plant Abgleiche für fällige Instanzen (idempotent pro Zeitfenster). Wird regelmäßig vom Worker aufgerufen. */
export async function scheduleDueSyncs(enqueue: (type: string, payload: unknown, o: { idempotencyKey: string }) => Promise<void>): Promise<number> {
const due = await query("SELECT id, sync_interval_sec FROM connector_instances WHERE enabled = 1 AND (last_sync_at IS NULL OR last_sync_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL sync_interval_sec SECOND))");
for (const d of due) await enqueue('connector.sync', { instanceId: d.id }, { idempotencyKey: `sync:${d.id}:${Math.floor(Date.now() / (Number(d.sync_interval_sec) * 1000))}` });
return due.length;
}
export { ACTION_CAPABILITY, DESTRUCTIVE_ACTIONS };
export type { ActionName, Capability };
export interface JobMeta { id: string; correlationId: string; attempt: number; maxAttempts: number }
/**
* Provisioniert eine freigegebene Bestellung (Auftrag `order.provision`). Je Position: Ressource beim Provider anlegen
* (falls das Produkt an eine Verbindung gebunden ist), Vertrag aktivieren. Bereits aktive Verträge werden übersprungen,
* sodass ein Neustart des Auftrags keine Doppelanlage erzeugt. Anlage wird nur bei sicher nicht gesendeter Anfrage wiederholt.
*/
export async function provisionOrder(orderId: string, meta: JobMeta): Promise<string> {
const order = await one('SELECT * FROM orders WHERE id = ?', [orderId]);
if (!order) throw new JobFailure('Bestellung nicht gefunden', false, 'failed');
if (order.status === 'completed') return 'bereits abgeschlossen';
if (order.status !== 'provisioning') throw new JobFailure(`Bestellung ist im Status ${order.status}`, false, 'failed');
const items = await query(
`SELECT oi.id AS item_id, oi.snapshot_json, c.id AS contract_id, c.number AS contract_number, c.status AS cstatus, c.renewal AS c_renewal, c.renewal_term_months AS c_renewal_months, pv.name AS product_name, pv.term_months, pv.provisioning_json,
p.connector_instance_id, p.customer_actions, o.name AS org_name, o.customer_number, ord.number AS order_number
FROM order_items oi JOIN contracts c ON c.order_item_id = oi.id JOIN product_versions pv ON pv.id = oi.product_version_id
JOIN products p ON p.id = pv.product_id JOIN orders ord ON ord.id = oi.order_id JOIN organizations o ON o.id = ord.org_id
WHERE oi.order_id = ? ORDER BY oi.id`, [orderId]);
// Kunde/Vorgang für den Anbieter (Inhaber der Organisation als Ansprechpartner)
const owner = await one("SELECT u.name, u.email FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? AND m.role = 'owner' ORDER BY m.created_at LIMIT 1", [order.org_id]);
const fail = async (note: string, ambiguous: boolean, err: unknown) => {
await run("UPDATE orders SET status = ?, failure_note = ?, failure_ambiguous = ? WHERE id = ?", [transition(ORDER_MACHINE, 'provisioning', 'fail'), note.slice(0, 500), ambiguous ? 1 : 0, orderId]);
await audit({ actorType: 'system', orgId: order.org_id, action: 'order.provision', resourceType: 'order', resourceId: orderId, result: 'failure', errorClass: err instanceof ConnectorError ? err.code : 'unexpected', correlationId: meta.correlationId, after: { note, ambiguous } });
};
for (const it of items) {
if (it.cstatus === 'active') continue;
let resourceId: string | null = null; let ref = ''; let providerValidUntil: string | null = null;
try {
if (it.connector_instance_id) {
const { inst, connector, ctx } = await loadInstance(it.connector_instance_id, meta.correlationId);
if (!inst.enabled) throw new ConnectorError('BAD_CONFIG', 'Verbindung ist deaktiviert');
const caps = await connector.capabilities(ctx);
if (!caps.includes('lifecycle.create') || !connector.provision) throw new ConnectorError('UNSUPPORTED', 'Anlegen wird nicht unterstützt');
const params = typeof it.provisioning_json === 'string' ? JSON.parse(it.provisioning_json) : it.provisioning_json;
const out = await connector.provision(ctx, { params, label: `${it.org_name} · ${it.product_name}`, idempotencyKey: `${meta.id}:${it.item_id}`,
context: { source: 'kundencenter', customerNumber: it.customer_number, customerName: it.org_name, contactName: owner?.name ?? null, customerEmail: owner?.email ?? null, orderNumber: it.order_number, contractNumber: it.contract_number } });
ref = out.resource.externalRef; providerValidUntil = out.resource.validUntil ?? null;
try {
resourceId = await upsert(it.connector_instance_id, out.resource);
await run('UPDATE resources SET org_id = ?, customer_actions = ? WHERE id = ?', [order.org_id, JSON.stringify(typeof it.customer_actions === 'string' ? JSON.parse(it.customer_actions) : it.customer_actions), resourceId]);
} catch (dbErr) {
// Provider hat angelegt, lokale Zuordnung schlug fehl: nie automatisch wiederholen (sonst Doppelanlage)
await fail(`Beim Anbieter wurde ${ref} angelegt, die lokale Zuordnung schlug fehl. Bitte prüfen, nicht blind wiederholen.`, true, dbErr);
throw new JobFailure('Zuordnung nach Anlage fehlgeschlagen', false, 'needs_review');
}
}
// Erste Laufzeitperiode: Mindestlaufzeit, sonst (bei automatischer Verlängerung) die Verlängerungsperiode, damit auch rollierende Monatsverträge einen Verlängerungstakt haben
const now = new Date(); const months = Number(it.term_months) > 0 ? Number(it.term_months) : it.c_renewal === 'auto' ? Number(it.c_renewal_months) : 0;
const c = transition(CONTRACT_MACHINE, 'pending', 'activate');
await run('UPDATE contracts SET status = ?, started_at = ?, term_end = ?, resource_id = ? WHERE id = ? AND status = \'pending\'', [c, now, months > 0 ? addMonths(now, months) : null, resourceId, it.contract_id]);
// Laufzeit des Providerobjekts an das Vertragsende angleichen (z. B. Lizenz 365 Tage vs. 12 Kalendermonate)
if (resourceId && months > 0 && providerValidUntil) {
const target = addMonths(now, months);
if (Math.abs(new Date(providerValidUntil).getTime() - target.getTime()) > 60_000) await enqueue('connector.execute', { resourceId, action: 'extend', params: { until: target.toISOString() }, actorUserId: null }, { idempotencyKey: `align:${it.contract_id}`, correlationId: meta.correlationId });
}
await audit({ actorType: 'system', orgId: order.org_id, action: 'contract.activate', resourceType: 'contract', resourceId: it.contract_id, connector: it.connector_instance_id ? 'provisioned' : undefined, correlationId: meta.correlationId, after: { resourceId, externalRef: ref || undefined } });
} catch (e) {
if (e instanceof JobFailure) throw e;
if (e instanceof ConnectorError) {
const last = meta.attempt >= meta.maxAttempts;
if (e.retryable && e.notSent && !last) throw new JobFailure(e.userMessage, true, 'needs_review');
await fail(`${e.userMessage}${e.ambiguous ? ' Es ist unklar, ob beim Anbieter bereits etwas angelegt wurde. Bitte dort prüfen, bevor die Bestellung erneut gestartet wird.' : ''}`, e.ambiguous, e);
throw new JobFailure(e.userMessage, false, 'needs_review');
}
await fail('Unerwarteter Fehler bei der Bereitstellung', true, e);
throw new JobFailure('Unerwarteter Fehler', false, 'needs_review');
}
}
await run('UPDATE orders SET status = ?, failure_note = NULL, failure_ambiguous = 0 WHERE id = ?', [transition(ORDER_MACHINE, 'provisioning', 'complete'), orderId]);
await audit({ actorType: 'system', orgId: order.org_id, action: 'order.complete', resourceType: 'order', resourceId: orderId, correlationId: meta.correlationId });
return `${items.length} Position(en) bereitgestellt`;
}
/** Beendet Verträge zum Kündigungs-/Laufzeitende und verlängert automatisch. Idempotent; regelmäßig vom Worker aufgerufen. */
export async function processContractLifecycle(now: Date, enqueueJob: (type: string, payload: unknown, o: { idempotencyKey: string }) => Promise<void>): Promise<{ ended: number; renewed: number }> {
let ended = 0; let renewed = 0;
// 1) Gekündigte Verträge, deren Kündigung wirksam wird
const due = await query("SELECT id, org_id, resource_id, status FROM contracts WHERE status IN ('active','suspended') AND cancel_effective_at IS NOT NULL AND cancel_effective_at <= ?", [now]);
for (const c of due) {
const to = transition(CONTRACT_MACHINE, c.status, 'cancel');
const r = await run("UPDATE contracts SET status = ?, cancelled_at = ? WHERE id = ? AND status IN ('active','suspended')", [to, now, c.id]);
if (!r.affectedRows) continue;
ended++;
await audit({ actorType: 'system', orgId: c.org_id, action: 'contract.cancel.effective', resourceType: 'contract', resourceId: c.id });
// Ressource sperren (nicht löschen): Löschung ist destruktiv und bleibt eine bewusste manuelle Entscheidung
if (c.resource_id) await enqueueJob('connector.execute', { resourceId: c.resource_id, action: 'suspend', actorUserId: null }, { idempotencyKey: `contract-end:${c.id}` });
}
// 2) Laufzeitende ohne Kündigung: automatisch verlängern oder auslaufen lassen
const ended2 = await query("SELECT c.id, c.org_id, c.term_end, c.renewal, c.renewal_term_months, c.status, c.resource_id, r.valid_until AS res_until FROM contracts c LEFT JOIN resources r ON r.id = c.resource_id WHERE c.status IN ('active','suspended') AND c.term_end IS NOT NULL AND c.term_end <= ? AND c.cancel_effective_at IS NULL", [now]);
for (const c of ended2) {
if (c.renewal === 'auto' && Number(c.renewal_term_months) > 0) {
let end = new Date(c.term_end as Date); let guard = 0;
while (end <= now && guard++ < 1200) end = addMonths(end, Number(c.renewal_term_months));
await run('UPDATE contracts SET term_end = ? WHERE id = ? AND term_end = ?', [end, c.id, c.term_end]);
renewed++;
// Befristetes Providerobjekt (z. B. Monats-/Jahreslizenz) mitverlängern, sonst läuft es trotz laufendem Vertrag ab.
// HINWEIS: Bis zur Rechnungs-/Zahlungsanbindung erfolgt das ohne Zahlungsprüfung (siehe Plane).
if (c.resource_id && c.res_until) await enqueueJob('connector.execute', { resourceId: c.resource_id, action: 'extend', params: { until: end.toISOString() }, actorUserId: null }, { idempotencyKey: `renew:${c.id}:${end.toISOString()}` });
await audit({ actorType: 'system', orgId: c.org_id, action: 'contract.renew', resourceType: 'contract', resourceId: c.id, after: { termEnd: end.toISOString() } });
} else {
const to = transition(CONTRACT_MACHINE, c.status, 'expire');
const r = await run("UPDATE contracts SET status = ? WHERE id = ? AND status IN ('active','suspended')", [to, c.id]);
if (!r.affectedRows) continue;
ended++;
await audit({ actorType: 'system', orgId: c.org_id, action: 'contract.expire', resourceType: 'contract', resourceId: c.id });
if (c.resource_id) await enqueueJob('connector.execute', { resourceId: c.resource_id, action: 'suspend', actorUserId: null }, { idempotencyKey: `contract-end:${c.id}` });
}
}
return { ended, renewed };
}
export interface ChildPayload { resourceId: string; kind: ChildKind; op: ChildOp; id?: string; data?: Record<string, unknown>; secretEnc?: string; actorUserId: string | null; destructive?: boolean }
/**
* Führt eine Änderung an einem Unterobjekt aus (Auftrag `connector.child`). Geheimnisse (z. B. Passwörter) liegen nur verschlüsselt im Auftrag und
* werden nach dem Ende (Erfolg oder endgültiger Fehler) aus der Datenbank entfernt. Wiederholt wird nur, wenn die Anfrage sicher nicht ankam.
*/
export async function executeChild(p: ChildPayload, jobId: string, correlationId: string, isFinal: () => boolean): Promise<string> {
const wipe = () => run("UPDATE jobs SET payload = JSON_REMOVE(payload, '$.secretEnc') WHERE id = ?", [jobId]).catch(() => undefined);
const r = await one('SELECT * FROM resources WHERE id = ?', [p.resourceId]);
if (!r) { await wipe(); throw new JobFailure('Ressource nicht gefunden', false, 'failed'); }
const { inst, connector, ctx } = await loadInstance(r.instance_id, correlationId);
const caps = await connector.capabilities(ctx);
if (!connector.children || !caps.includes('children.write')) { await wipe(); throw new JobFailure('Der Anbieter unterstützt diese Änderung nicht', false, 'failed'); }
const secrets = p.secretEnc ? (JSON.parse(decrypt(p.secretEnc)) as Record<string, string>) : undefined;
const name = typeof p.data?.domain === 'string' ? p.data.domain : typeof p.data?.local === 'string' ? `${p.data.local}@${String(p.data.domain ?? '')}` : typeof p.data?.name === 'string' ? p.data.name : typeof p.data?.username === 'string' ? p.data.username : (p.id ?? '');
const base = { actorType: (p.actorUserId ? 'user' : 'system') as 'user' | 'system', actorId: p.actorUserId, orgId: r.org_id, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, correlationId };
try {
const out = await connector.children.act(ctx, { parentRef: r.external_ref, kind: p.kind, op: p.op, id: p.id, data: p.data ?? {}, secrets, idempotencyKey: jobId });
await wipe();
await audit({ ...base, action: `resource.child.${p.kind}.${p.op}`, after: { name: out.child?.name ?? name, id: out.child?.id ?? p.id } });
return `${p.kind}: ${p.op === 'create' ? 'angelegt' : p.op === 'update' ? 'geändert' : 'gelöscht'}${out.child?.name ? ` (${out.child.name})` : ''}`;
} catch (e) {
if (e instanceof ConnectorError) {
await audit({ ...base, action: `resource.child.${p.kind}.${p.op}`, result: 'failure', errorClass: e.code, after: { name } }).catch(() => undefined);
const retry = e.retryable && e.notSent && !isFinal();
if (!retry) await wipe();
// Bei abgelehnten Eingaben ist die Meldung des Anbieters für Nutzer hilfreich (z. B. "Name schon vergeben").
const msg = e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' || e.code === 'NOT_FOUND' ? e.message : e.userMessage;
throw new JobFailure(msg, retry, e.ambiguous ? 'needs_review' : 'failed');
}
await wipe(); throw e;
}
}

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }

View file

@ -0,0 +1,25 @@
{
"name": "@kc/platform",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"mysql2": "^3.24.4"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,81 @@
import { createHash } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { pool, one, query } from './db.js';
const SENSITIVE = /pass|secret|token|hash|code|key|totp/i;
/** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */
export function mask(v: unknown): unknown {
if (Array.isArray(v)) return v.map(mask);
if (v && typeof v === 'object') {
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)]));
}
return v;
}
export interface AuditInput {
actorType: 'user' | 'system' | 'anonymous';
actorId?: string | null;
orgId?: string | null;
action: string;
resourceType?: string;
resourceId?: string;
result?: 'success' | 'denied' | 'failure';
errorClass?: string;
connector?: string;
correlationId?: string;
ip?: string;
before?: unknown;
after?: unknown;
}
const canon = (o: unknown) => JSON.stringify(o);
/** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */
export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void> {
const own = !conn;
const c = conn ?? (await pool.getConnection());
try {
if (own) await c.beginTransaction();
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c);
const prev = last?.hash ?? '0'.repeat(64);
const ts = new Date();
const body = {
ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action,
resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success',
error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null,
before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after),
};
const hash = createHash('sha256').update(prev + canon(body)).digest('hex');
await c.execute(
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null,
body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
);
if (own) await c.commit();
} catch (err) {
if (own) await c.rollback();
throw err;
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
if (own) c.release();
}
}
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> {
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id');
let prev = '0'.repeat(64);
for (const r of rows) {
const body = {
ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action,
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id,
before: r.before_json ?? null, after: r.after_json ?? null,
};
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number };
prev = r.hash as string;
}
return { checked: rows.length, brokenAt: null };
}

View file

@ -0,0 +1,41 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
/** Lädt *.env aus KC_ENV_DIR (Standard /etc/kundencenter), ohne bereits gesetzte Variablen zu überschreiben. */
function loadEnvDir(dir: string): void {
if (!existsSync(dir)) return;
for (const f of readdirSync(dir).filter((n) => n.endsWith('.env')).sort()) {
for (const line of readFileSync(join(dir, f), 'utf8').split('\n')) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (m && m[1] && process.env[m[1]] === undefined) process.env[m[1]] = m[2] ?? '';
}
}
}
loadEnvDir(process.env.KC_ENV_DIR ?? '/etc/kundencenter');
function req(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Konfiguration fehlt: ${name}`);
return v;
}
export const config = {
env: process.env.KC_NODE_ENV ?? 'development',
isProd: (process.env.KC_NODE_ENV ?? 'development') === 'production',
port: Number(process.env.KC_API_PORT ?? 4100),
baseUrl: process.env.KC_BASE_URL ?? 'http://localhost:4101',
/** 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'),
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
database: req('DB_NAME'),
user: req('DB_USER'),
password: req('DB_PASSWORD'),
},
smtp: process.env.SMTP_HOST
? { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD, from: process.env.SMTP_FROM ?? 'kundencenter@localhost' }
: null,
};
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');

View file

@ -0,0 +1,42 @@
/** Laufzeit-, Verlängerungs- und Kündigungslogik (rein, ohne Datenbank). Zeitpunkte in UTC. */
export interface Terms { termEnd: Date | null; renewal: 'auto' | 'none'; renewalTermMonths: number; noticeDays: number }
export function addMonths(d: Date, months: number): Date {
const r = new Date(d.getTime());
const day = r.getUTCDate();
r.setUTCDate(1); r.setUTCMonth(r.getUTCMonth() + months);
const last = new Date(Date.UTC(r.getUTCFullYear(), r.getUTCMonth() + 1, 0)).getUTCDate();
r.setUTCDate(Math.min(day, last)); // 31.01. + 1 Monat = 28./29.02.
return r;
}
const addDays = (d: Date, n: number) => new Date(d.getTime() + n * 86400000);
/**
* Wann endet der Vertrag, wenn jetzt gekündigt wird?
* - ohne Mindestlaufzeit (termEnd null): mit Frist ab jetzt.
* - ohne Verlängerung: zum Laufzeitende.
* - mit Verlängerung: zum nächsten Laufzeitende, das mindestens noticeDays in der Zukunft liegt.
*/
export function effectiveCancelDate(now: Date, t: Terms): Date {
if (!t.termEnd) return addDays(now, t.noticeDays);
if (t.renewal === 'none') return t.termEnd > now ? t.termEnd : now;
let end = t.termEnd; let guard = 0;
while (addDays(end, -t.noticeDays) < now) {
if (t.renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit');
end = addMonths(end, t.renewalTermMonths);
}
return end;
}
/** Verlängert das Laufzeitende, solange es in der Vergangenheit liegt (nur bei auto-Verlängerung und ohne Kündigung). */
export function renewedTermEnd(now: Date, termEnd: Date, renewalTermMonths: number): Date {
let end = termEnd; let guard = 0;
while (end <= now) { if (renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit'); end = addMonths(end, renewalTermMonths); }
return end;
}
/**
* Verbraucherverträge (Privatkunden): Nach der Erstlaufzeit läuft der Vertrag nur auf unbestimmte Zeit weiter
* und ist mit höchstens einem Monat Frist kündbar. HINWEIS: rechtlich vor Produktivbetrieb prüfen lassen.
*/
export function consumerTerms(renewalTermMonths: number, noticeDays: number): { renewalTermMonths: number; noticeDays: number } {
return { renewalTermMonths: renewalTermMonths > 0 ? 1 : 0, noticeDays: Math.min(noticeDays, 30) };
}

View file

@ -0,0 +1,24 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from './config.js';
export const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
export const randomToken = (bytes = 32): string => randomBytes(bytes).toString('base64url');
/** AES-256-GCM, Format v1:<iv>:<tag>:<ciphertext> (base64url). Key-ID im Präfix ermöglicht spätere Rotation. */
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv('aes-256-gcm', config.secretKey, iv);
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
return ['v1', iv.toString('base64url'), c.getAuthTag().toString('base64url'), ct.toString('base64url')].join(':');
}
export function decrypt(blob: string): string {
const [v, iv, tag, ct] = blob.split(':');
if (v !== 'v1' || !iv || !tag || !ct) throw new Error('Unbekanntes Verschlüsselungsformat');
const d = createDecipheriv('aes-256-gcm', config.secretKey, Buffer.from(iv, 'base64url'));
d.setAuthTag(Buffer.from(tag, 'base64url'));
return Buffer.concat([d.update(Buffer.from(ct, 'base64url')), d.final()]).toString('utf8');
}
export function safeEqual(a: string, b: string): boolean {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}

View file

@ -0,0 +1,44 @@
import mysql from 'mysql2/promise';
import type { Pool, PoolConnection, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
import { config } from './config.js';
export const pool: Pool = mysql.createPool({
...config.db,
connectionLimit: 10,
charset: 'utf8mb4',
timezone: 'Z',
dateStrings: false,
namedPlaceholders: false,
});
// Alle Zeiten in UTC, auch die DB-Defaults (CURRENT_TIMESTAMP) – sonst Versatz zur Serverzeit.
(pool as unknown as { on(e: string, f: (c: { query(s: string): void }) => void): void }).on('connection', (c) => c.query("SET time_zone = '+00:00'"));
export type Row = RowDataPacket;
type Exec = Pick<Pool | PoolConnection, 'execute'>;
export async function query<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T[]> {
const [rows] = await c.execute<T[]>(sql, params as never[]);
return rows;
}
export async function one<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T | undefined> {
return (await query<T>(sql, params, c))[0];
}
export async function run(sql: string, params: unknown[] = [], c: Exec = pool): Promise<ResultSetHeader> {
const [res] = await c.execute<ResultSetHeader>(sql, params as never[]);
return res;
}
export async function tx<T>(fn: (c: PoolConnection) => Promise<T>): Promise<T> {
const c = await pool.getConnection();
try {
await c.beginTransaction();
const out = await fn(c);
await c.commit();
return out;
} catch (e) {
await c.rollback();
throw e;
} finally {
c.release();
}
}

View file

@ -0,0 +1,18 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { run } from './db.js';
/** Persistenten Auftrag einreihen. Mit idempotencyKey wird Doppelanlage verhindert. */
export async function enqueue(type: string, payload: unknown, opts: { idempotencyKey?: string; correlationId?: string; runAt?: Date } = {}, c?: PoolConnection): Promise<void> {
await run(
'INSERT IGNORE INTO jobs (id, type, payload, idempotency_key, correlation_id, run_at) VALUES (?,?,?,?,?,?)',
[randomUUID(), type, JSON.stringify(payload), opts.idempotencyKey ?? null, opts.correlationId ?? null, opts.runAt ?? new Date()],
c,
);
}
/** Wird von Job-Handlern geworfen, um die Wiederholungslogik zu steuern. */
export class JobFailure extends Error {
/** retry=false: nie automatisch wiederholen (z. B. destruktive Aktionen); finalStatus bestimmt den Endzustand. */
constructor(message: string, public retry: boolean, public finalStatus: 'failed' | 'needs_review' = 'needs_review', public delaySec?: number) { super(message); }
}

View file

@ -0,0 +1,36 @@
/**
* Serverseitige Preisberechnung in Cent (ganzzahlig, kaufmännisch gerundet je Position).
* Steuer in Basispunkten (1900 = 19 %); Rabatt in Basispunkten (1000 = 10 %).
* Preisbasis `net`: Beträge sind Nettopreise, Steuer wird aufgeschlagen.
* Preisbasis `gross`: Beträge sind Bruttopreise (z. B. Endkundenpreis 9,99 €) und bleiben exakt; Netto und Steuer werden herausgerechnet.
*/
export type Interval = 'once' | 'monthly' | 'yearly';
export type Basis = 'net' | 'gross';
export interface PriceInput {
basis?: Basis; setupCents: number; recurringCents: number; taxBp: number; interval: Interval; quantity: number; discountBp: number; currency?: string;
}
export interface PriceSnapshot {
basis: Basis; currency: string; interval: Interval; quantity: number; taxBp: number; discountBp: number;
unitSetupCents: number; unitRecurringCents: number;
setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number };
}
/** Kaufmännisches Runden (halb auf) für nicht-negative Ganzzahl-Division. */
const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2));
export function calculatePrice(i: PriceInput): PriceSnapshot {
const ints: [string, number][] = [['setupCents', i.setupCents], ['recurringCents', i.recurringCents], ['taxBp', i.taxBp], ['quantity', i.quantity], ['discountBp', i.discountBp]];
for (const [n, v] of ints) if (!Number.isInteger(v) || v < 0) throw new RangeError(`${n} muss eine nicht-negative Ganzzahl sein`);
if (i.quantity < 1 || i.quantity > 10000) throw new RangeError('quantity außerhalb des Bereichs');
if (i.discountBp > 10000) throw new RangeError('discountBp darf höchstens 10000 sein');
if (i.interval === 'once' && i.recurringCents > 0) throw new RangeError('Einmalprodukte haben keinen wiederkehrenden Preis');
const basis: Basis = i.basis ?? 'net';
const line = (unit: number) => {
const amount = divRound(unit * i.quantity * (10000 - i.discountBp), 10000);
if (basis === 'gross') { const net = divRound(amount * 10000, 10000 + i.taxBp); return { net, tax: amount - net, gross: amount }; }
const tax = divRound(amount * i.taxBp, 10000);
return { net: amount, tax, gross: amount + tax };
};
return { basis, currency: i.currency ?? 'EUR', interval: i.interval, quantity: i.quantity, taxBp: i.taxBp, discountBp: i.discountBp,
unitSetupCents: i.setupCents, unitRecurringCents: i.recurringCents, setup: line(i.setupCents), recurring: line(i.recurringCents) };
}
export const formatEuro = (cents: number): string => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });

View file

@ -0,0 +1,27 @@
/** Statusautomaten für Bestellung und Vertrag. Zustandsänderungen laufen ausschließlich über `transition`. */
export type OrderStatus = 'pending_approval' | 'approved' | 'provisioning' | 'completed' | 'failed' | 'rejected' | 'cancelled';
export type OrderEvent = 'approve' | 'reject' | 'cancel' | 'start_provisioning' | 'complete' | 'fail' | 'retry';
export type ContractStatus = 'pending' | 'active' | 'suspended' | 'cancelled' | 'expired' | 'failed';
export type ContractEvent = 'activate' | 'suspend' | 'unsuspend' | 'cancel' | 'expire' | 'fail';
type Machine<S extends string, E extends string> = Partial<Record<S, Partial<Record<E, S>>>>;
export const ORDER_MACHINE: Machine<OrderStatus, OrderEvent> = {
pending_approval: { approve: 'approved', reject: 'rejected', cancel: 'cancelled' },
approved: { start_provisioning: 'provisioning', cancel: 'cancelled' },
provisioning: { complete: 'completed', fail: 'failed' },
failed: { retry: 'provisioning', cancel: 'cancelled' },
};
export const CONTRACT_MACHINE: Machine<ContractStatus, ContractEvent> = {
pending: { activate: 'active', fail: 'failed', cancel: 'cancelled' },
active: { suspend: 'suspended', cancel: 'cancelled', expire: 'expired' },
suspended: { unsuspend: 'active', cancel: 'cancelled', expire: 'expired' },
};
export class InvalidTransition extends Error {
constructor(public from: string, public event: string) { super(`Ungültiger Statuswechsel: ${event} ist im Zustand ${from} nicht erlaubt`); }
}
export function transition<S extends string, E extends string>(m: Machine<S, E>, from: S, event: E): S {
const to = m[from]?.[event];
if (!to) throw new InvalidTransition(from, event);
return to;
}
export const terminalOrder = (s: OrderStatus) => ['completed', 'rejected', 'cancelled'].includes(s);

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { calculatePrice } from '../src/pricing.js';
import { CONTRACT_MACHINE, ORDER_MACHINE, InvalidTransition, transition, type ContractEvent, type ContractStatus, type OrderEvent, type OrderStatus } from '../src/statemachine.js';
import { addMonths, consumerTerms, effectiveCancelDate, renewedTermEnd } from '../src/contractterms.js';
const P = { setupCents: 0, recurringCents: 0, taxBp: 1900, interval: 'yearly' as const, quantity: 1, discountBp: 0 };
describe('Preisberechnung', () => {
it('rechnet Netto, Steuer und Brutto je Position in Cent', () => {
const r = calculatePrice({ ...P, setupCents: 1000, recurringCents: 4999 });
expect(r.setup).toEqual({ net: 1000, tax: 190, gross: 1190 });
expect(r.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 }); // 949,81 -> 950
});
it('wendet Menge und Rabatt vor der Steuer an und rundet kaufmännisch', () => {
const r = calculatePrice({ ...P, recurringCents: 999, quantity: 3, discountBp: 1000 }); // 3*9,99*0,9 = 26,973
expect(r.recurring.net).toBe(2697); expect(r.recurring.tax).toBe(512); expect(r.recurring.gross).toBe(3209);
expect(calculatePrice({ ...P, recurringCents: 5, discountBp: 5000 }).recurring.net).toBe(3); // 2,5 -> 3
});
it('unterstützt 7 %, 0 % und 100 % Rabatt', () => {
expect(calculatePrice({ ...P, taxBp: 700, recurringCents: 10000 }).recurring.tax).toBe(700);
expect(calculatePrice({ ...P, taxBp: 0, recurringCents: 10000 }).recurring.gross).toBe(10000);
expect(calculatePrice({ ...P, recurringCents: 10000, discountBp: 10000 }).recurring).toEqual({ net: 0, tax: 0, gross: 0 });
});
it('Bruttopreise bleiben exakt; Netto und Steuer werden herausgerechnet', () => {
const g = (cents: number, extra: object = {}) => calculatePrice({ ...P, basis: 'gross', recurringCents: cents, ...extra }).recurring;
expect(g(299)).toEqual({ net: 251, tax: 48, gross: 299 }); // 2,99 € brutto
expect(g(999)).toEqual({ net: 839, tax: 160, gross: 999 }); // 9,99 € bleibt 9,99 € (netto allein nicht darstellbar)
expect(g(2990)).toEqual({ net: 2513, tax: 477, gross: 2990 });
expect(g(15900)).toEqual({ net: 13361, tax: 2539, gross: 15900 });
expect(g(999, { taxBp: 0 })).toEqual({ net: 999, tax: 0, gross: 999 });
expect(g(999, { discountBp: 1000 })).toEqual({ net: 755, tax: 144, gross: 899 }); // 9,99 * 0,9 = 8,991 -> 8,99 brutto
for (let c = 0; c <= 20000; c += 7) { const r = g(c); expect(r.net + r.tax).toBe(r.gross); expect(r.gross).toBe(c); } // Summe stimmt immer
expect(calculatePrice({ ...P, basis: 'gross', recurringCents: 999 }).basis).toBe('gross');
});
it('lehnt ungültige Eingaben ab', () => {
for (const bad of [{ quantity: 0 }, { quantity: 1.5 }, { recurringCents: -1 }, { discountBp: 10001 }, { taxBp: NaN }, { interval: 'once' as const, recurringCents: 100 }])
expect(() => calculatePrice({ ...P, ...bad })).toThrow(RangeError);
});
});
describe('Statusautomaten', () => {
const orderStates: OrderStatus[] = ['pending_approval', 'approved', 'provisioning', 'completed', 'failed', 'rejected', 'cancelled'];
const orderEvents: OrderEvent[] = ['approve', 'reject', 'cancel', 'start_provisioning', 'complete', 'fail', 'retry'];
const contractStates: ContractStatus[] = ['pending', 'active', 'suspended', 'cancelled', 'expired', 'failed'];
const contractEvents: ContractEvent[] = ['activate', 'suspend', 'unsuspend', 'cancel', 'expire', 'fail'];
it('Bestellung: erlaubte Übergänge', () => {
expect(transition(ORDER_MACHINE, 'pending_approval', 'approve')).toBe('approved');
expect(transition(ORDER_MACHINE, 'approved', 'start_provisioning')).toBe('provisioning');
expect(transition(ORDER_MACHINE, 'provisioning', 'complete')).toBe('completed');
expect(transition(ORDER_MACHINE, 'failed', 'retry')).toBe('provisioning');
});
it('Bestellung: alle anderen Übergänge sind verboten, Endzustände haben keine Ausgänge', () => {
const allowed = new Set(['pending_approval:approve', 'pending_approval:reject', 'pending_approval:cancel', 'approved:start_provisioning', 'approved:cancel', 'provisioning:complete', 'provisioning:fail', 'failed:retry', 'failed:cancel']);
for (const s of orderStates) for (const e of orderEvents) {
if (allowed.has(`${s}:${e}`)) expect(() => transition(ORDER_MACHINE, s, e)).not.toThrow();
else expect(() => transition(ORDER_MACHINE, s, e)).toThrow(InvalidTransition);
}
});
it('Vertrag: alle Übergänge vollständig geprüft', () => {
const allowed = new Set(['pending:activate', 'pending:fail', 'pending:cancel', 'active:suspend', 'active:cancel', 'active:expire', 'suspended:unsuspend', 'suspended:cancel', 'suspended:expire']);
for (const s of contractStates) for (const e of contractEvents) {
if (allowed.has(`${s}:${e}`)) expect(() => transition(CONTRACT_MACHINE, s, e)).not.toThrow();
else expect(() => transition(CONTRACT_MACHINE, s, e)).toThrow(InvalidTransition);
}
});
});
const d = (s: string) => new Date(s + 'T00:00:00Z');
describe('Laufzeit und Kündigung', () => {
it('addMonths behandelt Monatsenden', () => {
expect(addMonths(d('2026-01-31'), 1).toISOString().slice(0, 10)).toBe('2026-02-28');
expect(addMonths(d('2028-01-31'), 1).toISOString().slice(0, 10)).toBe('2028-02-29');
expect(addMonths(d('2026-11-15'), 3).toISOString().slice(0, 10)).toBe('2027-02-15');
});
it('Kündigung zum nächsten Laufzeitende unter Beachtung der Frist', () => {
const t = { termEnd: d('2027-01-01'), renewal: 'auto' as const, renewalTermMonths: 12, noticeDays: 30 };
expect(effectiveCancelDate(d('2026-10-01'), t).toISOString().slice(0, 10)).toBe('2027-01-01'); // rechtzeitig
expect(effectiveCancelDate(d('2026-12-15'), t).toISOString().slice(0, 10)).toBe('2028-01-01'); // Frist verpasst -> nächste Periode
expect(effectiveCancelDate(d('2026-12-02'), t).toISOString().slice(0, 10)).toBe('2027-01-01'); // genau 30 Tage vorher
expect(effectiveCancelDate(d('2026-12-03'), t).toISOString().slice(0, 10)).toBe('2028-01-01');
});
it('ohne Verlängerung: zum Laufzeitende; ohne Laufzeit: mit Frist ab jetzt', () => {
expect(effectiveCancelDate(d('2026-10-01'), { termEnd: d('2027-01-01'), renewal: 'none', renewalTermMonths: 0, noticeDays: 30 }).toISOString().slice(0, 10)).toBe('2027-01-01');
expect(effectiveCancelDate(d('2026-10-01'), { termEnd: null, renewal: 'auto', renewalTermMonths: 1, noticeDays: 14 }).toISOString().slice(0, 10)).toBe('2026-10-15');
});
it('Verlängerung und Verbraucherregel', () => {
expect(renewedTermEnd(d('2027-03-10'), d('2027-01-01'), 12).toISOString().slice(0, 10)).toBe('2028-01-01');
expect(renewedTermEnd(d('2026-10-01'), d('2027-01-01'), 12).toISOString().slice(0, 10)).toBe('2027-01-01');
expect(consumerTerms(12, 90)).toEqual({ renewalTermMonths: 1, noticeDays: 30 }); // Jahresvertrag: nach der Erstlaufzeit monatlich
expect(consumerTerms(1, 30)).toEqual({ renewalTermMonths: 1, noticeDays: 30 }); // rollierender Monatsvertrag bleibt monatlich
expect(consumerTerms(0, 30)).toEqual({ renewalTermMonths: 0, noticeDays: 30 }); // keine Verlängerung
expect(() => effectiveCancelDate(d('2026-12-30'), { termEnd: d('2027-01-01'), renewal: 'auto', renewalTermMonths: 0, noticeDays: 30 })).toThrow(RangeError);
});
});

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src"] }