2026-09-27 00:51:32 +02:00
/ * *
* 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'
2026-09-27 21:35:15 +02:00
| 'settings.update' | 'plan.change' | 'sso.login' | 'webhooks' | 'panel.password_reset' ;
2026-09-27 00:51:32 +02:00
/** Aktionen, die über `execute` laufen (immer als persistenter Auftrag). */
2026-09-27 21:35:15 +02:00
export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan' | 'reset_password' ;
2026-09-27 00:51:32 +02:00
export const ACTION_CAPABILITY : Record < ActionName , Capability > = {
2026-09-27 21:35:15 +02:00
suspend : 'lifecycle.suspend' , unsuspend : 'lifecycle.unsuspend' , extend : 'lifecycle.extend' , terminate : 'lifecycle.terminate' , change_plan : 'plan.change' , reset_password : 'panel.password_reset' ,
2026-09-27 00:51:32 +02:00
} ;
2026-09-27 21:35:15 +02:00
/** 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 < ActionName > = new Set ( [ 'terminate' , 'reset_password' ] ) ;
2026-09-27 00:51:32 +02:00
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. */
2026-09-27 21:35:15 +02:00
execute ? ( ctx : ConnectorContext , req : { action : ActionName ; externalRef : string ; params? : Record < string , unknown > ; secrets? : Record < string , string > ; idempotencyKey : string } ) : Promise < { resource? : NormalizedResource } > ;
2026-09-27 00:51:32 +02:00
}
// ---- 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 ) } ` ) ;