/** * 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' | 'panel.password_reset'; /** Aktionen, die über `execute` laufen (immer als persistenter Auftrag). */ export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan' | 'reset_password'; export const ACTION_CAPABILITY: Record = { suspend: 'lifecycle.suspend', unsuspend: 'lifecycle.unsuspend', extend: 'lifecycle.extend', terminate: 'lifecycle.terminate', change_plan: 'plan.change', reset_password: 'panel.password_reset', }; /** Aktionen, die nie automatisch wiederholt werden dürfen (destruktiv, oder ein Fehlausgang wäre irreführend statt nur unvollständig). */ export const DESTRUCTIVE_ACTIONS: ReadonlySet = new Set(['terminate', 'reset_password']); 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; usage?: Record; /** Anzeigedaten. Geheimnisse (z. B. vollständige Lizenzschlüssel) sind bereits maskiert. */ details?: Record; } /** 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; /** Standard-Provisionierungsparameter (Grundlage für Produkte). */ provisioning: Record; /** Vorschläge aus dem Bestand des Anbieters (z. B. übliche Laufzeit/Limit bestehender Lizenzen). */ hints?: { label: string; provisioning: Record; 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; } // ---- 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; } 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; /** * Ä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; secrets?: Record; idempotencyKey: string }): Promise<{ child?: NormalizedChild }>; } export interface Health { ok: boolean; latencyMs: number; message?: string; version?: string } export interface ConnectorContext { config: Record; secrets: Record; 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[]; healthCheck(ctx: ConnectorContext): Promise; listResources(ctx: ConnectorContext): Promise; /** * 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; /** Legt ein neues Angebot beim Anbieter an und liefert es als Vorlage zurück (Capability `catalog.write`). */ createCatalogItem?(ctx: ConnectorContext, spec: NewCatalogSpec): Promise; /** Liest Angebote/Produktvorlagen des Anbieters aus (Capability `catalog.list`). */ listCatalog?(ctx: ConnectorContext): Promise; /** Prüft Provisionierungsparameter eines Produkts (beim Speichern). Liefert eine Fehlermeldung oder null. */ validateProvisioning?(params: Record): 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; 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; secrets?: Record; 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 = new Set(['RATE_LIMITED']); const RETRYABLE: ReadonlySet = new Set(['UNREACHABLE', 'TIMEOUT', 'RATE_LIMITED', 'UPSTREAM_ERROR']); const MESSAGES: Record = { 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 } const defaultSleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** JSON-Anfrage mit Timeout. Wiederholungen mit exponentiellem Backoff nur für GET (idempotent) bzw. wenn `retryWrites`. */ export async function httpJson(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, init: { headers?: Record; body?: unknown; form?: Record } = {}, o: HttpOptions & { retryWrites?: boolean } = {}): Promise { 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 = { 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)}`);