import { ConnectorError, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk'; import { mapLicense, type RawActivation, type RawGroup, type RawLicense, type RawProgram } from './index.js'; /** * Verwaltungs-Client für die Lizenzverwaltung im Kundencenter (Übersicht, Vergabe, Aktivierungen, Entitlements, * Lebenszyklus). Ergänzt den Connector, der nur den allgemeinen Ressourcen-Vertrag abdeckt. Benötigt einen * Service-Token (Scopes licenses:*, activations:reset, programs:read, products:read) oder API-Benutzer. * Fehlermeldungen des Lizenzsystems (detail) werden als ConnectorError INVALID_INPUT/CONFLICT weitergereicht, * damit die Oberfläche sie anzeigen kann (z. B. "Trial existiert bereits"). */ export interface LicenseActivation { id: number; instanceId: string | null; hardwareIdMasked: string | null; environment: string | null; lastSeenIp: string | null; productVersion: string | null; activatedAt: string; lastSeenAt: string; active: boolean } export interface LicenseDetail { resource: NormalizedResource; raw: RawLicense; activations: LicenseActivation[]; entitlement: { plan: string | null; modules: string[]; customerLimit: number | null; version: number }; limits: { maxActivations: number | null; activationLimit: number | null; userLimit: number | null; graceDays: number | null; effectiveGraceDays: number | null }; origin: { source: string | null; orderRef: string | null; externalRef: string | null; customerName: string | null; customerEmail: string | null; createdAt: string | null }; } export interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: 'LICENSED' | 'ADDON' | string; durationType: string; price: string | null; currency: string | null; planKey: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null; active: boolean } export interface LicensingCatalog { programs: { id: number; name: string }[]; products: CatalogProduct[] } const mask = (v: string | null | undefined) => (v ? (v.length <= 8 ? '****' : `${v.slice(0, 4)}…${v.slice(-4)}`) : null); const tokenCache = new Map(); const splitModules = (v: string | null | undefined): string[] => String(v ?? '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean); export function createLicensingAdmin(ctx: ConnectorContext, fetchImpl: typeof fetch = fetch) { const base = String(ctx.config.baseUrl ?? '').replace(/\/+$/, ''); if (!/^https?:\/\//.test(base)) throw new ConnectorError('BAD_CONFIG', 'baseUrl fehlt'); if (!ctx.secrets.token && !(ctx.secrets.username && ctx.secrets.password)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert'); async function auth(force = false): Promise { if (ctx.secrets.token) return `Bearer ${ctx.secrets.token}`; const key = `${base}|${ctx.secrets.username}|${ctx.secrets.password}`; const c = tokenCache.get(key); if (!force && c && Date.now() - c.at < 20 * 60_000) return `Bearer ${c.token}`; const r = await fetchImpl(`${base}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: ctx.secrets.username!, password: ctx.secrets.password! }).toString() }); const j = await r.json().catch(() => null) as { access_token?: string } | null; if (!r.ok || !j?.access_token) throw new ConnectorError('AUTH_FAILED', `HTTP ${r.status}`); tokenCache.set(key, { token: j.access_token, at: Date.now() }); return `Bearer ${j.access_token}`; } /** Fehlertext des Lizenzsystems (FastAPI: detail als Text, Objekt oder Liste von Validierungsfehlern). */ const detailOf = (b: unknown): string | undefined => { const d = (b as { detail?: unknown } | null)?.detail; if (typeof d === 'string') return d.slice(0, 300); if (Array.isArray(d)) return d.map((x) => `${Array.isArray(x?.loc) ? x.loc.slice(1).join('.') : ''}: ${x?.msg ?? ''}`).join('; ').slice(0, 300); if (d && typeof d === 'object' && typeof (d as { message?: unknown }).message === 'string') return String((d as { message: string }).message).slice(0, 300); return undefined; }; async function call(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown, headers: Record = {}, retried = false): Promise { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 15_000); let res: Response; try { res = await fetchImpl(`${base}${path}`, { method, signal: ctl.signal, headers: { accept: 'application/json', authorization: await auth(), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, body: body !== undefined ? JSON.stringify(body) : undefined }); } catch (e) { throw (e as { name?: string }).name === 'AbortError' ? new ConnectorError('TIMEOUT') : new ConnectorError('UNREACHABLE', (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message); } finally { clearTimeout(timer); } if (res.status === 401 && !retried && !ctx.secrets.token) { await auth(true); return call(method, path, body, headers, true); } if (res.status === 204) return undefined as T; const json = await res.json().catch(() => null); if (res.ok) return json as T; const detail = detailOf(json); if (res.status === 401 || res.status === 403) throw new ConnectorError('AUTH_FAILED', `HTTP ${res.status}`); if (res.status === 404) throw new ConnectorError('NOT_FOUND', detail); if (res.status === 409) throw new ConnectorError('CONFLICT', detail); if (res.status === 400 || res.status === 422) throw new ConnectorError('INVALID_INPUT', detail); if (res.status === 429) throw new ConnectorError('RATE_LIMITED'); throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`); } let programNames: Promise> | null = null; const programs = () => (programNames ??= call('GET', '/programs/?limit=1000').then((p) => new Map((Array.isArray(p) ? p : []).map((x) => [x.id, x.name])))); const normalize = async (l: RawLicense) => mapLicense(l, await programs(), 'UTC'); // Lizenzsysteme mit Verwaltungs-API liefern UTC (utc_timestamps) const activation = (a: RawActivation): LicenseActivation => ({ id: Number(a.id), instanceId: a.instance_id ?? null, hardwareIdMasked: mask(a.hardware_id), environment: a.environment ?? null, lastSeenIp: a.last_seen_ip ?? null, productVersion: a.product_version ?? null, activatedAt: a.activated_at, lastSeenAt: a.last_seen_at, active: a.is_active !== false, }); const lid = (id: string | number) => encodeURIComponent(String(id)); return { normalize, async get(id: string | number): Promise { const raw = await call('GET', `/licenses/${lid(id)}`); if (!raw || typeof raw.id !== 'number') throw new ConnectorError('INVALID_RESPONSE'); const acts = await call('GET', `/licenses/${lid(id)}/activations`).catch(() => raw.activations ?? []); return { resource: await normalize(raw), raw: { ...raw, license_key: '' }, // Schlüssel nie mitgeben (Abruf nur über reveal) activations: (Array.isArray(acts) ? acts : []).filter((a) => a.is_active !== false).map(activation), entitlement: { plan: raw.plan_key ?? null, modules: raw.modules ?? [], customerLimit: raw.customer_limit ?? null, version: raw.entitlement_version ?? 0 }, limits: { maxActivations: raw.max_activations ?? null, activationLimit: raw.activation_limit ?? null, userLimit: raw.user_limit ?? null, graceDays: raw.grace_days ?? null, effectiveGraceDays: raw.effective_grace_days ?? null }, origin: { source: raw.source ?? null, orderRef: raw.order_ref ?? null, externalRef: raw.external_ref ?? null, customerName: raw.customer_name ?? null, customerEmail: raw.customer_email ?? null, createdAt: raw.created_at ?? null }, }; }, /** Programme und Produkte (inkl. Add-on-Produkte) für die Vergabe. */ async catalog(): Promise { const [p, g] = await Promise.all([call('GET', '/programs/?limit=1000'), call('GET', '/products/groups')]); const products: CatalogProduct[] = []; for (const grp of Array.isArray(g) ? g : []) for (const x of grp.products ?? []) { products.push({ id: x.id, name: x.name, groupName: grp.name, programId: grp.program_id, type: x.product_type ?? 'LICENSED', durationType: x.duration_type ?? 'MONTH', price: x.price != null ? String(x.price) : null, currency: x.currency ?? null, planKey: x.plan_key ?? null, modules: splitModules(x.modules), customerLimit: x.customer_limit ?? null, userLimit: x.user_limit ?? null, active: x.is_active !== false && grp.is_active !== false }); } return { programs: (Array.isArray(p) ? p : []).map((x) => ({ id: x.id, name: x.name })), products }; }, create: (body: Record) => call('POST', '/licenses/', body), createTrial: (body: Record) => call('POST', '/licenses/trials', body), update: (id: string | number, body: Record) => call('PUT', `/licenses/${lid(id)}`, body), entitlement: (id: string | number, body: Record) => call('POST', `/licenses/${lid(id)}/entitlement`, body), lifecycle: (id: string | number, action: 'suspend' | 'unsuspend' | 'extend' | 'revoke', body: Record, idempotencyKey: string) => call<{ changed: boolean; license: RawLicense }>('POST', `/licenses/${lid(id)}/${action}`, body, { 'Idempotency-Key': idempotencyKey }), deleteActivation: (id: string | number, activationId: number) => call('DELETE', `/licenses/${lid(id)}/activations/${encodeURIComponent(String(activationId))}`), }; } export type LicensingAdmin = ReturnType;