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,27 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { query } from '../../core/db.js';
import { verifyAuditChain } from '../../core/audit.js';
import { requirePermission } from '../../core/auth.js';
import type { KcModule } from '../../core/module.js';
export const auditModule: KcModule = {
name: 'audit',
register(app: FastifyInstance) {
app.get('/admin/audit', async (req) => {
requirePermission(req, 'audit.read');
const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);
const rows = await query(
`SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json
FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
ORDER BY id DESC LIMIT ?`,
[q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit],
);
return rows.map((r) => ({ id: r.id, ts: r.ts, actorType: r.actor_type, actorId: r.actor_id, orgId: r.org_id, action: r.action, resourceType: r.resource_type, resourceId: r.resource_id, result: r.result, errorClass: r.error_class, correlationId: r.correlation_id, ip: r.ip, before: r.before_json, after: r.after_json }));
});
app.get('/admin/audit/verify', async (req) => {
requirePermission(req, 'audit.read');
return verifyAuditChain();
});
},
};

View file

@ -0,0 +1,176 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { verify } from '@node-rs/argon2';
import { randomUUID } from 'node:crypto';
import { isAbsolute, normalize } from 'node:path';
import { audit } from '../../core/audit.js';
import { one, query, run } from '../../core/db.js';
import { encrypt } from '../../core/crypto.js';
import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '../../ops/settings.js';
import { loadTargets, testTarget } from '../../ops/targets.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
const requestDir = () => process.env.BACKUP_REQUEST_DIR ?? '/var/lib/kundencenter/requests';
const FILES = { backup: 'backup-run', 'restore-test': 'backup-restore-test' } as const;
const hours = (iso?: string | null) => (iso ? (Date.now() - new Date(iso).getTime()) / 3600000 : null);
async function readState() {
let st: any = null;
try { st = JSON.parse(await readFile(statusFile(), 'utf8')); } catch { /* nicht eingerichtet oder noch nie gelaufen */ }
const envRemotes = (process.env.BACKUP_REMOTES ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const dbTargets = await query('SELECT name, type, enabled, last_test_ok FROM backup_targets ORDER BY name').catch(() => []);
const remotes = [...dbTargets.filter((t) => t.enabled).map((t) => t.name as string), ...envRemotes];
const run = st?.lastRun ?? null; const test = st?.lastRestoreTest ?? null;
const pending = ['backup', 'restore-test'].filter((a) => existsSync(join(requestDir(), FILES[a as keyof typeof FILES])));
return {
configured: !!process.env.BACKUP_AGE_RECIPIENT || !!st || (await passwordMeta().catch(() => ({ set: false }))).set,
running: st?.running ?? null, pendingRequests: pending,
lastRun: run, lastRestoreTest: test, history: st?.history ?? [],
stale: !run || !run.ok || (hours(run.at) ?? 999) > 26, restoreStale: !test || !test.ok || (hours(test.at) ?? 999) > 24 * 10,
schedule: { backup: 'täglich gegen 02:30 Uhr', restoreTest: 'sonntags gegen 04:30 Uhr' },
retention: { daily: Number(process.env.BACKUP_KEEP_DAILY ?? 14), weekly: Number(process.env.BACKUP_KEEP_WEEKLY ?? 8), monthly: Number(process.env.BACKUP_KEEP_MONTHLY ?? 12) },
localDir: process.env.BACKUP_DIR ?? '/var/backups/kundencenter', remotes, hasExternalTarget: remotes.length > 0, encryption: (await passwordMeta().catch(() => ({ set: false }))).set ? 'password' : process.env.BACKUP_AGE_RECIPIENT ? 'age' : 'none',
};
}
export const backupModule: KcModule = {
name: 'backup',
permissions: { staff: { admin: ['backup.read', 'backup.run'], superadmin: ['backup.read', 'backup.run', 'backup.secrets'] } },
register(app: FastifyInstance) {
app.get('/admin/backup', async (req) => { requirePermission(req, 'backup.read'); return readState(); });
// ---- Passwort und Ziele (nur Superadministratoren dürfen ändern) ------------------
const targetView = (r: any) => {
const cfg = typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json;
return { id: r.id, name: r.name, type: r.type, path: r.path, enabled: !!r.enabled, config: cfg, hasSecret: !!r.secrets_enc, hostKeyPinned: !!r.host_key, hostKeyFingerprint: cfg.hostKeyFingerprint ?? null,
lastTestAt: r.last_test_at, lastTestOk: r.last_test_ok === null ? null : !!r.last_test_ok, lastTestError: r.last_test_error };
};
app.get('/admin/backup/settings', async (req) => {
requirePermission(req, 'backup.read');
const pw = await passwordMeta();
const targets = (await query('SELECT * FROM backup_targets ORDER BY name')).map(targetView);
return { encryption: { mode: pw.set ? 'password' : process.env.BACKUP_AGE_RECIPIENT ? 'age' : 'none', passwordSet: pw.set, version: pw.version, updatedAt: pw.updatedAt, minLength: MIN_PASSWORD }, targets };
});
/** Backup-Passwort setzen/ändern: mindestens 11 Zeichen, Wiederholung, Bestätigung mit dem eigenen Anmeldepasswort. Der Wert wird nie angezeigt oder protokolliert. */
app.put('/admin/backup/password', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const b = z.object({ password: z.string().max(200), repeat: z.string().max(200), currentPassword: z.string().max(200) }).parse(req.body);
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
if (!u?.password_hash || !(await verify(u.password_hash, b.currentPassword).catch(() => false))) {
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.password.change', resourceType: 'backup', result: 'denied', errorClass: 'reauth_failed', correlationId: req.correlationId, ip: clientIp(req) });
throw forbidden('Ihr Anmeldepasswort ist falsch.', 'INVALID_CREDENTIALS');
}
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein.', 'PASSWORD_MISMATCH');
const problem = passwordProblem(b.password); if (problem) throw badRequest(problem, 'WEAK_PASSWORD');
const version = await setBackupPassword(b.password, a.user.id);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.password.change', resourceType: 'backup', correlationId: req.correlationId, ip: clientIp(req), after: { version } });
return { version };
});
const HOST = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$|^[0-9a-fA-F:]+$/;
const safeHost = (h: string) => HOST.test(h) && !/^(localhost|0\.0\.0\.0|127\.|169\.254\.|::1?$|metadata)/i.test(h);
const safeRel = (p: string) => !p.split('/').includes('..') && !/[\0\r\n]/.test(p);
const FORBIDDEN = ['/etc', '/proc', '/sys', '/dev', '/boot', '/usr', '/bin', '/sbin', '/lib', '/root', '/var/lib/kundencenter', '/srv/kundencenter'];
const str = (n: number) => z.string().trim().min(1).max(n);
const common = { name: str(100), path: z.string().trim().max(400).default('') };
const targetSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('sftp'), ...common, host: str(253), port: z.number().int().min(1).max(65535).default(22), user: str(100), authType: z.enum(['password', 'key']), password: z.string().max(500).optional(), privateKey: z.string().max(10000).optional(), keyPassphrase: z.string().max(500).optional() }),
z.object({ type: z.literal('ftp'), ...common, host: str(253), port: z.number().int().min(1).max(65535).default(21), user: str(100), tls: z.enum(['none', 'explicit', 'implicit']).default('explicit'), password: z.string().min(1).max(500) }),
z.object({ type: z.literal('gdrive'), ...common, scope: z.enum(['drive.file', 'drive']).default('drive.file'), token: z.string().min(10).max(8000), clientId: z.string().max(300).optional(), clientSecret: z.string().max(300).optional() }),
z.object({ type: z.literal('local'), ...common }),
]);
type NewTarget = z.infer<typeof targetSchema>;
function split(t: NewTarget) {
const cfg: Record<string, unknown> = {}; const sec: Record<string, string> = {};
if (t.type === 'sftp') {
if (!safeHost(t.host)) throw badRequest('Ungültiger oder nicht erlaubter Servername.', 'BAD_HOST');
cfg.host = t.host; cfg.port = t.port; cfg.user = t.user; cfg.authType = t.authType;
if (t.authType === 'password') { if (!t.password) throw badRequest('Bitte ein Passwort angeben.', 'SECRET_MISSING'); sec.password = t.password; }
else { if (!t.privateKey || !/PRIVATE KEY/.test(t.privateKey)) throw badRequest('Bitte den privaten Schlüssel (PEM) einfügen.', 'SECRET_MISSING'); sec.privateKey = t.privateKey; if (t.keyPassphrase) sec.keyPassphrase = t.keyPassphrase; }
} else if (t.type === 'ftp') {
if (!safeHost(t.host)) throw badRequest('Ungültiger oder nicht erlaubter Servername.', 'BAD_HOST');
cfg.host = t.host; cfg.port = t.port; cfg.user = t.user; cfg.tls = t.tls; sec.password = t.password;
} else if (t.type === 'gdrive') {
try { const j = JSON.parse(t.token); if (!j.access_token && !j.refresh_token) throw new Error(); } catch { throw badRequest('Der Token muss der JSON-Text aus "rclone authorize" sein.', 'BAD_TOKEN'); }
cfg.scope = t.scope; sec.token = t.token; if (t.clientId) sec.clientId = t.clientId; if (t.clientSecret) sec.clientSecret = t.clientSecret;
}
let path = t.path.replace(/\\/g, '/');
if (t.type === 'local') {
path = normalize(path);
if (!isAbsolute(path) || path === '/' || FORBIDDEN.some((f) => path === f || path.startsWith(f + '/'))) throw badRequest('Bitte einen absoluten Ordner angeben (z. B. ein eingehängtes Netzlaufwerk unter /mnt/...). Systemordner sind nicht erlaubt.', 'BAD_PATH');
} else if (!safeRel(path)) throw badRequest('Ungültiger Ordnerpfad.', 'BAD_PATH');
return { cfg, sec, path };
}
app.post('/admin/backup/targets', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const b = targetSchema.parse(req.body);
if (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name])) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
const { cfg, sec, path } = split(b); const id = randomUUID();
await run('INSERT INTO backup_targets (id, name, type, path, config_json, secrets_enc) VALUES (?,?,?,?,?,?)', [id, b.name, b.type, path, JSON.stringify(cfg), Object.keys(sec).length ? encrypt(JSON.stringify(sec)) : null]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.create', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.name, type: b.type, path, config: cfg, secrets: Object.keys(sec) } });
return { id };
});
app.patch('/admin/backup/targets/:id', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ enabled: z.boolean().optional(), name: str(100).optional() }).parse(req.body);
const r = await one('SELECT * FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
if (b.name && b.name !== r.name && (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name]))) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
await run('UPDATE backup_targets SET enabled = COALESCE(?, enabled), name = COALESCE(?, name) WHERE id = ?', [b.enabled === undefined ? null : b.enabled ? 1 : 0, b.name ?? null, id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.update', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { enabled: !!r.enabled, name: r.name }, after: b });
return { status: 'ok' };
});
app.delete('/admin/backup/targets/:id', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT name, type FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
await run('DELETE FROM backup_targets WHERE id = ?', [id]); // bereits abgelegte Sicherungen am Ziel bleiben unberührt
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.delete', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { name: r.name, type: r.type } });
return { status: 'ok' };
});
/** Verbindungstest (legt Ordner an, schreibt und löscht eine Testdatei). Bei SFTP wird der Server-Schlüssel beim ersten Erfolg gemerkt. */
app.post('/admin/backup/targets/:id/test', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const t = (await loadTargets(false)).find((x) => x.id === id); if (!t) throw notFound();
const res = await testTarget(t);
if (res.ok && res.hostKey) { const cfg = { ...t.config, hostKeyFingerprint: res.fingerprint }; await run('UPDATE backup_targets SET host_key = ?, config_json = ? WHERE id = ?', [res.hostKey, JSON.stringify(cfg), id]); }
await run('UPDATE backup_targets SET last_test_at = UTC_TIMESTAMP(3), last_test_ok = ?, last_test_error = ? WHERE id = ?', [res.ok ? 1 : 0, res.ok ? null : (res.error ?? '').slice(0, 400), id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.test', resourceType: 'backup_target', resourceId: id, result: res.ok ? 'success' : 'failure', errorClass: res.ok ? undefined : 'test_failed', correlationId: req.correlationId, ip: clientIp(req), after: { fingerprint: res.fingerprint } });
return { ok: res.ok, error: res.error ?? null, fingerprint: res.fingerprint ?? null, newHostKey: !!res.hostKey };
});
app.post('/admin/backup/targets/:id/reset-hostkey', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT config_json FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
const cfg = typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json; delete cfg.hostKeyFingerprint;
await run('UPDATE backup_targets SET host_key = NULL, config_json = ?, last_test_ok = NULL WHERE id = ?', [JSON.stringify(cfg), id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.hostkey_reset', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
/**
* Sicherung oder Wiederherstellungstest anfordern. Die Oberfläche startet nichts selbst: Sie legt nur eine Anfragedatei ab,
* die ein systemd-Pfadauslöser als root abarbeitet (feste Aktionen, keine Eingaben).
*/
app.post('/admin/backup/run', async (req, reply) => {
const a = requirePermission(req, 'backup.run');
const { action } = z.object({ action: z.enum(['backup', 'restore-test']) }).parse(req.body);
const st = await readState();
if (st.running) throw conflict('Es läuft bereits ein Vorgang. Bitte warten.', 'BACKUP_RUNNING');
if (st.pendingRequests.length) throw conflict('Es liegt bereits eine Anfrage vor. Bitte einen Moment warten.', 'BACKUP_PENDING');
await mkdir(requestDir(), { recursive: true });
await writeFile(join(requestDir(), FILES[action]), `${new Date().toISOString()}\n`, { mode: 0o644 });
await audit({ actorType: 'user', actorId: a.user.id, action: `backup.request.${action}`, resourceType: 'backup', correlationId: req.correlationId, ip: clientIp(req) });
return reply.code(202).send({ status: 'requested' });
});
},
};

View file

@ -0,0 +1,273 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { calculatePrice } from '@kc/platform/pricing';
import { getConnector, loadInstance } from '@kc/connectors';
import { ConnectorError } from '@kc/connector-sdk';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
import { canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const int = (max: number) => z.number().int().min(0).max(max);
const versionSchema = z.object({
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
provisioning: z.record(z.string(), z.unknown()).default({}),
});
type VersionIn = z.infer<typeof versionSchema>;
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
const ACTIONS = z.array(z.enum(CUSTOMER_ACTIONS));
/** Fachregeln für Laufzeiten/Preise je Abrechnungsintervall. */
function checkTerms(v: VersionIn): number {
if (v.billingInterval === 'once') {
if (v.recurringCents > 0) throw badRequest('Einmalprodukte haben keinen wiederkehrenden Preis', 'BAD_TERMS');
if (v.termMonths > 0 || v.renewal === 'auto') throw badRequest('Einmalprodukte haben keine Laufzeit und keine Verlängerung', 'BAD_TERMS');
return 0;
}
const per = v.billingInterval === 'monthly' ? 1 : 12;
const renewalMonths = v.renewal === 'auto' ? (v.renewalTermMonths ?? per) : 0;
if (v.renewal === 'auto' && renewalMonths < 1) throw badRequest('Bei automatischer Verlängerung ist eine Verlängerungslaufzeit nötig', 'BAD_TERMS');
if (v.termMonths > 0 && v.termMonths % per !== 0) throw badRequest(`Die Laufzeit muss ein Vielfaches des Abrechnungsintervalls (${per} Monat${per > 1 ? 'e' : ''}) sein`, 'BAD_TERMS');
return renewalMonths;
}
async function checkConnector(instanceId: string | null | undefined, provisioning: Record<string, unknown>, forActivation = false): Promise<void> {
if (!instanceId) { if (Object.keys(provisioning).length) throw badRequest('Provisionierungsparameter ohne Verbindung', 'BAD_PROVISIONING'); return; }
const inst = await one('SELECT connector_key, capabilities_json FROM connector_instances WHERE id = ?', [instanceId]);
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
const c = getConnector(inst.connector_key);
const msg = c.validateProvisioning?.(provisioning) ?? null;
if (msg) throw badRequest(`Provisionierung ungültig: ${msg}`, 'BAD_PROVISIONING');
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
if (caps.length && !caps.includes('lifecycle.create')) throw badRequest('Diese Verbindung kann aktuell keine Objekte anlegen (Zugangsdaten mit Schreibrechten prüfen)', 'NO_CREATE_CAPABILITY');
// Edition (Schlüssel-Präfix) und festes Ablaufdatum: ein aktives Produkt darf sie nur versprechen, wenn der Anbieter sie nachweislich umsetzt.
if (forActivation) {
if (provisioning.keyPrefix && !caps.includes('license.key_prefix')) throw badRequest('Produkte mit Edition (Schlüssel-Präfix) können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt. Als Entwurf ist es gespeichert.', 'NEEDS_LICENSE_EXTENSION');
if (provisioning.validityDays && !caps.includes('license.expiry')) throw badRequest('Produkte mit festem Ablaufdatum können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt.', 'NEEDS_LICENSE_EXTENSION');
}
}
async function insertVersion(c: Parameters<typeof run>[2], productId: string, v: VersionIn, by: string): Promise<string> {
const tax = await one('SELECT id, rate_bp, active FROM tax_rates WHERE id = ?', [v.taxRateId], c);
if (!tax || !tax.active) throw badRequest('Steuersatz nicht gefunden', 'BAD_TAX');
const renewalMonths = checkTerms(v);
const last = await one('SELECT COALESCE(MAX(version), 0) AS n FROM product_versions WHERE product_id = ?', [productId], c);
const id = randomUUID();
await run(
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
return id;
}
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
await checkConnector(b.connectorInstanceId, b.version.provisioning, b.status === 'active');
const id = randomUUID();
await tx(async (c) => {
await run('INSERT INTO products (id, sku, category, status, connector_instance_id, external_ref, orderable_by_customer, requires_approval, customer_actions) VALUES (?,?,?,?,?,?,?,?,?)',
[id, b.sku, b.category, b.status, b.connectorInstanceId ?? null, b.externalRef ?? null, b.orderableByCustomer ? 1 : 0, b.requiresApproval ? 1 : 0, JSON.stringify(b.customerActions)], c);
await insertVersion(c, id, b.version, actorId);
});
return id;
}
// ---- Produktpakete (Vorlagen, z. B. Editionen einer Software) --------------------
const bundleSchema = z.object({
key: z.string().regex(/^[a-z0-9-]{2,40}$/), name: z.string().max(200), description: z.string().max(2000).optional(), source: z.string().max(200).optional(),
products: z.array(z.object({
sku: z.string().regex(/^[A-Za-z0-9._-]{2,50}$/), name: z.string().max(200), description: z.string().max(2000).optional(), category: z.enum(['hosting', 'license', 'addon', 'service']),
priceBasis: z.enum(['net', 'gross']), setupCents: int(100_000_00), recurringCents: int(100_000_00), taxBp: int(10000), interval: z.enum(['once', 'monthly', 'yearly']),
termMonths: int(120), renewal: z.enum(['auto', 'none']), renewalTermMonths: int(120), noticeDays: int(365), provisioning: z.record(z.string(), z.unknown()),
orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]),
})).min(1).max(50),
});
type Bundle = z.infer<typeof bundleSchema>;
const bundleDir = fileURLToPath(new URL('../../../../../bundles/', import.meta.url));
function loadBundles(): Bundle[] {
if (!existsSync(bundleDir)) return [];
const out: Bundle[] = [];
for (const f of readdirSync(bundleDir).filter((n) => n.endsWith('.json')).sort()) {
const r = bundleSchema.safeParse(JSON.parse(readFileSync(join(bundleDir, f), 'utf8')));
if (r.success) out.push(r.data);
}
return out;
}
const versionView = (r: any) => ({
id: r.vid ?? r.id, version: r.version, name: r.vname ?? r.name, description: r.description, taxBp: r.tax_bp, priceBasis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, currency: r.currency,
billingInterval: r.billing_interval, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days,
});
const productSelect = `SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.description, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days, v.provisioning_json, i.name AS connector_name
FROM products p LEFT JOIN product_versions v ON v.id = p.current_version_id LEFT JOIN connector_instances i ON i.id = p.connector_instance_id`;
const j = (v: unknown, d: unknown) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
const productView = (r: any) => ({
id: r.id, sku: r.sku, category: r.category, status: r.status, connectorInstanceId: r.connector_instance_id, externalRef: r.external_ref ?? null, connectorName: r.connector_name, orderableByCustomer: !!r.orderable_by_customer, requiresApproval: !!r.requires_approval,
customerActions: j(r.customer_actions, []), provisioning: j(r.provisioning_json, {}), current: r.vid ? versionView(r) : null,
});
export const catalogModule: KcModule = {
name: 'catalog',
permissions: { staff: { support: ['products.read'], accounting: ['products.read'], admin: ['products.read', 'products.write'], superadmin: ['products.read', 'products.write'] } },
register(app: FastifyInstance) {
app.get('/admin/tax-rates', async (req) => {
requirePermission(req, 'products.read');
return (await query('SELECT id, name, rate_bp FROM tax_rates WHERE active = 1 ORDER BY rate_bp DESC')).map((t) => ({ id: t.id, name: t.name, rateBp: t.rate_bp }));
});
app.get('/admin/products', async (req) => {
requirePermission(req, 'products.read');
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
});
app.get('/admin/products/:id', async (req) => {
requirePermission(req, 'products.read');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
if (!r) throw notFound();
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
return { ...productView(r), versions: versions.map(versionView) };
});
app.post('/admin/products', async (req) => {
const a = requirePermission(req, 'products.write');
const b = z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/, 'Nur Buchstaben, Ziffern, Punkt, Unterstrich und Bindestrich'), category: z.enum(['hosting', 'license', 'addon', 'service']),
connectorInstanceId: z.string().uuid().nullable().optional(), externalRef: z.string().trim().max(100).optional(), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), status: z.enum(['draft', 'active']).default('draft'), version: versionSchema }).parse(req.body);
if (b.externalRef && !b.connectorInstanceId) throw badRequest('Herkunft ohne Verbindung', 'BAD_CONNECTOR');
const id = await createProduct(b, a.user.id);
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.sku, importedFrom: b.externalRef, status: b.status, version: b.version.name } });
return { id };
});
/** Preis-/Vertragsänderung = neue unveränderliche Version. Bestehende Bestellungen und Verträge behalten ihren Snapshot. */
app.post('/admin/products/:id/versions', async (req) => {
const a = requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const v = versionSchema.parse(req.body);
const p = await one('SELECT id, connector_instance_id, status FROM products WHERE id = ?', [id]);
if (!p) throw notFound();
await checkConnector(p.connector_instance_id, v.provisioning, p.status === 'active');
const vid = await tx((c) => insertVersion(c, id, v, a.user.id));
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.version', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { versionId: vid, setupCents: v.setupCents, recurringCents: v.recurringCents } });
return { versionId: vid };
});
app.patch('/admin/products/:id', async (req) => {
const a = requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ status: z.enum(['draft', 'active', 'retired']).optional(), orderableByCustomer: z.boolean().optional(), requiresApproval: z.boolean().optional(), customerActions: ACTIONS.optional(), connectorInstanceId: z.string().uuid().nullable().optional() }).parse(req.body);
const before = await one(`${productSelect} WHERE p.id = ?`, [id]);
if (!before) throw notFound();
if (b.status === 'active' && !before.vid) throw badRequest('Ohne Version nicht aktivierbar');
if (b.status === 'active') await checkConnector(before.connector_instance_id, j(before.provisioning_json, {}) as Record<string, unknown>, true);
if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record<string, unknown>);
await run('UPDATE products SET status = COALESCE(?, status), orderable_by_customer = COALESCE(?, orderable_by_customer), requires_approval = COALESCE(?, requires_approval), customer_actions = COALESCE(?, customer_actions), connector_instance_id = ? WHERE id = ?',
[b.status ?? null, b.orderableByCustomer === undefined ? null : b.orderableByCustomer ? 1 : 0, b.requiresApproval === undefined ? null : b.requiresApproval ? 1 : 0, b.customerActions ? JSON.stringify(b.customerActions) : null, b.connectorInstanceId === undefined ? before.connector_instance_id : b.connectorInstanceId, id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.update', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: before.status, orderableByCustomer: !!before.orderable_by_customer, requiresApproval: !!before.requires_approval }, after: b });
return { status: 'ok' };
});
/**
* Übernahme: Angebote/Programme eines Anbieters live auslesen (Verbindung, Zugangsdaten bleiben serverseitig).
* Zeigt je Eintrag, wie viele Produkte bereits daraus angelegt wurden.
*/
app.get('/admin/connectors/:id/catalog', async (req) => {
requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
try {
const { connector, ctx } = await loadInstance(id, req.correlationId);
if (!connector.listCatalog || !(await connector.capabilities(ctx)).includes('catalog.list')) throw badRequest('Diese Verbindung unterstützt keine Produktübernahme', 'NO_CATALOG');
const items = await connector.listCatalog(ctx);
const counts = await query('SELECT external_ref, COUNT(*) AS n FROM products WHERE connector_instance_id = ? AND external_ref IS NOT NULL GROUP BY external_ref', [id]);
const byRef = new Map(counts.map((c) => [c.external_ref as string, Number(c.n)]));
return items.map((i) => ({ ...i, importedProducts: byRef.get(i.externalRef) ?? 0 }));
} catch (e) {
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`);
throw e;
}
});
/** Vorlagenpakete (Dateien in /bundles): Vorschau mit Preisen, Laufzeiten und Provisionierung. */
app.get('/admin/product-bundles', async (req) => {
requirePermission(req, 'products.write');
const taken = new Set((await query('SELECT sku FROM products')).map((r) => r.sku as string));
return loadBundles().map((b) => ({ ...b, products: b.products.map((p) => ({ ...p, exists: taken.has(p.sku) })) }));
});
/** Importiert ausgewählte Produkte eines Pakets als Entwürfe und verknüpft sie mit Verbindung und Anbieter-Programm. */
app.post('/admin/product-bundles/:key/import', async (req) => {
const a = requirePermission(req, 'products.write');
const { key } = z.object({ key: z.string().max(40) }).parse(req.params);
const b = z.object({ connectorInstanceId: z.string().uuid(), programRef: z.string().trim().min(1).max(100), skus: z.array(z.string()).min(1).max(50) }).parse(req.body);
const bundle = loadBundles().find((x) => x.key === key);
if (!bundle) throw notFound('Paket nicht gefunden');
const inst = await one('SELECT connector_key FROM connector_instances WHERE id = ?', [b.connectorInstanceId]);
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
const tax = await query('SELECT id, rate_bp FROM tax_rates WHERE active = 1');
const created: { sku: string; id: string }[] = []; const skipped: { sku: string; reason: string }[] = [];
for (const sku of b.skus) {
const p = bundle.products.find((x) => x.sku === sku);
if (!p) { skipped.push({ sku, reason: 'nicht im Paket' }); continue; }
const t = tax.find((x) => Number(x.rate_bp) === p.taxBp);
if (!t) { skipped.push({ sku, reason: `Steuersatz ${p.taxBp / 100} % nicht angelegt` }); continue; }
try {
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
created.push({ sku, id });
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.bundle.import', resourceType: 'product', connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { bundle: key, programRef: b.programRef, created: created.map((c) => c.sku), skipped } });
return { created, skipped };
});
/**
* Neuen Hosting-Tarif beim Anbieter anlegen UND als Produkt definieren. Wirkt sofort beim Anbieter (Tarif entsteht dort):
* Name muss frei sein, Größen in GB, "unbegrenzt" nur wenn die Instanz es kennt. Danach entsteht das Produkt (Entwurf oder aktiv).
*/
app.post('/admin/connectors/:id/hosting-plans', async (req) => {
const a = requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const lim = z.number().min(0).max(1_000_000).nullable().optional();
const b = z.object({
plan: z.object({ name: z.string().trim().min(1).max(100), limits: z.object({ diskSpaceGb: lim, trafficGb: lim, domains: lim, subdomains: lim, emailAccounts: lim, emailAddresses: lim, emailForwardings: lim, databases: lim, ftpUsers: lim, scheduledTasks: lim }).default({}), permissions: z.record(z.string(), z.boolean()).optional() }),
product: z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/), status: z.enum(['draft', 'active']).default('draft'), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), version: versionSchema.omit({ provisioning: true }) }),
}).parse(req.body);
const inst = await one('SELECT id, connector_key, capabilities_json FROM connector_instances WHERE id = ?', [id]);
if (!inst) throw notFound();
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
if (!caps.includes('catalog.write')) throw badRequest('Diese Verbindung kann keine Tarife anlegen', 'NO_CATALOG_WRITE');
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.product.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
let item;
try { const { connector, ctx } = await loadInstance(id, req.correlationId); item = await connector.createCatalogItem!(ctx, b.plan); }
catch (e) { if (e instanceof ConnectorError) throw new AppError(e.code === 'CONFLICT' ? 409 : e.code === 'INVALID_INPUT' ? 400 : 502, e.code === 'CONFLICT' ? 'PLAN_EXISTS' : 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; }
await audit({ actorType: 'user', actorId: a.user.id, action: 'catalog.plan.create', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.plan.name, ref: item.externalRef, limits: b.plan.limits } });
try {
const productId = await createProduct({ sku: b.product.sku, category: item.category, connectorInstanceId: id, externalRef: item.externalRef, orderableByCustomer: b.product.orderableByCustomer, requiresApproval: b.product.requiresApproval, customerActions: b.product.customerActions, status: b.product.status,
version: { ...b.product.version, provisioning: item.provisioning } }, a.user.id);
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: productId, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.product.sku, importedFrom: item.externalRef, viaPlanCreate: true } });
return { productId, plan: { ref: item.externalRef, name: item.name, features: item.features } };
} catch (e) {
// Der Tarif existiert beim Anbieter bereits: nicht erneut anlegen, sondern über "Übernehmen" als Produkt anlegen
throw new AppError(e instanceof AppError ? e.status : 500, 'PRODUCT_AFTER_PLAN_FAILED', `Der Tarif „${item.name}“ wurde beim Anbieter angelegt, das Produkt konnte aber nicht angelegt werden${e instanceof AppError ? `: ${e.message}` : ''}. Bitte den Tarif über „Aus Verbindung übernehmen“ als Produkt übernehmen.`);
}
});
/** Katalog für Kunden: nur aktive, bestellbare Produkte, Preise für die jeweilige Organisation (Netto/Brutto). */
app.get('/catalog', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid() }).parse(req.query);
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
return rows.map((r) => {
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
});
});
},
};

View file

@ -0,0 +1,98 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { connect as tlsConnect } from 'node:tls';
import { encryptSecrets, getConnector, listConnectors } from '@kc/connectors';
import { one, query, run } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const parseErr = (v: unknown): { tech: string; hint: string } | null => { if (!v) return null; try { const o = JSON.parse(String(v)); return { tech: String(o.tech ?? ''), hint: String(o.hint ?? '') }; } catch { return { tech: String(v), hint: '' }; } };
const pub = (r: any) => ({
id: r.id, connector: r.connector_key, name: r.name, enabled: !!r.enabled, syncIntervalSec: r.sync_interval_sec, health: r.health, healthMessage: r.health_message,
capabilities: r.capabilities_json ?? [], lastOkAt: r.last_ok_at, lastError: parseErr(r.last_error), lastErrorAt: r.last_error_at ?? null, lastSyncAt: r.last_sync_at, config: r.config_json, hasSecrets: !!r.secrets_enc, resources: Number(r.resources ?? 0),
});
/** Trennt Eingabefelder in Konfiguration und Geheimnisse anhand der Felddefinition des Connectors. */
function split(key: string, input: Record<string, string>) {
const fields = getConnector(key).configFields; const config: Record<string, string> = {}; const secrets: Record<string, string> = {};
for (const f of fields) { const v = input[f.name]?.trim(); if (v) (f.secret ? secrets : config)[f.name] = v; }
const missing = fields.filter((f) => f.required && !config[f.name] && !secrets[f.name]).map((f) => f.label);
return { config, secrets, missing };
}
export const connectorsModule: KcModule = {
name: 'connectors',
permissions: { staff: { admin: ['connectors.read'], superadmin: ['connectors.read', 'connectors.write'] } },
register(app: FastifyInstance) {
app.get('/admin/connector-types', async (req) => {
requirePermission(req, 'connectors.read');
return listConnectors().map((c) => ({ key: c.key, name: c.displayName, fields: c.configFields.map((f) => ({ name: f.name, label: f.label, secret: !!f.secret, required: !!f.required, placeholder: f.placeholder ?? '', help: f.help ?? '', advanced: !!f.advanced, options: f.options ?? null })) }));
});
/**
* Zertifikat-Fingerabdruck eines Servers holen (für selbstsignierte Zertifikate). Es wird nur ein TLS-Handshake durchgeführt und
* das Zertifikat gelesen, keine Anfrage gesendet. Loopback/Link-Local sind gesperrt.
*/
app.post('/admin/connector-tls-check', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
requirePermission(req, 'connectors.write');
const { url } = z.object({ url: z.string().max(300) }).parse(req.body);
let u: URL; try { u = new URL(url); } catch { throw badRequest('Ungültige Adresse', 'BAD_URL'); }
if (u.protocol !== 'https:') throw badRequest('Bitte eine Adresse mit https:// angeben', 'BAD_URL');
const host = u.hostname.replace(/^\[|\]$/g, ''); if (/^(localhost|0\.0\.0\.0|127\.|169\.254\.|::1?$)/i.test(host)) throw badRequest('Diese Adresse ist nicht erlaubt', 'BAD_HOST');
const port = Number(u.port || 443);
return new Promise((resolve, reject) => {
const sock = tlsConnect({ host, port, servername: host, rejectUnauthorized: false, timeout: 8000 }, () => {
const c = sock.getPeerCertificate(); const trusted = sock.authorized; sock.end();
if (!c || !c.fingerprint256) return reject(new AppError(502, 'NO_CERT', 'Der Server hat kein Zertifikat geliefert.'));
resolve({ fingerprint: c.fingerprint256, subject: c.subject?.CN ?? null, issuer: c.issuer?.CN ?? null, validTo: c.valid_to ?? null, trusted, selfSigned: c.issuer?.CN === c.subject?.CN && !trusted });
});
sock.on('timeout', () => { sock.destroy(); reject(new AppError(502, 'TIMEOUT', 'Der Server hat nicht rechtzeitig geantwortet.')); });
sock.on('error', () => reject(new AppError(502, 'UNREACHABLE', 'Der Server ist nicht erreichbar (Adresse und Port prüfen).')));
});
});
app.get('/admin/connectors', async (req) => {
requirePermission(req, 'connectors.read');
return (await query('SELECT i.*, (SELECT COUNT(*) FROM resources r WHERE r.instance_id = i.id) AS resources FROM connector_instances i ORDER BY i.name')).map(pub);
});
app.post('/admin/connectors', async (req) => {
const a = requirePermission(req, 'connectors.write'); // Secrets: nur Superadmin
const b = z.object({ connector: z.string().max(50), name: z.string().trim().min(1).max(100), values: z.record(z.string(), z.string().max(500)), syncIntervalSec: z.number().int().min(60).max(86400).default(300) }).parse(req.body);
try { getConnector(b.connector); } catch { throw badRequest('Unbekannter Connector'); }
const { config, secrets, missing } = split(b.connector, b.values);
if (missing.length) throw badRequest(`Pflichtfelder fehlen: ${missing.join(', ')}`);
if (await one('SELECT 1 AS x FROM connector_instances WHERE name = ?', [b.name])) throw conflict('Name bereits vergeben');
const id = randomUUID();
await run('INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc, sync_interval_sec) VALUES (?,?,?,?,?,?)', [id, b.connector, b.name, JSON.stringify(config), encryptSecrets(secrets), b.syncIntervalSec]);
await enqueue('connector.sync', { instanceId: id }, { idempotencyKey: `sync:${id}:initial`, correlationId: req.correlationId });
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.create', resourceType: 'connector', resourceId: id, connector: b.connector, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.name, config, secrets: Object.keys(secrets) } });
return { id };
});
app.patch('/admin/connectors/:id', async (req) => {
const a = requirePermission(req, 'connectors.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ enabled: z.boolean().optional(), syncIntervalSec: z.number().int().min(60).max(86400).optional(), values: z.record(z.string(), z.string().max(500)).optional() }).parse(req.body);
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [id]);
if (!inst) throw notFound();
let config = inst.config_json as Record<string, string>; let secretsEnc = inst.secrets_enc as string | null;
if (b.values) {
// Leere Werte behalten den bisherigen Wert; Geheimnisse werden nur ersetzt, wenn neu angegeben.
const { config: c2, secrets } = split(inst.connector_key, b.values);
config = { ...config, ...c2 };
if (Object.keys(secrets).length) { const { decrypt } = await import('../../core/crypto.js'); const old = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {}; secretsEnc = encryptSecrets({ ...old, ...secrets }); }
}
await run('UPDATE connector_instances SET enabled = COALESCE(?, enabled), sync_interval_sec = COALESCE(?, sync_interval_sec), config_json = ?, secrets_enc = ? WHERE id = ?', [b.enabled === undefined ? null : b.enabled ? 1 : 0, b.syncIntervalSec ?? null, JSON.stringify(config), secretsEnc, id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.update', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), before: { enabled: !!inst.enabled, config: inst.config_json }, after: { enabled: b.enabled, syncIntervalSec: b.syncIntervalSec, config, secretsChanged: !!b.values } });
return { status: 'ok' };
});
app.post('/admin/connectors/:id/sync', async (req) => {
const a = requirePermission(req, 'connectors.read');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
await enqueue('connector.sync', { instanceId: id }, { idempotencyKey: `sync:${id}:manual:${Date.now()}`, correlationId: req.correlationId });
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.sync.request', resourceType: 'connector', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'queued' };
});
},
};

View file

@ -0,0 +1,264 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { canInOrg } from '../../core/policy.js';
import { createInvitedUser, inviteLink, mailInvite } from '../../core/accounts.js';
import { enqueue } from '../../core/jobs.js';
import { calculatePrice } from '@kc/platform/pricing';
import { addMonths, consumerTerms } from '@kc/platform/contractterms';
import { getConnector, loadInstance, syncInstance } from '@kc/connectors';
import { ConnectorError, type ImportableCustomer } from '@kc/connector-sdk';
import { AppError } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
const opt = (n: number) => z.string().trim().max(n).optional().transform((v) => (v ? v : null));
const billing = z.object({
company: opt(200), contactName: opt(150), street: opt(200), zip: opt(20), city: opt(100),
country: z.string().length(2).toUpperCase().default('DE'), vatId: opt(30), billingEmail: opt(254), phone: opt(50),
});
type BillingIn = z.infer<typeof billing>;
/** Fachregeln je Kundenart: Privatkunde ohne Firma/USt-IdNr., Geschäftskunde mit Firmenname. */
function applyTypeRules(type: 'private' | 'business', name: string, b: Partial<BillingIn>, opts: { partial?: boolean } = {}): void {
if (type === 'private') {
if (b.company || b.vatId) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr.', 'PRIVATE_NO_COMPANY');
} else {
if (!opts.partial && !(b.company ?? name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY');
if (b.vatId && !/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(b.vatId.replace(/[\s.-]/g, '').toUpperCase())) throw badRequest('USt-IdNr. hat ein ungültiges Format (z. B. DE123456789)', 'INVALID_VAT_ID');
}
}
const normVat = (v: string | null | undefined) => (v ? v.replace(/[\s.-]/g, '').toUpperCase() : v);
async function nextCustomerNumber(c: Parameters<typeof one>[2]): Promise<string> {
await run('UPDATE customer_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE id = 1', [], c as never);
const r = await one('SELECT LAST_INSERT_ID() AS n', [], c);
return `K-${r!.n}`;
}
export const customersModule: KcModule = {
name: 'customers',
register(app: FastifyInstance) {
// ---- Admin: Kunden -----------------------------------------------------
app.get('/admin/customers', async (req) => {
requirePermission(req, 'customers.read');
const q = z.object({ q: z.string().max(100).optional(), type: z.enum(['private', 'business']).optional() }).parse(req.query);
const rows = await query(
`SELECT o.id, o.customer_number, o.name, o.customer_type, o.status, o.created_at, b.city, b.billing_email,
(SELECT COUNT(*) FROM memberships m WHERE m.org_id = o.id) AS members
FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id
WHERE (? IS NULL OR o.name LIKE CONCAT('%', ?, '%') OR o.customer_number LIKE CONCAT('%', ?, '%') OR b.billing_email LIKE CONCAT('%', ?, '%')) AND (? IS NULL OR o.customer_type = ?)
ORDER BY o.created_at DESC LIMIT 200`,
[q.q ?? null, q.q ?? null, q.q ?? null, q.q ?? null, q.type ?? null, q.type ?? null],
);
return rows.map((r) => ({ id: r.id, customerNumber: r.customer_number, name: r.name, customerType: r.customer_type, status: r.status, createdAt: r.created_at, city: r.city, billingEmail: r.billing_email, members: Number(r.members) }));
});
app.post('/admin/customers', async (req) => {
const a = requirePermission(req, 'customers.write');
const b = z.object({ type: z.enum(['private', 'business']), name: z.string().trim().min(1).max(200), owner: z.object({ email, name: z.string().trim().min(1).max(150).optional() }), billing: billing.prefault({}) }).parse(req.body);
b.billing.vatId = normVat(b.billing.vatId) ?? null;
applyTypeRules(b.type, b.name, b.billing);
const ownerName = b.owner.name ?? b.name; // Privatkunde: Ansprechpartner = Kunde selbst
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
const orgId = randomUUID();
const res = await tx(async (c) => {
const number = await nextCustomerNumber(c);
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
const bp = b.billing;
await run('INSERT INTO billing_profiles (org_id, company, contact_name, street, zip, city, country, vat_id, billing_email, phone) VALUES (?,?,?,?,?,?,?,?,?,?)',
[orgId, b.type === 'business' ? (bp.company ?? b.name) : null, b.type === 'private' ? b.name : bp.contactName, bp.street, bp.zip, bp.city, bp.country, bp.vatId, bp.billingEmail ?? b.owner.email, bp.phone], c);
const inv = await createInvitedUser(c, { email: b.owner.email, name: ownerName, kind: 'customer' });
await run('INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,\'owner\')', [orgId, inv.userId], c);
await enqueue('discord.notify', { event: 'customer.created', customerNumber: number }, { idempotencyKey: `customer.created:${orgId}`, correlationId: req.correlationId }, c);
return { number, inv };
});
const mail = await mailInvite(b.owner.email, ownerName, res.inv.token);
await audit({ actorType: 'user', actorId: a.user.id, orgId, action: 'customer.create', resourceType: 'organization', resourceId: orgId, correlationId: req.correlationId, ip: clientIp(req), after: { customerNumber: res.number, customerType: b.type, name: b.name, owner: b.owner.email } });
return { id: orgId, customerNumber: res.number, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(res.inv.token) };
});
app.get('/admin/customers/:id', async (req) => {
requirePermission(req, 'customers.read');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
return loadOrg(id);
});
app.patch('/admin/customers/:id', async (req) => {
const a = requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
// Felder: nicht angegeben = unverändert, leer/null = löschen
const f = (n: number) => z.string().trim().max(n).nullable().optional();
const b = z.object({
name: z.string().trim().min(1).max(200).optional(), customerType: z.enum(['private', 'business']).optional(), status: z.enum(['active', 'suspended', 'closed']).optional(),
billing: z.object({ company: f(200), contactName: f(150), street: f(200), zip: f(20), city: f(100), country: z.string().length(2).toUpperCase().optional(), vatId: f(30), billingEmail: f(254), phone: f(50) }).optional(),
}).parse(req.body);
const before = await loadOrg(id);
const cols: Record<string, string> = { company: 'company', contactName: 'contact_name', street: 'street', zip: 'zip', city: 'city', country: 'country', vatId: 'vat_id', billingEmail: 'billing_email', phone: 'phone' };
const patch: Record<string, string | null> = {};
for (const [k, v] of Object.entries(b.billing ?? {})) if (v !== undefined) patch[k] = k === 'vatId' ? (normVat(v as string | null) || null) : ((v as string | null) || null);
// Ergebnis nach der Änderung gegen die Regeln der (neuen) Kundenart prüfen
const type = b.customerType ?? before.customerType;
const name = b.name ?? before.name;
const after = { company: 'company' in patch ? patch.company : before.billing.company, vatId: 'vatId' in patch ? patch.vatId : before.billing.vatId };
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
await tx(async (c) => {
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
const keys = Object.keys(patch);
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.update', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before, after: b });
return loadOrg(id);
});
// ---- Übernahme bestehender Kunden aus einem Anbieter (z. B. KeyHelp) --------------
const capsOf = (inst: any): string[] => (inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : []);
const legacyRef = (inst: any, ref: string) => `${inst.connector_key}:${inst.id}:${ref}`;
async function liveCustomers(instId: string, corr: string): Promise<{ inst: any; list: ImportableCustomer[] }> {
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [instId]); if (!inst) throw notFound();
if (!capsOf(inst).includes('customers.list')) throw badRequest('Diese Verbindung unterstützt keine Kundenübernahme', 'NO_CUSTOMERS');
try { const { connector, ctx } = await loadInstance(instId, corr); return { inst, list: await connector.listCustomers!(ctx) }; }
catch (e) { if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`); throw e; }
}
/** Kunden des Anbieters mit Übernahmestatus, Produkt-Vorschlägen und möglichen vorhandenen Kunden (gleiche E-Mail/Firma). */
app.get('/admin/connectors/:id/customers', async (req) => {
requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const { inst, list } = await liveCustomers(id, req.correlationId);
const orgs = await query('SELECT o.id, o.customer_number, o.name, o.legacy_ref, b.billing_email FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id');
const byLegacy = new Map(orgs.filter((o) => o.legacy_ref).map((o) => [o.legacy_ref as string, o]));
const res = await query('SELECT external_ref, org_id FROM resources WHERE instance_id = ?', [id]); const resOrg = new Map(res.map((r) => [r.external_ref as string, r.org_id as string | null]));
const prods = await query('SELECT p.id, p.sku, p.external_ref, p.status, v.name FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.connector_instance_id = ?', [id]);
return list.map((c) => {
const done = byLegacy.get(legacyRef(inst, c.externalRef));
const matches = orgs.filter((o) => !o.legacy_ref && ((c.email && o.billing_email && String(o.billing_email).toLowerCase() === c.email.toLowerCase()) || (c.company && String(o.name).toLowerCase() === c.company.toLowerCase())));
return { ...c, imported: done ? { orgId: done.id, customerNumber: done.customer_number } : null, resourceKnown: resOrg.has(c.externalRef), resourceOrgId: resOrg.get(c.externalRef) ?? null,
suggestedProducts: prods.filter((p) => c.planRef && p.external_ref === c.planRef).map((p) => ({ id: p.id, sku: p.sku, name: p.name, status: p.status })),
possibleOrgs: matches.map((o) => ({ id: o.id, customerNumber: o.customer_number, name: o.name })) };
});
});
const importItem = z.object({
externalRef: z.string().min(1).max(100), type: z.enum(['private', 'business']), name: z.string().trim().min(1).max(200).optional(), ownerName: z.string().trim().min(1).max(150).optional(), ownerEmail: email.optional(),
linkToOrgId: z.string().uuid().optional(), contract: z.object({ productId: z.string().uuid(), startedAt: z.string().max(40).optional() }).optional(),
});
/**
* Übernimmt ausgewählte Kunden: legt Kunde (Organisation, Rechnungsanschrift, Inhaber) an oder verknüpft mit einem vorhandenen Kunden,
* ordnet das Hosting-Konto zu und legt optional einen laufenden Bestandsvertrag an. Es werden KEINE E-Mails versendet (Einladung später gezielt).
*/
app.post('/admin/connectors/:id/customers/import', async (req) => {
const a = requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ items: z.array(importItem).min(1).max(200) }).parse(req.body);
const { inst, list } = await liveCustomers(id, req.correlationId);
const byRef = new Map(list.map((c) => [c.externalRef, c]));
// Fehlende lokale Ressourcen-Spiegel vorab abgleichen (einmalig)
const known = async () => new Map((await query('SELECT id, external_ref, org_id, state, valid_from FROM resources WHERE instance_id = ?', [id])).map((r) => [r.external_ref as string, r]));
let resMap = await known();
if (b.items.some((i) => !resMap.has(i.externalRef))) { await syncInstance(id, req.correlationId).catch(() => undefined); resMap = await known(); }
const results: { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string }[] = [];
const seq = async (c: Parameters<typeof one>[2], name: 'order' | 'contract', prefix: string) => { await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c as never); return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`; };
for (const it of b.items) {
const c = byRef.get(it.externalRef); const res = resMap.get(it.externalRef); const skip = (reason: string) => results.push({ externalRef: it.externalRef, status: 'skipped', reason });
if (!c) { skip('Beim Anbieter nicht gefunden'); continue; }
if (!res) { skip('Das Konto ist noch nicht abgeglichen (Verbindung prüfen)'); continue; }
if (await one('SELECT 1 AS x FROM organizations WHERE legacy_ref = ?', [legacyRef(inst, c.externalRef)])) { skip('Bereits übernommen'); continue; }
if (res.org_id && !it.linkToOrgId) { skip('Das Konto ist bereits einem Kunden zugeordnet'); continue; }
const name = it.name ?? (it.type === 'business' ? (c.company ?? c.displayName) : ([c.firstName, c.lastName].filter(Boolean).join(' ') || c.displayName));
try {
let product: any = null;
if (it.contract) {
product = await one('SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [it.contract.productId]);
if (!product || product.connector_instance_id !== id) throw badRequest('Das Produkt gehört nicht zu dieser Verbindung', 'BAD_PRODUCT');
if (await one('SELECT 1 AS x FROM contracts WHERE resource_id = ?', [res.id])) throw badRequest('Für dieses Konto gibt es bereits einen Vertrag', 'CONTRACT_EXISTS');
}
const ownerEmail = (it.ownerEmail ?? c.email ?? '').toLowerCase();
let orgId = it.linkToOrgId ?? ''; let customerNumber = ''; let orgType: 'private' | 'business' = it.type; let contractNumber: string | undefined;
await tx(async (cn) => {
if (it.linkToOrgId) {
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
} else {
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }
orgId = randomUUID(); customerNumber = await nextCustomerNumber(cn);
await run('INSERT INTO organizations (id, customer_number, name, customer_type, legacy_ref) VALUES (?,?,?,?,?)', [orgId, customerNumber, name, it.type, legacyRef(inst, c.externalRef)], cn);
const country = c.address.country && /^[A-Za-z]{2}$/.test(c.address.country) ? c.address.country.toUpperCase() : 'DE';
await run('INSERT INTO billing_profiles (org_id, company, contact_name, street, zip, city, country, billing_email, phone) VALUES (?,?,?,?,?,?,?,?,?)',
[orgId, it.type === 'business' ? (c.company ?? name) : null, it.type === 'private' ? name : ([c.firstName, c.lastName].filter(Boolean).join(' ') || null), c.address.street, c.address.zip, c.address.city, country, ownerEmail, c.phone], cn);
const u = await createInvitedUser(cn, { email: ownerEmail, name: it.ownerName ?? ([c.firstName, c.lastName].filter(Boolean).join(' ') || name), kind: 'customer' }); // Einladung wird NICHT versendet
await run("INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,'owner')", [orgId, u.userId], cn);
}
if (it.linkToOrgId) await run('UPDATE organizations SET legacy_ref = COALESCE(legacy_ref, ?) WHERE id = ?', [legacyRef(inst, c.externalRef), orgId], cn);
await run('UPDATE resources SET org_id = ?, customer_actions = COALESCE(?, customer_actions) WHERE id = ?', [orgId, product ? product.customer_actions : null, res.id], cn);
if (product) {
const now = new Date(); const started = it.contract!.startedAt && !Number.isNaN(Date.parse(it.contract!.startedAt)) ? new Date(it.contract!.startedAt) : (c.createdAt ? new Date(c.createdAt) : now);
let terms = { termMonths: product.term_months as number, renewal: product.renewal as 'auto' | 'none', renewalTermMonths: product.renewal_term_months as number, noticeDays: product.notice_days as number };
if (orgType === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) };
const price = calculatePrice({ basis: product.price_basis, setupCents: product.setup_cents, recurringCents: product.recurring_cents, taxBp: product.tax_bp, interval: product.billing_interval, quantity: 1, discountBp: 0 });
const snapshot = { productId: product.id, sku: product.sku, productVersionId: product.vid, version: product.version, name: product.vname, category: product.category, customerType: orgType, ...price, terms, imported: true };
// Laufzeitende: erste Periode ab Beginn, bei automatischer Verlängerung bis in die Zukunft fortgeschrieben
const period = terms.termMonths > 0 ? terms.termMonths : terms.renewal === 'auto' ? terms.renewalTermMonths : 0; let termEnd: Date | null = null;
if (period > 0) { termEnd = addMonths(started, period); const step = terms.renewal === 'auto' && terms.renewalTermMonths > 0 ? terms.renewalTermMonths : 0; let g = 0; while (termEnd <= now && step > 0 && g++ < 1200) termEnd = addMonths(termEnd, step); if (termEnd <= now) termEnd = null; }
const orderId = randomUUID(); const itemId = randomUUID(); const oNum = await seq(cn, 'order', 'B'); contractNumber = await seq(cn, 'contract', 'V');
await run("INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,'completed',?,'staff',0,?,UTC_TIMESTAMP(3),?)", [orderId, oNum, orgId, a.user.id, a.user.id, `Übernahme aus ${inst.name} (Bestand)`], cn);
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,1,0,?)', [itemId, orderId, product.vid, JSON.stringify(snapshot)], cn);
await run('INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, started_at, term_end, renewal, renewal_term_months, notice_days, resource_id, price_snapshot_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
[randomUUID(), contractNumber, orgId, itemId, product.vid, res.state === 'suspended' ? 'suspended' : 'active', started, termEnd, terms.renewal, terms.renewalTermMonths, terms.noticeDays, res.id, JSON.stringify(snapshot)], cn);
}
});
await audit({ actorType: 'user', actorId: a.user.id, orgId, action: 'customer.import', resourceType: 'organization', resourceId: orgId, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { source: legacyRef(inst, c.externalRef), customerNumber, linked: !!it.linkToOrgId, contractNumber } });
results.push({ externalRef: it.externalRef, status: it.linkToOrgId ? 'linked' : 'created', orgId, customerNumber, contractNumber });
} catch (e) { skip(e instanceof AppError ? e.message : 'Fehler bei der Übernahme'); }
}
return { results };
});
// ---- Kundenseite: eigene Organisation ---------------------------------
app.get('/orgs/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!canInOrg(a.principal, id, 'org.read', 'customers.read')) throw notFound(); // keine Existenz verraten
return loadOrg(id);
});
app.get('/orgs/:id/members', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!canInOrg(a.principal, id, 'org.members.read', 'customers.read')) throw notFound();
return (await loadOrg(id)).members;
});
app.post('/orgs/:id/invitations', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!canInOrg(a.principal, id, 'org.read', 'customers.read')) throw notFound();
if (!canInOrg(a.principal, id, 'org.members.invite', 'customers.write')) throw forbidden();
const b = z.object({ email, name: z.string().trim().min(1).max(150), role: z.enum(['admin', 'member']) }).parse(req.body);
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('Diese E-Mail ist bereits registriert', 'EMAIL_EXISTS');
const inv = await tx(async (c) => {
const i = await createInvitedUser(c, { email: b.email, name: b.name, kind: 'customer' });
await run('INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,?)', [id, i.userId, b.role], c);
return i;
});
const mail = await mailInvite(b.email, b.name, inv.token);
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'org.member.invite', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, role: b.role } });
return { id: inv.userId, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(inv.token) };
});
},
};
async function loadOrg(id: string) {
const o = await one('SELECT o.*, b.company, b.contact_name, b.street, b.zip, b.city, b.country, b.vat_id, b.billing_email, b.phone FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [id]);
if (!o) throw notFound('Kunde nicht gefunden');
const members = await query('SELECT u.id, u.email, u.name, u.status, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.created_at', [id]);
return {
id: o.id as string, customerNumber: o.customer_number as string, name: o.name as string, customerType: o.customer_type as 'private' | 'business', status: o.status as string, createdAt: o.created_at as Date,
billing: { company: o.company, contactName: o.contact_name, street: o.street, zip: o.zip, city: o.city, country: o.country, vatId: o.vat_id, billingEmail: o.billing_email, phone: o.phone },
members: members.map((m) => ({ id: m.id as string, email: m.email as string, name: m.name as string, status: m.status as string, role: m.role as string })),
};
}

View file

@ -0,0 +1,132 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { requireAuth, requirePermission, clientIp } from '../../core/auth.js';
import { badRequest, notFound } from '../../core/errors.js';
import { rl } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
import { randomUUID } from 'node:crypto';
import { loadInstance } from '@kc/connectors';
import { breakdown, checkAvailability, normalizeDomain, parsePriceList, sellPrice, splitDomain, type CostBasis, type Margin } from './logic.js';
const taxBp = async () => Number((await one("SELECT rate_bp FROM tax_rates WHERE name LIKE 'Regelsteuersatz%' LIMIT 1"))?.rate_bp ?? 1900);
/** Preis-Schnappschuss für eine Domain: Einkauf brutto/netto und errechneter Verkauf brutto/netto. Null-Preise, wenn Endung fehlt oder kein Aufschlag festgelegt ist. */
async function snapshot(tld: string) {
const s = await settings(); const r = await one('SELECT * FROM domain_tlds WHERE tld = ?', [tld]); const tax = await taxBp();
if (!r) return { termMonths: null, costNet: null, costGross: null, setup: 0, net: null, gross: null, tax };
const o = offer(r, s.tier, s.margin, s.rounding, s.basis, tax);
return { termMonths: o.termMonths, costNet: o.costNetCents, costGross: o.costGrossCents, setup: o.setupGrossCents, net: o.priceNetCents, gross: o.priceGrossCents, tax };
}
const recView = (r: any) => ({ id: r.id, domain: r.domain, tld: r.tld, orgId: r.org_id, customer: r.org_name ?? null, customerNumber: r.customer_number ?? null, resourceId: r.resource_id, source: r.source, termMonths: r.term_months,
costNetCents: r.cost_net_cents, costGrossCents: r.cost_gross_cents, setupCostCents: r.setup_cost_cents, sellNetCents: r.sell_net_cents, taxBp: r.tax_bp, sellGrossCents: r.sell_gross_cents, profitNetCents: r.sell_net_cents === null || r.cost_net_cents === null ? null : r.sell_net_cents - r.cost_net_cents,
procurement: r.procurement, orderedAt: r.ordered_at, orderedRef: r.ordered_ref, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at });
const REC_SELECT = 'SELECT d.*, o.name AS org_name, o.customer_number FROM domain_records d LEFT JOIN organizations o ON o.id = d.org_id';
const SUGGEST = ['de', 'com', 'net', 'org', 'eu', 'info'];
const marginIn = z.object({ type: z.enum(['percent', 'fixed']).nullable(), value: z.number().int().min(0).max(100_000_00).nullable() }).refine((m) => (m.type === null) === (m.value === null), 'Art und Wert gehören zusammen');
async function settings() { const s = await one('SELECT tier, margin_type, margin_value, rounding, cost_basis FROM domain_settings WHERE id = 1'); return { tier: Number(s?.tier ?? 1), rounding: s ? !!s.rounding : true, basis: (s?.cost_basis ?? 'gross') as CostBasis, margin: { type: s?.margin_type ?? null, value: s?.margin_value ?? null } as Margin }; }
const offer = (r: any, tier: number, g: Margin, round: boolean, basis: CostBasis, tax: number) => {
const list = Number(r[`cost${tier}_cents`]); const own: Margin = { type: r.margin_type, value: r.margin_value }; const b = breakdown(list, own, g, round, basis, tax);
const setupGross = basis === 'gross' ? r.setup_cents : r.setup_cents + Math.round((r.setup_cents * tax) / 10000);
return { tld: r.tld, termMonths: r.term_months, ...b, setupGrossCents: setupGross as number, setupNetCents: Math.round((setupGross * 10000) / (10000 + tax)), taxBp: tax, margin: own, active: !!r.active };
};
export const domainsModule: KcModule = {
name: 'domains',
permissions: { staff: { support: ['domains.read'], accounting: ['domains.read'], admin: ['domains.read', 'domains.write'], superadmin: ['domains.read', 'domains.write'] } },
register(app: FastifyInstance) {
// Domain prüfen (für alle angemeldeten Benutzer). Ohne Endung werden gängige Endungen geprüft. Einkaufspreise erscheinen hier nie.
app.get('/domains/check', { config: rl(30, '1 minute') }, async (req) => {
requireAuth(req);
const { name } = z.object({ name: z.string().min(1).max(300) }).parse(req.query);
const s = await settings(); const tax = await taxBp(); const rows = await query('SELECT * FROM domain_tlds WHERE active = 1'); const by = new Map(rows.map((r) => [r.tld as string, r]));
const norm = normalizeDomain(name.includes('.') ? name : `${name}.de`); if (!norm) throw badRequest('Das ist kein gültiger Domainname. Erlaubt sind Buchstaben, Ziffern und Bindestriche.');
const names = name.includes('.') ? [norm] : SUGGEST.filter((t) => by.has(t)).map((t) => `${norm.split('.')[0]}.${t}`);
const results = await Promise.all(names.map(async (n) => {
const sp = splitDomain(n, new Set(by.keys()))!; const o = by.get(sp.tld); const price = o ? offer(o, s.tier, s.margin, s.rounding, s.basis, tax) : null; const a = await checkAvailability(n);
return { domain: n, tld: sp.tld, ...a, offer: price && price.priceGrossCents !== null ? { termMonths: price.termMonths, priceGrossCents: price.priceGrossCents, priceNetCents: price.priceNetCents!, taxBp: tax, setupGrossCents: price.setupGrossCents } : null, offered: !!o };
}));
return { results };
});
app.get('/admin/domain-tlds', async (req) => {
requirePermission(req, 'domains.read'); const s = await settings(); const tax = await taxBp();
return { settings: { tier: s.tier, margin: s.margin, rounding: s.rounding, basis: s.basis, taxBp: tax }, tlds: (await query('SELECT * FROM domain_tlds ORDER BY tld')).map((r) => offer(r, s.tier, s.margin, s.rounding, s.basis, tax)) };
});
// ---- Domain-Aufstellung (Einkauf beim Registrar KCS vs. errechneter Verkauf) ----
app.get('/admin/domain-records', async (req) => {
requirePermission(req, 'domains.read'); const q = z.object({ status: z.enum(['open', 'ordered', 'external']).optional(), q: z.string().max(100).optional(), orgId: z.string().uuid().optional() }).parse(req.query);
const rows = await query(`${REC_SELECT} WHERE (? IS NULL OR d.org_id = ?) AND (? IS NULL OR d.procurement = ?) AND (? IS NULL OR d.domain LIKE ?) ORDER BY d.created_at DESC LIMIT 2000`, [q.orgId ?? null, q.orgId ?? null, q.status ?? null, q.status ?? null, q.q ?? null, `%${q.q ?? ''}%`]);
return rows.map(recView);
});
app.post('/admin/domain-records', async (req) => {
const a = requirePermission(req, 'domains.write'); const b = z.object({ domain: z.string().max(300), orgId: z.string().uuid().nullable().optional(), procurement: z.enum(['open', 'ordered', 'external']).default('open'), note: z.string().max(300).optional() }).parse(req.body);
const name = normalizeDomain(b.domain); if (!name || !name.includes('.')) throw badRequest('Das ist kein gültiger Domainname.');
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const sp = splitDomain(name, known)!;
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) throw badRequest('Diese Domain ist bereits erfasst.');
const p = await snapshot(sp.tld); const id = randomUUID();
await run('INSERT INTO domain_records (id, domain, tld, org_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, procurement, note, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [id, name, sp.tld, b.orgId ?? null, 'manual', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, b.procurement, b.note ?? null]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.create', resourceType: 'domain_record', resourceId: id, after: { domain: name }, ip: clientIp(req) });
return { id };
});
// Domains eines Hosting-Kontos aus dem Panel übernehmen (Subdomains und System-Domain werden ausgelassen)
app.post('/admin/domain-records/from-resource/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT r.id, r.org_id, r.external_ref, r.instance_id, i.capabilities_json FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]); if (!r) throw notFound();
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); if (!connector.children) throw badRequest('Diese Verbindung liefert keine Domains.');
const list = await connector.children.list(ctx, r.external_ref, 'domain' as never) as { name: string; details?: { subdomain?: boolean; system?: boolean } }[];
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((x) => x.tld as string)); let added = 0, skipped = 0, unpriced = 0;
for (const d of list) {
const name = normalizeDomain(d.name); if (!name || d.details?.subdomain || d.details?.system) { skipped++; continue; }
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) { skipped++; continue; }
const sp = splitDomain(name, known); if (!sp) { skipped++; continue; } const p = await snapshot(sp.tld); if (p.net === null) unpriced++;
await run('INSERT INTO domain_records (id, domain, tld, org_id, resource_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [randomUUID(), name, sp.tld, r.org_id, id, 'keyhelp', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross]); added++;
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.import', resourceType: 'resource', resourceId: id, after: { added, skipped }, ip: clientIp(req) });
return { added, skipped, unpriced };
});
app.patch('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedRef: z.string().max(100).nullable().optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body);
const rec = await one('SELECT * FROM domain_records WHERE id = ?', [id]); if (!rec) throw notFound();
if (b.procurement) await run('UPDATE domain_records SET procurement = ?, ordered_at = ? WHERE id = ?', [b.procurement, b.procurement === 'ordered' ? (rec.ordered_at ?? new Date()) : null, id]);
if (b.orderedRef !== undefined) await run('UPDATE domain_records SET ordered_ref = ? WHERE id = ?', [b.orderedRef, id]);
if (b.note !== undefined) await run('UPDATE domain_records SET note = ? WHERE id = ?', [b.note, id]);
if (b.orgId !== undefined) await run('UPDATE domain_records SET org_id = ? WHERE id = ?', [b.orgId, id]);
if (b.reprice) { const p = await snapshot(rec.tld); await run('UPDATE domain_records SET term_months=?, cost_net_cents=?, cost_gross_cents=?, setup_cost_cents=?, sell_net_cents=?, tax_bp=?, sell_gross_cents=?, priced_at=UTC_TIMESTAMP(3) WHERE id = ?', [p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, id]); }
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.update', resourceType: 'domain_record', resourceId: id, after: b, ip: clientIp(req) });
return { ok: true };
});
app.delete('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
await run('DELETE FROM domain_records WHERE id = ?', [id]); await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.delete', resourceType: 'domain_record', resourceId: id, ip: clientIp(req) }); return { ok: true };
});
app.put('/admin/domain-settings', async (req) => {
const a = requirePermission(req, 'domains.write');
const b = z.object({ tier: z.number().int().min(1).max(4), margin: marginIn, rounding: z.boolean(), basis: z.enum(['gross', 'net']) }).parse(req.body);
await run('UPDATE domain_settings SET tier = ?, margin_type = ?, margin_value = ?, rounding = ?, cost_basis = ? WHERE id = 1', [b.tier, b.margin.type, b.margin.value, b.rounding ? 1 : 0, b.basis]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.settings', resourceType: 'domain_settings', resourceId: '1', after: b, ip: clientIp(req) });
return { ok: true };
});
app.patch('/admin/domain-tlds/:tld', async (req) => {
const a = requirePermission(req, 'domains.write'); const { tld } = z.object({ tld: z.string().max(63) }).parse(req.params);
const b = z.object({ margin: marginIn.optional(), active: z.boolean().optional() }).parse(req.body);
if (!(await one('SELECT 1 AS x FROM domain_tlds WHERE tld = ?', [tld]))) throw notFound();
if (b.margin) await run('UPDATE domain_tlds SET margin_type = ?, margin_value = ? WHERE tld = ?', [b.margin.type, b.margin.value, tld]);
if (b.active !== undefined) await run('UPDATE domain_tlds SET active = ? WHERE tld = ?', [b.active ? 1 : 0, tld]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.tld.update', resourceType: 'domain_tld', resourceId: tld, after: b, ip: clientIp(req) });
return { ok: true };
});
// Preisliste einlesen (Einkaufspreise); Aufschläge und Aktiv-Status bestehender Endungen bleiben erhalten.
app.post('/admin/domain-tlds/import', async (req) => {
const a = requirePermission(req, 'domains.write'); const { text, dryRun } = z.object({ text: z.string().min(1).max(500_000), dryRun: z.boolean().default(false) }).parse(req.body);
const { rows, skipped } = parsePriceList(text); if (rows.length === 0) throw badRequest('Es wurden keine Preiszeilen erkannt. Erwartet: Endung, Laufzeit, vier Staffelpreise, optional Setup.');
const existing = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const created = rows.filter((r) => !existing.has(r.tld)).length;
if (!dryRun) {
await tx(async (c) => { for (const r of rows) await run('INSERT INTO domain_tlds (tld, term_months, cost1_cents, cost2_cents, cost3_cents, cost4_cents, setup_cents) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE term_months=VALUES(term_months), cost1_cents=VALUES(cost1_cents), cost2_cents=VALUES(cost2_cents), cost3_cents=VALUES(cost3_cents), cost4_cents=VALUES(cost4_cents), setup_cents=VALUES(setup_cents)', [r.tld, r.termMonths, ...r.costs, r.setupCents], c); });
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.import', resourceType: 'domain_tld', resourceId: 'bulk', after: { rows: rows.length, created }, ip: clientIp(req) });
}
return { rows: rows.length, created, updated: rows.length - created, skipped, dryRun };
});
},
};

View file

@ -0,0 +1,93 @@
import { domainToASCII } from 'node:url';
import { promises as dns } from 'node:dns';
export type MarginType = 'percent' | 'fixed';
export interface Margin { type: MarginType | null; value: number | null }
/** Kaufmännische Rundung nach oben: Cent-Anteil 00–50 → ,50; 51–99 → ,99 (z. B. 12,00 → 12,50; 12,51 → 12,99). */
export function roundPrice(cents: number): number { const euro = Math.floor(cents / 100), c = cents % 100; return euro * 100 + (c <= 50 ? 50 : 99); }
/** Verkaufspreis (netto, Cent) = Einkauf + Gewinnaufschlag; null, wenn kein Aufschlag festgelegt ist. Prozent in Basispunkten. */
export function sellPrice(costCents: number, own: Margin, global: Margin, round = false): number | null {
const m = own.type && own.value !== null ? own : global; if (!m.type || m.value === null) return null;
const p = costCents + (m.type === 'percent' ? Math.round((costCents * m.value) / 10000) : m.value); return round ? roundPrice(p) : p;
}
export type CostBasis = 'gross' | 'net';
export interface Breakdown { costGrossCents: number; costNetCents: number; priceGrossCents: number | null; priceNetCents: number | null; profitNetCents: number | null }
/** Preise aus dem Listenpreis: Ist die Liste brutto (inkl. USt), wird Netto herausgerechnet, nie erneut USt aufgeschlagen. Rundung und Aufschlag wirken auf die Listenbasis. */
export function breakdown(listCost: number, own: Margin, global: Margin, round: boolean, basis: CostBasis, taxBp: number): Breakdown {
const toNet = (g: number) => Math.round((g * 10000) / (10000 + taxBp)); const vat = (n: number) => Math.round((n * taxBp) / 10000);
const p = sellPrice(listCost, own, global, round);
const costGross = basis === 'gross' ? listCost : listCost + vat(listCost); const costNet = basis === 'gross' ? toNet(listCost) : listCost;
if (p === null) return { costGrossCents: costGross, costNetCents: costNet, priceGrossCents: null, priceNetCents: null, profitNetCents: null };
const g = basis === 'gross' ? p : p + vat(p); const n = basis === 'gross' ? toNet(p) : p;
return { costGrossCents: costGross, costNetCents: costNet, priceGrossCents: g, priceNetCents: n, profitNetCents: n - costNet };
}
export interface ParsedRow { tld: string; termMonths: number; costs: [number, number, number, number]; setupCents: number }
const num = (s: string): number | null => {
const t = s.replace(/[€\s]/g, ''); if (!t) return null;
const n = t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t; // deutsches Format 1.380,00 oder 26.60
return /^\d+(\.\d+)?$/.test(n) ? Math.round(Number(n) * 100) : null;
};
/** Liest eine Preisliste (Tabulator/Leerzeichen getrennt): Endung, Laufzeit, 4 Staffelpreise, optional Setup. Kopf-/Fußzeilen werden übersprungen. */
export function parsePriceList(text: string): { rows: ParsedRow[]; skipped: string[] } {
const rows: ParsedRow[] = []; const skipped: string[] = []; const seen = new Set<string>();
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim(); if (!line || line.startsWith('#')) continue;
const c = line.split(/[\t ]+/).filter((x) => x !== '€');
const tld = (c[0] ?? '').toLowerCase().replace(/^\./, '');
if (!/^[a-z0-9]([a-z0-9.-]{0,60}[a-z0-9])?$/.test(tld) || !/^\d{1,3}$/.test(c[1] ?? '')) { if (!/^Domaintyp/i.test(line)) skipped.push(line.slice(0, 80)); continue; }
const p = [2, 3, 4, 5].map((i) => num(c[i] ?? '')); const setup = c[6] !== undefined ? num(c[6]) : 0;
if (p.some((x) => x === null) || setup === null) { skipped.push(line.slice(0, 80)); continue; }
if (seen.has(tld)) { skipped.push(`doppelt: ${tld}`); continue; } seen.add(tld);
rows.push({ tld, termMonths: Number(c[1]), costs: p as [number, number, number, number], setupCents: setup });
}
return { rows, skipped };
}
/** Normalisiert Eingaben wie „https://www.Beispiel.de/x“ zu „beispiel.de“ (Punycode). */
export function normalizeDomain(input: string): string | null {
let s = input.trim().toLowerCase().replace(/^[a-z]+:\/\//, '').replace(/[/?#].*$/, '').replace(/^www\./, '').replace(/\.$/, '');
if (!s || s.length > 253) return null; s = domainToASCII(s); if (!s) return null;
return s.split('.').every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l)) ? s : null;
}
/** Trennt Name und Endung; bekannte mehrteilige Endungen (co.uk) werden bevorzugt. */
export function splitDomain(name: string, known: Set<string>): { label: string; tld: string } | null {
const parts = name.split('.'); if (parts.length < 2) return null;
for (let i = 1; i < parts.length; i++) { const tld = parts.slice(i).join('.'); if (known.has(tld) && parts.slice(0, i).length === 1) return { label: parts[0]!, tld }; }
for (let i = 1; i < parts.length; i++) { const tld = parts.slice(i).join('.'); if (known.has(tld)) return { label: parts.slice(0, i).join('.'), tld }; }
return { label: parts.slice(0, -1).join('.'), tld: parts[parts.length - 1]! };
}
export type Availability = { status: 'available' | 'registered' | 'unknown'; method: 'rdap' | 'dns'; note?: string };
/** Ergänzung für Endungen, die nicht in der IANA-Liste stehen, aber eine RDAP-Auskunft anbieten. */
const EXTRA_RDAP: Record<string, string> = { de: 'https://rdap.denic.de/' };
let boot: { at: number; map: Map<string, string> } | null = null;
async function rdapBase(tld: string): Promise<string | null> {
if (!boot || Date.now() - boot.at > 24 * 3600_000) {
const r = await fetch('https://data.iana.org/rdap/dns.json', { signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error(`IANA ${r.status}`);
const j = (await r.json()) as { services: [string[], string[]][] }; const map = new Map<string, string>();
for (const [tlds, urls] of j.services) { const u = urls.find((x) => x.startsWith('https://')); if (u) for (const t of tlds) map.set(t.toLowerCase(), u.endsWith('/') ? u : `${u}/`); }
boot = { at: Date.now(), map };
}
const last = tld.split('.').pop()!; return boot.map.get(last) ?? EXTRA_RDAP[last] ?? null;
}
async function viaDns(name: string): Promise<Availability> {
try { await dns.resolveNs(name); return { status: 'registered', method: 'dns' }; } catch (e) {
const c = (e as { code?: string }).code;
if (c === 'ENOTFOUND') return { status: 'available', method: 'dns', note: 'Ermittelt über DNS, ohne Gewähr. Vor der Bestellung wird beim Registrar erneut geprüft.' };
if (c === 'ENODATA') { try { await dns.resolveSoa(name); return { status: 'registered', method: 'dns' }; } catch { return { status: 'unknown', method: 'dns' }; } }
return { status: 'unknown', method: 'dns' };
}
}
/** Prüft, ob eine Domain vergeben ist: bevorzugt per RDAP (Registry-Auskunft), sonst per DNS. */
export async function checkAvailability(name: string): Promise<Availability> {
try {
const base = await rdapBase(name.split('.').slice(1).join('.'));
if (base) {
const r = await fetch(`${base}domain/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(7000), headers: { accept: 'application/rdap+json, application/json' }, redirect: 'follow' });
if (r.status === 200) return { status: 'registered', method: 'rdap' };
if (r.status === 404) return { status: 'available', method: 'rdap' };
}
} catch { /* Fallback auf DNS */ }
return viaDns(name);
}

View file

@ -0,0 +1,277 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { hash, verify } from '@node-rs/argon2';
import * as OTPAuth from 'otpauth';
import { randomBytes } from 'node:crypto';
import { rl } from '../../core/config.js';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.js';
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
import { can, staffPermissions } from '../../core/policy.js';
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
import { sendMail } from '../../core/mail.js';
import type { KcModule } from '../../core/module.js';
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
const MAX_FAILS = 5;
const LOCK_MINUTES = 15;
const password = z.string().min(12, 'Mindestens 12 Zeichen').max(200);
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
// Scheinhash gegen Timing-Unterschiede bei unbekannten Benutzern
const DUMMY = await hash('dummy-password-for-timing', ARGON);
function totp(secret: string, label: string): OTPAuth.TOTP {
return new OTPAuth.TOTP({ issuer: 'Kundencenter', label, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secret) });
}
/** Prüft TOTP mit ±1 Schritt und verhindert Wiederverwendung desselben Schritts. */
async function checkTotp(userId: string, code: string): Promise<boolean> {
const row = await one('SELECT secret_enc, last_step FROM mfa_totp WHERE user_id = ?', [userId]);
if (!row) return false;
const t = totp(decrypt(row.secret_enc), 'x');
const delta = t.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) return false;
const step = Math.floor(Date.now() / 30000) + delta;
if (row.last_step !== null && Number(row.last_step) >= step) return false;
await run('UPDATE mfa_totp SET last_step = ? WHERE user_id = ?', [step, userId]);
return true;
}
async function useRecoveryCode(userId: string, code: string): Promise<boolean> {
const r = await run('UPDATE recovery_codes SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND code_hash = ? AND used_at IS NULL', [userId, sha256(code.trim().toLowerCase())]);
return r.affectedRows === 1;
}
const publicUser = (u: { id: string; email: string; name: string; kind: string; staff_role: string | null; status: string }) => ({ id: u.id, email: u.email, name: u.name, kind: u.kind, staffRole: u.staff_role, status: u.status });
export const identityModule: KcModule = {
name: 'identity',
register(app: FastifyInstance) {
// ---- Login -------------------------------------------------------------
app.post('/auth/login', { config: rl(10, '1 minute') }, async (req, reply) => {
const body = z.object({ email, password: z.string().max(200) }).parse(req.body);
const u = await one('SELECT * FROM users WHERE email = ?', [body.email]);
const locked = u?.locked_until && new Date(u.locked_until as Date) > new Date();
const ok = await verify(u?.password_hash ?? DUMMY, body.password).catch(() => false);
if (!u || !u.password_hash || !ok || u.status !== 'active' || locked) {
if (u && !locked && u.status === 'active') {
const fails = Number(u.failed_logins) + 1;
await run('UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?', [fails, fails >= MAX_FAILS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null, u.id]);
}
await audit({ actorType: 'anonymous', action: 'auth.login', result: 'denied', errorClass: locked ? 'locked' : 'invalid_credentials', correlationId: req.correlationId, ip: clientIp(req), after: { email: body.email } });
throw unauthorized('E-Mail oder Passwort falsch', 'INVALID_CREDENTIALS');
}
await run('UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?', [u.id]);
if (req.auth) await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ?', [req.auth.sessionId]); // keine Session-Fixation
const hasMfa = !!(await one('SELECT 1 AS x FROM mfa_totp WHERE user_id = ? AND confirmed_at IS NOT NULL', [u.id]));
const s = await createSession(reply, u.id, { pendingMfa: hasMfa, ip: clientIp(req), ua: req.headers['user-agent'] ?? '' });
await audit({ actorType: 'user', actorId: u.id, action: hasMfa ? 'auth.login.password_ok' : 'auth.login', correlationId: req.correlationId, ip: clientIp(req) });
return { status: hasMfa ? 'mfa_required' : 'ok', csrf: s.csrf };
});
app.post('/auth/mfa/verify', { config: rl(10, '1 minute') }, async (req) => {
const a = requireAuth(req, { allowPendingMfa: true });
if (!a.pendingMfa) return { status: 'ok' };
const { code } = z.object({ code: z.string().min(6).max(20) }).parse(req.body);
const good = /^\d{6}$/.test(code.replace(/\s/g, '')) ? await checkTotp(a.user.id, code) : await useRecoveryCode(a.user.id, code);
if (!good) {
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa', result: 'denied', correlationId: req.correlationId, ip: clientIp(req) });
throw unauthorized('Code ungültig', 'INVALID_MFA');
}
await run('UPDATE sessions SET pending_mfa = 0, mfa_verified = 1 WHERE id = ?', [a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.login', correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.post('/auth/logout', async (req, reply) => {
if (req.auth) {
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ?', [req.auth.sessionId]);
await audit({ actorType: 'user', actorId: req.auth.user.id, action: 'auth.logout', correlationId: req.correlationId, ip: clientIp(req) });
}
reply.clearCookie(COOKIE, { path: '/' });
return { status: 'ok' };
});
app.get('/auth/me', async (req) => {
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
const orgs = a.principal.memberships.length
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
: [];
return {
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
};
});
// ---- MFA-Einrichtung ---------------------------------------------------
app.post('/auth/mfa/setup', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
if (a.mfaEnrolled) throw conflict('2FA ist bereits aktiv', 'MFA_ALREADY_ENABLED');
const secret = new OTPAuth.Secret({ size: 20 });
await run('REPLACE INTO mfa_totp (user_id, secret_enc) VALUES (?,?)', [a.user.id, encrypt(secret.base32)]);
return { secret: secret.base32, otpauthUrl: totp(secret.base32, a.user.email).toString() };
});
app.post('/auth/mfa/confirm', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
const { code } = z.object({ code: z.string().regex(/^\d{6}$/) }).parse(req.body);
const row = await one('SELECT confirmed_at FROM mfa_totp WHERE user_id = ?', [a.user.id]);
if (!row || row.confirmed_at) throw badRequest('Keine offene 2FA-Einrichtung', 'NO_PENDING_MFA');
if (!(await checkTotp(a.user.id, code))) throw badRequest('Code ungültig', 'INVALID_MFA');
const codes = Array.from({ length: 10 }, () => randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2'));
await tx(async (c) => {
await run('UPDATE mfa_totp SET confirmed_at = UTC_TIMESTAMP(3) WHERE user_id = ?', [a.user.id], c);
await run('DELETE FROM recovery_codes WHERE user_id = ?', [a.user.id], c);
for (const code2 of codes) await run('INSERT INTO recovery_codes (user_id, code_hash) VALUES (?,?)', [a.user.id, sha256(code2)], c);
});
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.enable', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { recoveryCodes: codes }; // einmalige Anzeige
});
/** 2FA entfernen (z. B. neues Handy): Passwort + aktueller Code oder Wiederherstellungscode nötig. Danach kann neu eingerichtet werden. */
app.post('/auth/mfa/disable', { config: rl(5, '10 minutes') }, async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
if (!a.mfaEnrolled) throw badRequest('2FA ist nicht aktiv', 'MFA_NOT_ENABLED');
const b = z.object({ password: z.string().max(200), code: z.string().min(6).max(20) }).parse(req.body);
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
const pwOk = !!u?.password_hash && (await verify(u.password_hash, b.password).catch(() => false));
const codeOk = pwOk && (/^\d{6}$/.test(b.code.replace(/\s/g, '')) ? await checkTotp(a.user.id, b.code) : await useRecoveryCode(a.user.id, b.code));
if (!pwOk || !codeOk) {
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.disable', result: 'denied', correlationId: req.correlationId, ip: clientIp(req) });
throw forbidden('Passwort oder Code falsch', 'INVALID_CREDENTIALS');
}
await tx(async (c) => { await run('DELETE FROM recovery_codes WHERE user_id = ?', [a.user.id], c); await run('DELETE FROM mfa_totp WHERE user_id = ?', [a.user.id], c); });
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND id <> ? AND revoked_at IS NULL', [a.user.id, a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.disable', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
// ---- Passwort ----------------------------------------------------------
app.post('/auth/password/change', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
const b = z.object({ current: z.string(), next: password, repeat: z.string() }).parse(req.body);
if (b.next !== b.repeat) throw badRequest('Die neuen Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
if (!u?.password_hash || !(await verify(u.password_hash, b.current))) throw forbidden('Aktuelles Passwort falsch', 'INVALID_CREDENTIALS');
await run('UPDATE users SET password_hash = ? WHERE id = ?', [await hash(b.next, ARGON), a.user.id]);
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND id <> ? AND revoked_at IS NULL', [a.user.id, a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.password.change', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.post('/auth/password/forgot', { config: rl(5, '10 minutes') }, async (req) => {
const { email: e } = z.object({ email }).parse(req.body);
const u = await one('SELECT id, name FROM users WHERE email = ? AND status = \'active\'', [e]);
if (u) {
const t = await createToken(undefined, u.id, 'password_reset');
await sendMail(e, 'password_reset', 'Passwort zurücksetzen', `Hallo ${u.name},\n\nüber diesen Link können Sie Ihr Passwort zurücksetzen (1 Stunde gültig):\n${resetLink(t)}\n\nWenn Sie das nicht angefordert haben, ignorieren Sie diese E-Mail.\n`);
await audit({ actorType: 'anonymous', action: 'auth.password.forgot', resourceType: 'user', resourceId: u.id, correlationId: req.correlationId, ip: clientIp(req) });
}
return { status: 'accepted' }; // immer gleiche Antwort
});
async function consumeToken(token: string, purpose: 'invite' | 'password_reset') {
const r = await one('SELECT id, user_id FROM user_tokens WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3)', [sha256(token), purpose]);
if (!r) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
const upd = await run('UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE id = ? AND used_at IS NULL', [r.id]);
if (upd.affectedRows !== 1) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
return r.user_id as string;
}
app.post('/auth/password/reset', { config: rl(10, '10 minutes') }, async (req) => {
const b = z.object({ token: z.string().min(20).max(100), password, repeat: z.string() }).parse(req.body);
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
const uid = await consumeToken(b.token, 'password_reset');
await run('UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL WHERE id = ?', [await hash(b.password, ARGON), uid]);
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [uid]);
await audit({ actorType: 'user', actorId: uid, action: 'auth.password.reset', resourceType: 'user', resourceId: uid, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.post('/auth/invite/accept', { config: rl(10, '10 minutes') }, async (req) => {
const b = z.object({ token: z.string().min(20).max(100), password, repeat: z.string() }).parse(req.body);
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
const uid = await consumeToken(b.token, 'invite');
await run('UPDATE users SET password_hash = ?, status = \'active\', email_verified_at = UTC_TIMESTAMP(3) WHERE id = ? AND status = \'invited\'', [await hash(b.password, ARGON), uid]);
await audit({ actorType: 'user', actorId: uid, action: 'auth.invite.accept', resourceType: 'user', resourceId: uid, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
// ---- Sitzungen ---------------------------------------------------------
app.get('/auth/sessions', async (req) => {
const a = requireAuth(req);
const rows = await query('SELECT id, ip, user_agent, created_at, last_seen_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3) ORDER BY last_seen_at DESC', [a.user.id]);
return rows.map((r) => ({ id: r.id, ip: r.ip, userAgent: r.user_agent, createdAt: r.created_at, lastSeenAt: r.last_seen_at, current: r.id === a.sessionId }));
});
app.delete('/auth/sessions/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ? AND user_id = ? AND revoked_at IS NULL', [id, a.user.id]);
if (!r.affectedRows) throw notFound();
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.session.revoke', resourceType: 'session', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
// ---- Benutzerverwaltung (Staff) ---------------------------------------
app.get('/admin/users', async (req) => {
requirePermission(req, 'users.read');
const q = z.object({ q: z.string().max(100).optional(), kind: z.enum(['customer', 'staff']).optional() }).parse(req.query);
const rows = await query(
`SELECT id, email, name, kind, staff_role, status, created_at,
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = users.id AND m.confirmed_at IS NOT NULL) AS mfa
FROM users WHERE (? IS NULL OR email LIKE CONCAT('%', ?, '%') OR name LIKE CONCAT('%', ?, '%')) AND (? IS NULL OR kind = ?)
ORDER BY created_at DESC LIMIT 200`,
[q.q ?? null, q.q ?? null, q.q ?? null, q.kind ?? null, q.kind ?? null],
);
return rows.map((r) => ({ ...publicUser(r as never), mfa: Number(r.mfa) > 0, createdAt: r.created_at }));
});
app.post('/admin/users', async (req) => {
const a = requirePermission(req, 'users.write');
const b = z.object({ email, name: z.string().min(1).max(150), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']) }).parse(req.body);
if ((b.staffRole === 'admin' || b.staffRole === 'superadmin') && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen Administratoren anlegen', 'PRIVILEGED_ONLY');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('E-Mail bereits vergeben', 'EMAIL_EXISTS');
const inv = await tx((c) => createInvitedUser(c, { email: b.email, name: b.name, kind: 'staff', staffRole: b.staffRole }));
const mail = await mailInvite(b.email, b.name, inv.token);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.create', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, kind: 'staff', staffRole: b.staffRole } });
return { id: inv.userId, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(inv.token) };
});
/** Support-/Admin-Reset der 2FA (Kunde hat Handy und Wiederherstellungscodes verloren). Beendet alle Sitzungen; bei Mitarbeitern muss neu eingerichtet werden. */
app.post('/admin/users/:id/mfa-reset', async (req) => {
const a = requirePermission(req, 'users.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const u = await one('SELECT id, kind, staff_role FROM users WHERE id = ?', [id]);
if (!u) throw notFound();
if (id === a.user.id) throw forbidden('Eigene 2FA bitte unter „Mein Konto“ zurücksetzen', 'SELF_CHANGE');
if (u.kind === 'staff' && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen die 2FA von Mitarbeitern zurücksetzen', 'PRIVILEGED_ONLY');
await tx(async (c) => { await run('DELETE FROM recovery_codes WHERE user_id = ?', [id], c); await run('DELETE FROM mfa_totp WHERE user_id = ?', [id], c); });
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.mfa.reset', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
/** Einladung (erneut) senden: für eingeladene Benutzer, z. B. nach einer Kundenübernahme (dort wird bewusst nichts versendet). */
app.post('/admin/users/:id/reinvite', async (req) => {
const a = requirePermission(req, 'users.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const u = await one('SELECT id, email, name, kind, status, staff_role FROM users WHERE id = ?', [id]);
if (!u) throw notFound();
if (u.status !== 'invited') throw badRequest('Nur eingeladene Benutzer haben eine offene Einladung', 'NOT_INVITED');
if (u.kind === 'staff' && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren', 'PRIVILEGED_ONLY');
await run("UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND purpose = 'invite' AND used_at IS NULL", [id]);
const token = await createToken(undefined, id, 'invite');
const mail = await mailInvite(u.email, u.name, token);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.reinvite', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { mail } });
return { mail, inviteLink: mail === 'sent' ? undefined : inviteLink(token) };
});
app.patch('/admin/users/:id', async (req) => {
const a = requirePermission(req, 'users.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ status: z.enum(['active', 'disabled']).optional(), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']).optional() }).parse(req.body);
const u = await one('SELECT * FROM users WHERE id = ?', [id]);
if (!u) throw notFound();
if (id === a.user.id) throw forbidden('Eigenes Konto kann hier nicht geändert werden', 'SELF_CHANGE');
const privileged = (r: unknown) => r === 'admin' || r === 'superadmin';
if ((privileged(u.staff_role) || privileged(b.staffRole)) && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren', 'PRIVILEGED_ONLY');
if (b.staffRole && u.kind !== 'staff') throw badRequest('Nur für Mitarbeiter');
if (b.status === 'active' && u.status === 'invited') throw badRequest('Eingeladene Benutzer aktivieren sich selbst');
await run('UPDATE users SET status = COALESCE(?, status), staff_role = COALESCE(?, staff_role) WHERE id = ?', [b.status ?? null, b.staffRole ?? null, id]);
if (b.status === 'disabled') await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.update', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: u.status, staffRole: u.staff_role }, after: b });
return { status: 'ok' };
});
},
};

View file

@ -0,0 +1,14 @@
import type { KcModule } from '../core/module.js';
import { systemModule } from './system/index.js';
import { identityModule } from './identity/index.js';
import { customersModule } from './customers/index.js';
import { auditModule } from './audit/index.js';
import { connectorsModule } from './connectors/index.js';
import { resourcesModule } from './resources/index.js';
import { domainsModule } from './domains/index.js';
import { catalogModule } from './catalog/index.js';
import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/index.js';
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, backupModule];

View file

@ -0,0 +1,213 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing';
import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine';
import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const j = (v: unknown, d: unknown = null) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
async function nextNumber(c: PoolConnection, name: 'order' | 'contract', prefix: string): Promise<string> {
await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c);
return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
}
const orderView = (o: any, items: any[] = []) => ({
id: o.id, number: o.number, orgId: o.org_id, orgName: o.org_name, status: o.status, placedVia: o.placed_via, approvalRequired: !!o.approval_required, note: o.note,
failureNote: o.failure_note, failureAmbiguous: !!o.failure_ambiguous, createdAt: o.created_at, approvedAt: o.approved_at,
items: items.map((i) => ({ id: i.id, quantity: i.quantity, discountBp: i.discount_bp, snapshot: j(i.snapshot_json), contractId: i.contract_id ?? null, contractNumber: i.contract_number ?? null })),
});
const contractView = (c: any) => ({
id: c.id, number: c.number, orgId: c.org_id, orgName: c.org_name, status: c.status, productName: c.product_name, startedAt: c.started_at, termEnd: c.term_end, renewal: c.renewal, renewalTermMonths: c.renewal_term_months,
noticeDays: c.notice_days, cancelRequestedAt: c.cancel_requested_at, cancelEffectiveAt: c.cancel_effective_at, cancelledAt: c.cancelled_at, resourceId: c.resource_id, price: j(c.price_snapshot_json), createdAt: c.created_at,
});
const ORDER_SQL = 'SELECT o.*, g.name AS org_name FROM orders o JOIN organizations g ON g.id = o.org_id';
async function loadOrder(id: string) {
const o = await one(`${ORDER_SQL} WHERE o.id = ?`, [id]);
if (!o) return null;
const items = await query('SELECT oi.*, c.id AS contract_id, c.number AS contract_number FROM order_items oi LEFT JOIN contracts c ON c.order_item_id = oi.id WHERE oi.order_id = ?', [id]);
return { o, items };
}
const CONTRACT_SQL = `SELECT c.*, g.name AS org_name, JSON_VALUE(c.price_snapshot_json, '$.name') AS product_name FROM contracts c JOIN organizations g ON g.id = c.org_id`;
/** Wendet ein Ereignis des Bestell-Statusautomaten an (atomar über den erwarteten Ausgangszustand). */
async function orderEvent(c: PoolConnection | undefined, id: string, from: string, ev: OrderEvent, extra: { sql?: string; params?: unknown[] } = {}): Promise<string> {
const to = transition(ORDER_MACHINE, from as never, ev);
const r = await run(`UPDATE orders SET status = ?${extra.sql ? ', ' + extra.sql : ''} WHERE id = ? AND status = ?`, [to, ...(extra.params ?? []), id, from], c);
if (!r.affectedRows) throw conflict('Die Bestellung wurde inzwischen geändert. Bitte neu laden.', 'STALE_STATE');
return to;
}
const startProvisioning = (orderId: string, key: string, correlationId: string) => enqueue('order.provision', { orderId }, { idempotencyKey: key, correlationId });
const orderAccess = (a: AuthContext, orgId: string) => canInOrg(a.principal, orgId, 'orders.read', 'orders.read');
export const ordersModule: KcModule = {
name: 'orders',
permissions: {
staff: {
support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'],
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'],
},
org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] },
},
register(app: FastifyInstance) {
// ---- Bestellung anlegen ------------------------------------------------
app.post('/orders', async (req) => {
const a = requireAuth(req);
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
const staff = can(a.principal, 'orders.write');
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
if (!org) throw notFound();
if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann nichts bestellt werden', 'ORG_INACTIVE');
const lines: { p: any; it: { productId: string; quantity: number; discountBp: number }; snapshot: any }[] = [];
for (const it of b.items) {
const p = await one(`SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days
FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?`, [it.productId]);
if (!p || p.status !== 'active') throw badRequest('Produkt nicht verfügbar', 'PRODUCT_UNAVAILABLE');
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
}
const approvalRequired = !staff && lines.some((l) => !!l.p.requires_approval); // Personal bestellt = freigegeben
const orderId = randomUUID();
const number = await tx(async (c) => {
const n = await nextNumber(c, 'order', 'B');
await run('INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,?,?,?,?,?,?,?)',
[orderId, n, b.orgId, approvalRequired ? 'pending_approval' : 'approved', a.user.id, staff ? 'staff' : 'customer', approvalRequired ? 1 : 0, approvalRequired ? null : a.user.id, approvalRequired ? null : new Date(), b.note ?? null], c);
for (const l of lines) {
const itemId = randomUUID();
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,?,?,?)', [itemId, orderId, l.snapshot.productVersionId, l.it.quantity, l.it.discountBp, JSON.stringify(l.snapshot)], c);
await run("INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, renewal, renewal_term_months, notice_days, price_snapshot_json) VALUES (?,?,?,?,?,'pending',?,?,?,?)",
[randomUUID(), await nextNumber(c, 'contract', 'V'), b.orgId, itemId, l.snapshot.productVersionId, l.snapshot.terms.renewal, l.snapshot.terms.renewalTermMonths, l.snapshot.terms.noticeDays, JSON.stringify(l.snapshot)], c);
}
if (!approvalRequired) { await orderEvent(c, orderId, 'approved', 'start_provisioning'); await startProvisioning(orderId, `provision:${orderId}:1`, req.correlationId); }
return n;
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'order.create', resourceType: 'order', resourceId: orderId, correlationId: req.correlationId, ip: clientIp(req), after: { number, approvalRequired, items: lines.map((l) => ({ sku: l.snapshot.sku, quantity: l.it.quantity, discountBp: l.it.discountBp })) } });
return { id: orderId, number, status: approvalRequired ? 'pending_approval' : 'provisioning' };
});
app.get('/orders', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
const staff = can(a.principal, 'orders.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && !orgs.length) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`o.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.status) { where.push('o.status = ?'); params.push(q.status); }
const rows = await query(`${ORDER_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY o.created_at DESC LIMIT 300`, params);
return rows.map((o) => orderView(o));
});
app.get('/orders/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadOrder(id);
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
return orderView(r.o, r.items);
});
// ---- Freigabe, Ablehnung, Storno, Wiederholung -------------------------
app.post('/admin/orders/:id/approve', async (req) => {
const a = requirePermission(req, 'orders.approve');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadOrder(id); if (!r) throw notFound();
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'approve', { sql: 'approved_by = ?, approved_at = ?', params: [a.user.id, new Date()] }); await orderEvent(c, id, 'approved', 'start_provisioning'); await startProvisioning(id, `provision:${id}:1`, req.correlationId); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.approve', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'provisioning' };
});
app.post('/admin/orders/:id/reject', async (req) => {
const a = requirePermission(req, 'orders.approve');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ reason: z.string().trim().max(500).optional() }).parse(req.body ?? {});
const r = await loadOrder(id); if (!r) throw notFound();
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'reject', { sql: 'failure_note = ?', params: [b.reason ?? null] }); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.reject', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason } });
return { status: 'rejected' };
});
app.post('/orders/:id/cancel', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadOrder(id);
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
// Kunden dürfen nur ihre noch nicht freigegebene Bestellung zurückziehen
const allowed = can(a.principal, 'orders.write') || (r.o.status === 'pending_approval' && canInOrg(a.principal, r.o.org_id, 'orders.create', 'orders.write'));
if (!allowed) throw forbidden();
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'cancel'); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.cancel', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'cancelled' };
});
/** Fehlgeschlagene Bereitstellung erneut starten. Bei unklarem Ausgang beim Anbieter ist eine ausdrückliche Bestätigung nötig (Doppelanlage!). */
app.post('/admin/orders/:id/retry', async (req) => {
const a = requirePermission(req, 'orders.approve');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ confirmChecked: z.boolean().default(false) }).parse(req.body ?? {});
const r = await loadOrder(id); if (!r) throw notFound();
if (r.o.failure_ambiguous && !b.confirmChecked) throw badRequest('Bitte bestätigen, dass beim Anbieter geprüft wurde, dass nichts angelegt wurde', 'CONFIRM_REQUIRED');
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'retry', { sql: 'failure_note = NULL, failure_ambiguous = 0' }); await startProvisioning(id, `provision:${id}:retry:${Date.now()}`, req.correlationId); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.retry', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { ambiguousConfirmed: b.confirmChecked } });
return { status: 'provisioning' };
});
// ---- Verträge ------------------------------------------------------------
app.get('/contracts', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
const staff = can(a.principal, 'contracts.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && !orgs.length) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`c.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.status) { where.push('c.status = ?'); params.push(q.status); }
return (await query(`${CONTRACT_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY c.created_at DESC LIMIT 300`, params)).map(contractView);
});
app.get('/contracts/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
});
/** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */
app.post('/contracts/:id/cancel', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ immediate: z.boolean().default(false) }).parse(req.body ?? {});
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
if (b.immediate && !can(a.principal, 'contracts.write')) throw forbidden('Sofortige Beendigung nur durch das Personal', 'STAFF_ONLY');
if (!['active', 'suspended'].includes(c.status)) throw badRequest('Dieser Vertrag kann nicht gekündigt werden', 'NOT_CANCELLABLE');
if (c.cancel_requested_at) throw conflict('Die Kündigung wurde bereits eingereicht', 'ALREADY_CANCELLED');
transition(CONTRACT_MACHINE, c.status, 'cancel'); // Prüfung, dass die Kündigung im Zustand erlaubt ist
const now = new Date();
const effective = b.immediate ? now : effectiveCancelDate(now, { termEnd: c.term_end ? new Date(c.term_end) : null, renewal: c.renewal, renewalTermMonths: Number(c.renewal_term_months), noticeDays: Number(c.notice_days) });
await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ? AND cancel_requested_at IS NULL', [now, effective, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { effectiveAt: effective.toISOString(), immediate: b.immediate } });
return { cancelEffectiveAt: effective.toISOString() };
});
app.post('/contracts/:id/cancel/revoke', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
if (!c.cancel_requested_at || new Date(c.cancel_effective_at) <= new Date()) throw badRequest('Keine widerrufbare Kündigung vorhanden', 'NOT_REVOCABLE');
await run("UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ? AND status IN ('active','suspended')", [id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
},
};

View file

@ -0,0 +1,258 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID, createHash } from 'node:crypto';
import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors';
import { ConnectorError, type ChildKind } from '@kc/connector-sdk';
import { encrypt } from '../../core/crypto.js';
import { CHILD_MANAGE, CUSTOMER_ACTIONS } from '../../core/actions.js';
import { rl } from '../../core/config.js';
import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk';
import { one, query, run } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const STALE_FACTOR = 3;
const json = <T>(v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T));
/** Ressource + Instanzzustand; "stale" = Provider gestört oder Daten älter als 3 Abgleichintervalle. */
async function loadResource(id: string) {
return one('SELECT r.*, i.connector_key, i.name AS instance_name, i.health, i.health_message, i.sync_interval_sec, i.capabilities_json, i.enabled FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]);
}
function view(r: any, staff: boolean, a?: AuthContext) {
const stale = r.health !== 'ok' || Date.now() - new Date(r.synced_at).getTime() > STALE_FACTOR * r.sync_interval_sec * 1000;
return {
id: r.id, type: r.type, name: r.name, state: r.state, orgId: r.org_id, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since,
canReveal: a ? canReveal(r, a) : undefined,
stale, staleReason: r.health !== 'ok' ? (r.health_message ?? 'Der Dienst ist derzeit nicht erreichbar.') : stale ? 'Die Daten sind älter als erwartet.' : null,
...(staff ? { instance: r.instance_name, connector: r.connector_key, externalRef: r.external_ref } : {}),
};
}
/** Erlaubte Aktionen = Connector-Fähigkeit ∧ Rolle ∧ Ressourcenregel (Kunden nur freigegebene). */
function allowedActions(r: any, a: AuthContext): ActionName[] {
const caps = json<string[]>(r.capabilities_json, []);
const supported = ACTIONS.filter((x) => caps.includes(ACTION_CAPABILITY[x]) && !DESTRUCTIVE_ACTIONS.has(x));
if (r.health !== 'ok' || !r.enabled) return [];
if (can(a.principal, 'resources.write')) return supported;
const orgOk = r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write');
return orgOk ? supported.filter((x) => json<string[]>(r.customer_actions, []).includes(x)) : [];
}
const childCapsTop = (r: any): string[] => json<string[]>(r.capabilities_json, []);
const canLoginTop = (r: any, a: AuthContext): boolean => childCapsTop(r).includes('sso.login') && r.health === 'ok' && !!r.enabled && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes('panel.login')));
/** Zugangsdaten/Schlüssel anzeigen: Personal mit Schreibrecht oder Inhaber/Admin der zugehörigen Organisation; Anbieter muss es unterstützen und erreichbar sein. */
function canReveal(r: any, a: AuthContext): boolean {
const caps = json<string[]>(r.capabilities_json, []);
if (!caps.includes('secret.reveal') || !r.enabled) return false;
return can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'));
}
function access(a: AuthContext, r: any): boolean {
return can(a.principal, 'resources.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.read', 'resources.read'));
}
export const resourcesModule: KcModule = {
name: 'resources',
permissions: {
staff: { support: ['resources.read'], accounting: ['resources.read'], admin: ['resources.read', 'resources.write'], superadmin: ['resources.read', 'resources.write'] },
org: { owner: ['resources.read', 'resources.manage'], admin: ['resources.read', 'resources.manage'], member: ['resources.read'] },
},
register(app: FastifyInstance) {
app.get('/resources', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), unassigned: z.enum(['1']).optional(), type: z.string().max(30).optional() }).parse(req.query);
const staff = can(a.principal, 'resources.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && orgs.length === 0) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`r.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.unassigned && staff) where.push('r.org_id IS NULL');
if (q.type) { where.push('r.type = ?'); params.push(q.type); }
const rows = await query(`SELECT r.*, i.connector_key, i.name AS instance_name, i.health, i.health_message, i.sync_interval_sec, i.capabilities_json, i.enabled FROM resources r JOIN connector_instances i ON i.id = r.instance_id ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY r.name LIMIT 500`, params);
return rows.map((r) => view(r, staff, a));
});
app.get('/resources/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
const staff = can(a.principal, 'resources.read');
const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]);
const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {});
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
});
app.post('/resources/:id/actions', async (req, reply) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend']), days: z.number().int().min(1).max(3650).optional() }).parse(req.body);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!allowedActions(r, a).includes(b.action)) throw forbidden('Diese Aktion ist für diese Ressource nicht verfügbar', 'ACTION_NOT_ALLOWED');
const params: Record<string, unknown> = {};
if (b.action === 'extend') {
if (!b.days) throw badRequest('Anzahl Tage fehlt');
const from = r.valid_until && new Date(r.valid_until) > new Date() ? new Date(r.valid_until) : new Date();
params.until = new Date(from.getTime() + b.days * 86400000).toISOString(); // absoluter Zielwert => bei Wiederholung wirkungsgleich
params.days = b.days;
}
// Idempotenz: Header vom Client (pro Bestätigungsdialog), sonst Hash aus Ressource/Aktion/Parameter im Minutenfenster
const hdr = req.headers['idempotency-key'];
const key = `act:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${b.action}|${JSON.stringify(params)}|${Math.floor(Date.now() / 60000)}`).digest('hex').slice(0, 40)}`;
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
const jobId = existing?.id ?? randomUUID();
if (!existing) {
try {
await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', [jobId, 'connector.execute', JSON.stringify({ resourceId: id, action: b.action, params, actorUserId: a.user.id }), key, req.correlationId]);
} catch (e) {
if ((e as { code?: string }).code !== 'ER_DUP_ENTRY') throw e; // parallele Doppelanfrage: bestehenden Auftrag zurückgeben
const dup = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
return reply.code(202).send({ jobId: dup!.id, duplicate: true });
}
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `resource.${b.action}.request`, resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId, params } });
}
return reply.code(202).send({ jobId, duplicate: !!existing });
});
/** Schlüssel/Zugangsdaten auf Abruf: live beim Anbieter gelesen, nie gespeichert oder protokolliert (nur DASS abgerufen wurde). */
app.post('/resources/:id/reveal', { config: rl(10, '1 minute') }, async (req, reply) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!canReveal(r, a)) throw forbidden('Der Zugriff auf Zugangsdaten ist für diese Ressource nicht möglich', 'REVEAL_FORBIDDEN');
let items: { label: string; value: string }[];
try {
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId);
if (!connector.reveal) throw badRequest('Nicht unterstützt', 'NOT_SUPPORTED');
items = await connector.reveal(ctx, r.external_ref);
} catch (e) {
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Beim Anbieter konnte nichts gelesen werden: ${e.userMessage}`);
throw e;
}
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.reveal', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { labels: items.map((i) => i.label) } });
reply.header('cache-control', 'no-store');
return { items, hideAfterSec: 60 };
});
// ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ----------
const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const;
const kindParam = z.object({ id: z.string().uuid(), kind: z.enum(KINDS) });
const childCaps = (r: any): string[] => json<string[]>(r.capabilities_json, []);
/** Schreiben erlaubt: Personal mit Schreibrecht ODER Inhaber/Admin der Organisation, wenn das Produkt es für diese Art freigibt. */
const canManageKind = (r: any, a: AuthContext, kind: string): boolean => {
if (kind === 'certificate') return false;
if (r.health !== 'ok' || !r.enabled || !childCaps(r).includes('children.write')) return false;
if (can(a.principal, 'resources.write')) return true;
const need = CHILD_MANAGE[kind];
return !!need && !!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes(need);
};
const providerError = (e: unknown): never => { if (e instanceof ConnectorError) throw new AppError(e.code === 'NOT_FOUND' ? 404 : 502, 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; };
app.get('/resources/:id/children', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!childCaps(r).includes('children.read')) return { kinds: [], panelLogin: false };
try {
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId);
const kinds = await connector.children!.kinds(ctx, r.external_ref);
return { kinds: kinds.map((k) => ({ kind: k.kind, canWrite: k.canWrite && canManageKind(r, a, k.kind) })), panelLogin: canLogin(r, a) };
} catch (e) { return providerError(e); }
});
app.get('/resources/:id/children/:kind', { config: rl(60, '1 minute') }, async (req) => {
const a = requireAuth(req);
const { id, kind } = kindParam.parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r) || !childCaps(r).includes('children.read')) throw notFound();
try { const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); return await connector.children!.list(ctx, r.external_ref, kind as ChildKind); }
catch (e) { return providerError(e); }
});
/** Änderung an einem Unterobjekt: läuft als persistenter Auftrag; Passwörter nur verschlüsselt im Auftrag, nach der Ausführung entfernt. */
app.post('/resources/:id/children/:kind', { config: rl(30, '1 minute') }, async (req, reply) => {
const a = requireAuth(req);
const { id, kind } = kindParam.parse(req.params);
const b = z.object({ op: z.enum(['create', 'update', 'delete']), id: z.string().max(40).optional(), data: z.record(z.string(), z.unknown()).default({}), password: z.string().max(128).optional() }).parse(req.body);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!canManageKind(r, a, kind)) throw forbidden('Diese Änderung ist für diese Ressource nicht möglich', 'ACTION_NOT_ALLOWED');
if ((b.op === 'update' || b.op === 'delete') && !b.id) throw badRequest('Objekt fehlt', 'ID_REQUIRED');
const needsPw = b.op === 'create' && ['email', 'database', 'ftp'].includes(kind);
if (needsPw && !b.password) throw badRequest('Bitte ein Passwort angeben.', 'PASSWORD_REQUIRED');
if (b.password && (b.password.length < 12 || /[\0\r\n]/.test(b.password))) throw badRequest('Das Passwort muss mindestens 12 Zeichen lang sein.', 'WEAK_PASSWORD');
if (JSON.stringify(b.data).length > 4000) throw badRequest('Eingabe zu groß', 'TOO_LARGE');
const hdr = req.headers['idempotency-key'];
const key = `child:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${kind}|${JSON.stringify(b.data)}|${b.op}|${b.id ?? ''}|${Math.floor(Date.now() / 60000)}`).digest('hex').slice(0, 40)}`;
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
if (existing) return reply.code(202).send({ jobId: existing.id, duplicate: true });
const jobId = randomUUID();
const payload = { resourceId: id, kind, op: b.op, id: b.id, data: b.data, actorUserId: a.user.id, destructive: b.op === 'delete', ...(b.password ? { secretEnc: encrypt(JSON.stringify({ password: b.password })) } : {}) };
try { await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', [jobId, 'connector.child', JSON.stringify(payload), key, req.correlationId]); }
catch (e) { if ((e as { code?: string }).code !== 'ER_DUP_ENTRY') throw e; const d = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]); return reply.code(202).send({ jobId: d!.id, duplicate: true }); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `resource.child.${kind}.${b.op}.request`, resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId, id: b.id, data: b.data } });
return reply.code(202).send({ jobId, duplicate: false });
});
const canLogin = (r: any, a: AuthContext): boolean => {
if (!childCaps(r).includes('sso.login') || r.health !== 'ok' || !r.enabled) return false;
if (can(a.principal, 'resources.write')) return true;
return !!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes('panel.login');
};
/** Panel-Login: kurzlebiger Link, nie gespeichert, Abruf im Audit. */
app.post('/resources/:id/login', { config: rl(10, '1 minute') }, async (req, reply) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!canLogin(r, a)) throw forbidden('Der Panel-Login ist für diese Ressource nicht möglich', 'LOGIN_FORBIDDEN');
let out: { url: string; validForSec: number };
try { const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); out = await connector.loginUrl!(ctx, r.external_ref); } catch (e) { return providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.login', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req) });
reply.header('cache-control', 'no-store'); return out;
});
app.get('/jobs/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const j = await one("SELECT id, type, status, attempts, max_attempts, last_error, created_at, updated_at, JSON_VALUE(payload, '$.resourceId') AS resource_id FROM jobs WHERE id = ?", [id]);
if (!j || !j.resource_id) throw notFound();
const r = await loadResource(j.resource_id);
if (!r || !access(a, r)) throw notFound();
return { id: j.id, status: j.status, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.last_error, createdAt: j.created_at, updatedAt: j.updated_at };
});
app.get('/admin/jobs', async (req) => {
requirePermission(req, 'jobs.read');
const q = z.object({ status: z.string().max(30).optional() }).parse(req.query);
return (await query('SELECT id, type, status, attempts, max_attempts, last_error, run_at, created_at, updated_at, correlation_id FROM jobs WHERE (? IS NULL OR status = ?) ORDER BY created_at DESC LIMIT 200', [q.status ?? null, q.status ?? null]))
.map((j) => ({ id: j.id, type: j.type, status: j.status, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.last_error, runAt: j.run_at, createdAt: j.created_at, correlationId: j.correlation_id }));
});
/** Kontrollierte manuelle Wiederholung: nur für Jobs in needs_review/failed, nie für destruktive Aktionen. */
app.post('/admin/jobs/:id/retry', async (req) => {
const a = requirePermission(req, 'resources.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const j = await one("SELECT id, status, (JSON_VALUE(payload, '$.destructive') IN ('1','true') OR JSON_VALUE(payload, '$.action') = 'terminate') AS destructive FROM jobs WHERE id = ?", [id]);
if (!j) throw notFound();
if (!['needs_review', 'failed'].includes(j.status)) throw badRequest('Nur fehlgeschlagene Aufträge können wiederholt werden', 'NOT_RETRYABLE');
if (Number(j.destructive) === 1) throw forbidden('Destruktive Aufträge werden nicht automatisch wiederholt; bitte Ergebnis beim Provider prüfen', 'DESTRUCTIVE');
await run("UPDATE jobs SET status='retrying', attempts=0, run_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id = ?", [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'job.retry', resourceType: 'job', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.patch('/admin/resources/:id', async (req) => {
const a = requirePermission(req, 'resources.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ orgId: z.string().uuid().nullable().optional(), customerActions: z.array(z.enum(CUSTOMER_ACTIONS)).optional() }).parse(req.body);
const r = await loadResource(id);
if (!r) throw notFound();
if (b.orgId && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw badRequest('Kunde nicht gefunden');
await run('UPDATE resources SET org_id = ?, customer_actions = ? WHERE id = ?', [b.orgId === undefined ? r.org_id : b.orgId, JSON.stringify(b.customerActions ?? json(r.customer_actions, [])), id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id, customerActions: json(r.customer_actions, []) }, after: b });
return { status: 'ok' };
});
},
};

View file

@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import { one } from '../../core/db.js';
import { requirePermission } from '../../core/auth.js';
import { query } from '../../core/db.js';
import { readFile } from 'node:fs/promises';
import type { KcModule } from '../../core/module.js';
/** Backup-Zustand aus der Statusdatei des Backup-Laufs (siehe docs/betrieb-backup-restore.md). Ohne Datei: nicht eingerichtet. */
async function backupState() {
const file = process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
let st: any; try { st = JSON.parse(await readFile(file, 'utf8')); } catch { return { configured: false as const }; }
const hours = (iso?: string) => (iso ? (Date.now() - new Date(iso).getTime()) / 3600000 : null);
const run = st.lastRun; const test = st.lastRestoreTest;
return {
configured: true as const, lastRunAt: run?.at ?? null, ok: !!run?.ok, ageHours: hours(run?.at), stale: !run || !run.ok || (hours(run.at) ?? 999) > 26, error: run?.error ?? null,
file: run?.file ?? null, sizeBytes: run?.sizeBytes ?? null, targets: (run?.targets ?? []).map((t: any) => ({ name: t.name, ok: t.ok })),
lastRestoreTestAt: test?.at ?? null, restoreOk: test ? !!test.ok : null, restoreStale: !test || !test.ok || (hours(test.at) ?? 999) > 24 * 10, restoreError: test?.error ?? null,
};
}
export const systemModule: KcModule = {
name: 'system',
register(app: FastifyInstance) {
app.get('/health', async () => ({ status: 'ok' })); // Liveness
app.get('/ready', async (_req, reply) => { // Readiness: DB erreichbar
try { await one('SELECT 1 AS ok'); return { status: 'ready' }; } catch { return reply.code(503).send({ status: 'db_unavailable' }); }
});
app.get('/admin/system', async (req) => {
requirePermission(req, 'jobs.read');
const jobs = await query('SELECT status, COUNT(*) AS n FROM jobs GROUP BY status');
const oldest = await one('SELECT MIN(run_at) AS t FROM jobs WHERE status IN (\'scheduled\',\'retrying\')');
return { jobs: Object.fromEntries(jobs.map((j) => [j.status, Number(j.n)])), oldestPendingJob: oldest?.t ?? null, serverTime: new Date().toISOString(), backup: await backupState() };
});
},
};