Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
237
packages/connector-licensing/src/index.ts
Normal file
237
packages/connector-licensing/src/index.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue