commit 4763548bfbb37e9f85376d0fb19e0da7bc594717 Author: Kundencenter Date: Sun Sep 27 00:51:32 2026 +0200 Stand vor Einführung des Nacht-Agenten diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e64b8b --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Beispielwerte OHNE Geheimnisse. Echte Werte liegen in /etc/kundencenter/*.env (chmod 600). +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=kundencenter +DB_USER=kundencenter +DB_PASSWORD= +KC_SECRET_KEY= # 32 Byte base64 (openssl rand -base64 32); Verschluesselung von TOTP-Secrets +KC_BASE_URL=http://localhost:4101 +KC_API_PORT=4100 +KC_NODE_ENV=development +# optional: SMTP (sonst wird nur im Mail-Protokoll abgelegt) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +# optional: Discord (sonst bleibt der Bot deaktiviert) +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +DISCORD_ADMIN_CHANNEL_ID= +DISCORD_STAFF_USER_IDS= # kommagetrennt, dürfen /kc-Befehle nutzen diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..762d9ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.next +.env +.env.* +!.env.example +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..d12fc99 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Kundencenter + +Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, TypeScript). + +| Prozess | Pfad | Port (nur 127.0.0.1) | Aufgabe | +|---|---|---|---| +| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Policy, Audit | +| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot | +| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, Proxy `/api/*` → API | + +## Stand (Grundsystem) +Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen), +Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional). +Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`). +Noch **nicht** vorhanden: Rechnungen/Zahlungen, Tickets, Selbstregistrierung, KeyHelp/Plesk-Connector. + +## Modularität +API-Module liegen in `apps/api/src/modules/` und implementieren `KcModule` (`core/module.ts`). +Sie werden in `modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander. +Neue Fähigkeiten (Connectoren, Rechnungen, Tickets) kommen als weitere Module hinzu. + +## Konfiguration +Geheimnisse liegen **nicht** im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600): `db.env`, `app.env`, `discord.env`, optional SMTP in `app.env`. +Vorlage: `.env.example`. Wichtig: `KC_BASE_URL` muss exakt der Browser-URL entsprechen (Origin-Prüfung gegen CSRF). + +## Betrieb +```bash +systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always) +journalctl -u kc-api -f # Logs +pnpm install && pnpm build # Update: danach chown -R kundencenter und systemctl restart kc-* +pnpm migrate # Migrationen (migrations/*.sql, einmalig je Datei) +pnpm cli create-superadmin --email=… --name=… --password=… +``` +Health: `GET :4100/v1/health`, `GET :4100/v1/ready`, `GET :4102/health`. + +## Tests +`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf neu erstellt). +Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette/Maskierung. + +## Discord-Bot +In `/etc/kundencenter/discord.env` setzen: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_ADMIN_CHANNEL_ID`, `DISCORD_STAFF_USER_IDS` (kommagetrennt), dann `systemctl restart kc-worker`. +Befehle: `/kc-status`, `/kc-kunde suche:` (nur für freigegebene Discord-User, Antworten nur für sie sichtbar). In Kanäle gehen nur Ereignisse ohne personenbezogene Daten (z. B. „Neuer Kunde angelegt: K-10001“). + +## Backup / Restore (Datenbank) +```bash +set -a; . /etc/kundencenter/db.env; set +a +MYSQL_PWD=$DB_PASSWORD mysqldump -h127.0.0.1 -u$DB_USER --single-transaction --routines $DB_NAME | gzip > kundencenter-$(date +%F).sql.gz +gunzip -c kundencenter-DATUM.sql.gz | MYSQL_PWD=$DB_PASSWORD mysql -h127.0.0.1 -u$DB_USER $DB_NAME # Restore in leere DB +``` +Automatisiertes, verschlüsseltes Backup mit Restore-Test ist noch offen (siehe Plane). diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c0e2209 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,37 @@ +{ + "name": "@kc/api", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "node dist/server.js", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "migrate": "tsx src/cli/migrate.ts", + "cli": "tsx src/cli/index.ts", + "test": "vitest run" + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/helmet": "^13.1.1", + "@fastify/rate-limit": "^11.2.0", + "@kc/connector-sdk": "workspace:*", + "@kc/connectors": "workspace:*", + "@kc/platform": "workspace:*", + "@node-rs/argon2": "^2.2.1", + "fastify": "^5.12.5", + "mysql2": "^3.24.4", + "nodemailer": "^10.0.10", + "otpauth": "^9.5.2", + "zod": "^4.6.5" + }, + "devDependencies": { + "@kc/connector-mock": "workspace:*", + "@types/node": "^26.6.3", + "@types/nodemailer": "^8.0.2", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } +} diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts new file mode 100644 index 0000000..0971736 --- /dev/null +++ b/apps/api/src/cli/index.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto'; +import { hash } from '@node-rs/argon2'; +import '../core/config.js'; +import { pool, one, run } from '../core/db.js'; +import { audit } from '../core/audit.js'; + +const [cmd, ...args] = process.argv.slice(2); +const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3); + +if (cmd === 'create-superadmin') { + const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin', pw = opt('password'); + if (!email || !pw || pw.length < 12) { console.error('Nutzung: create-superadmin --email=… --name=… --password='); process.exit(2); } + if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits'); process.exit(1); } + const id = randomUUID(); + await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, { memoryCost: 19456, timeCost: 2, parallelism: 1 })]); + await audit({ actorType: 'system', action: 'user.create', resourceType: 'user', resourceId: id, after: { email, kind: 'staff', staffRole: 'superadmin', via: 'cli' } }); + console.log('Superadmin angelegt:', email, '(2FA muss beim ersten Login eingerichtet werden)'); +} else if (cmd === 'connector-secrets') { + // Zugangsdaten einer Verbindung aus einer geschützten Datei setzen (Werte erscheinen nie in Argumenten/Logs) + // Nutzung: connector-secrets --name=Licensing --file=/pfad/datei.txt (Zeilen SCHLÜSSEL=Wert; LICENSING_API_USER→username, LICENSING_API_PASSWORD→password) + const { readFileSync } = await import('node:fs'); + const { encryptSecrets } = await import('@kc/connectors'); + const { decrypt } = await import('../core/crypto.js'); + const { enqueue } = await import('../core/jobs.js'); + const name = opt('name'), file = opt('file'); + if (!name || !file) { console.error('Nutzung: connector-secrets --name= --file='); process.exit(2); } + const map: Record = { LICENSING_API_USER: 'username', LICENSING_API_PASSWORD: 'password' }; + const vals: Record = {}; + for (const line of readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m && map[m[1]!]) vals[map[m[1]!]!] = m[2]!; } + const inst = await one('SELECT id, connector_key, secrets_enc FROM connector_instances WHERE name = ?', [name]); + if (!inst) { console.error('Verbindung nicht gefunden'); process.exit(1); } + const old = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {}; + await run('UPDATE connector_instances SET secrets_enc = ? WHERE id = ?', [encryptSecrets({ ...old, ...vals }), inst.id]); + await enqueue('connector.sync', { instanceId: inst.id }, { idempotencyKey: `sync:${inst.id}:secrets:${Date.now()}` }); + await audit({ actorType: 'system', action: 'connector.update', resourceType: 'connector', resourceId: inst.id, connector: inst.connector_key, after: { secrets: Object.keys(vals), via: 'cli' } }); + console.log('Zugangsdaten gesetzt für', name, '- Felder:', Object.keys(vals).join(', '), '- Abgleich eingeplant'); +} else if (cmd === 'import-domains') { + // Domain-Preisliste (Einkauf) aus einer Textdatei einlesen: import-domains --file=/pfad/liste.txt + const { readFileSync } = await import('node:fs'); const { parsePriceList } = await import('../modules/domains/logic.js'); + const file = opt('file'); if (!file) { console.error('Nutzung: import-domains --file='); process.exit(2); } + const { rows, skipped } = parsePriceList(readFileSync(file, 'utf8')); + 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]); + console.log(`${rows.length} Endungen importiert, ${skipped.length} Zeilen übersprungen`, skipped); +} else if (cmd === 'backup') { + // backup run | restore-test [datei] | status + const { loadBackupConfig, runBackup, runRestoreTest, readStatus } = await import('../ops/backup.js'); + const cfg = loadBackupConfig(); const sub = args[0]; + if (sub === 'run') { const r = await runBackup(cfg); console.log(JSON.stringify({ ok: r.ok, file: r.file, sizeBytes: r.sizeBytes, targets: r.targets, error: r.error }, null, 2)); process.exitCode = r.ok ? 0 : 1; } + else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; } + else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2)); + else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; } +} else { + console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup'); + process.exit(2); +} +await pool.end(); diff --git a/apps/api/src/cli/migrate.ts b/apps/api/src/cli/migrate.ts new file mode 100644 index 0000000..59addb4 --- /dev/null +++ b/apps/api/src/cli/migrate.ts @@ -0,0 +1,23 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join, dirname } from 'node:path'; +import '../core/config.js'; +import { pool } from '../core/db.js'; + +const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations'); +export async function migrate(): Promise { + await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))'); + const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name)); + for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { + if (done.has(f)) continue; + const c = await pool.getConnection(); + try { + // DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch. + const stmts = readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean); + for (const st of stmts) await c.query(st); + await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]); + console.log('migriert:', f); + } finally { c.release(); } + } +} +if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); } diff --git a/apps/api/src/core/accounts.ts b/apps/api/src/core/accounts.ts new file mode 100644 index 0000000..4f999c2 --- /dev/null +++ b/apps/api/src/core/accounts.ts @@ -0,0 +1,27 @@ +import { randomUUID } from 'node:crypto'; +import type { PoolConnection } from 'mysql2/promise'; +import { run } from './db.js'; +import { randomToken, sha256 } from './crypto.js'; +import { config } from './config.js'; +import { sendMail } from './mail.js'; + +export const INVITE_DAYS = 7; + +/** Legt einen eingeladenen Benutzer an und liefert den Einladungslink (Token nur als Hash gespeichert). */ +export async function createInvitedUser(c: PoolConnection, u: { email: string; name: string; kind: 'customer' | 'staff'; staffRole?: string | null }): Promise<{ userId: string; token: string }> { + const userId = randomUUID(); + await run('INSERT INTO users (id, email, name, kind, staff_role, status) VALUES (?,?,?,?,?,\'invited\')', [userId, u.email.toLowerCase(), u.name, u.kind, u.staffRole ?? null], c); + return { userId, token: await createToken(c, userId, 'invite') }; +} +export async function createToken(c: PoolConnection | undefined, userId: string, purpose: 'invite' | 'password_reset' | 'verify_email'): Promise { + const token = randomToken(32); + const hours = purpose === 'invite' ? INVITE_DAYS * 24 : 1; + await run('INSERT INTO user_tokens (id, user_id, purpose, token_hash, expires_at) VALUES (?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))', [randomUUID(), userId, purpose, sha256(token), hours], c); + return token; +} +export const inviteLink = (token: string): string => `${config.baseUrl}/einladung?token=${encodeURIComponent(token)}`; +export const resetLink = (token: string): string => `${config.baseUrl}/passwort-reset?token=${encodeURIComponent(token)}`; + +export async function mailInvite(email: string, name: string, token: string): Promise { + return sendMail(email, 'invite', 'Ihr Zugang zum Kundencenter', `Hallo ${name},\n\nfür Sie wurde ein Zugang eingerichtet. Bitte legen Sie hier Ihr Passwort fest (${INVITE_DAYS} Tage gültig):\n${inviteLink(token)}\n`); +} diff --git a/apps/api/src/core/actions.ts b/apps/api/src/core/actions.ts new file mode 100644 index 0000000..4a38534 --- /dev/null +++ b/apps/api/src/core/actions.ts @@ -0,0 +1,4 @@ +/** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können (Rolle ∧ Freigabe ∧ Anbieter-Fähigkeit müssen zusammenpassen). */ +export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const; +export type CustomerAction = (typeof CUSTOMER_ACTIONS)[number]; +export const CHILD_MANAGE: Record = { domain: 'domain.manage', email: 'email.manage', database: 'database.manage', ftp: 'ftp.manage' }; diff --git a/apps/api/src/core/audit.ts b/apps/api/src/core/audit.ts new file mode 100644 index 0000000..7244b5e --- /dev/null +++ b/apps/api/src/core/audit.ts @@ -0,0 +1 @@ +export * from '@kc/platform/audit'; diff --git a/apps/api/src/core/auth.ts b/apps/api/src/core/auth.ts new file mode 100644 index 0000000..5e87b9a --- /dev/null +++ b/apps/api/src/core/auth.ts @@ -0,0 +1,92 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { one, query, run } from './db.js'; +import { config } from './config.js'; +import { randomToken, sha256, safeEqual } from './crypto.js'; +import { forbidden, unauthorized } from './errors.js'; +import { can, type Principal, type OrgRole, type StaffRole } from './policy.js'; + +export const COOKIE = 'kc_session'; +const SESSION_HOURS = 12; +const IDLE_MINUTES = 120; + +export interface AuthContext { + sessionId: string; + csrf: string; + pendingMfa: boolean; + mfaEnrolled: boolean; + user: { id: string; email: string; name: string }; + principal: Principal; +} +declare module 'fastify' { + interface FastifyRequest { auth?: AuthContext; correlationId: string } +} + +export async function createSession(reply: FastifyReply, userId: string, opts: { pendingMfa: boolean; ip: string; ua: string }): Promise<{ id: string; csrf: string }> { + const token = randomToken(32); + const id = randomUUID(); + const csrf = randomToken(32); + await run( + `INSERT INTO sessions (id, user_id, token_hash, csrf_token, mfa_verified, pending_mfa, ip, user_agent, expires_at) + VALUES (?,?,?,?,?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))`, + [id, userId, sha256(token), csrf, opts.pendingMfa ? 0 : 1, opts.pendingMfa ? 1 : 0, opts.ip, opts.ua.slice(0, 255), SESSION_HOURS], + ); + reply.setCookie(COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: config.isProd, path: '/', maxAge: SESSION_HOURS * 3600 }); + return { id, csrf }; +} + +async function loadAuth(req: FastifyRequest): Promise { + const token = req.cookies[COOKIE]; + if (!token) return undefined; + const s = await one( + `SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status, + (SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3) + AND s.last_seen_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL ? MINUTE)`, + [sha256(token), IDLE_MINUTES], + ); + if (!s || s.status !== 'active') return undefined; + const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]); + await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]); + return { + sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0, + user: { id: s.uid, email: s.email, name: s.name }, + principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) }, + }; +} + +/** Globale Hooks: Korrelations-ID, Sitzung laden, CSRF- und Origin-Prüfung für schreibende Requests. */ +export function registerAuth(app: FastifyInstance): void { + app.decorateRequest('correlationId', ''); + app.decorateRequest('auth', undefined); + app.addHook('onRequest', async (req, reply) => { + const inbound = req.headers['x-correlation-id']; + req.correlationId = typeof inbound === 'string' && /^[0-9a-f-]{36}$/i.test(inbound) ? inbound : randomUUID(); + reply.header('x-correlation-id', req.correlationId); + req.auth = await loadAuth(req); + if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + const origin = req.headers.origin; + if (origin && !config.allowedOrigins.has(origin)) throw forbidden('Ungültiger Origin', 'BAD_ORIGIN'); + if (req.auth) { + const sent = req.headers['x-csrf-token']; + if (typeof sent !== 'string' || !safeEqual(sent, req.auth.csrf)) throw forbidden('CSRF-Token ungültig', 'BAD_CSRF'); + } + } + }); +} + +/** Voraussetzung für geschützte Routen. Staff müssen 2FA eingerichtet haben (außer bei MFA-Einrichtung). */ +export function requireAuth(req: FastifyRequest, opts: { allowPendingMfa?: boolean; allowUnenrolledStaff?: boolean } = {}): AuthContext { + const a = req.auth; + if (!a) throw unauthorized(); + if (a.pendingMfa && !opts.allowPendingMfa) throw unauthorized('Zweiter Faktor erforderlich', 'MFA_REQUIRED'); + if (a.principal.kind === 'staff' && !a.mfaEnrolled && !opts.allowUnenrolledStaff) throw forbidden('Für Mitarbeiter ist 2FA verpflichtend', 'MFA_ENROLL_REQUIRED'); + return a; +} +export function requirePermission(req: FastifyRequest, permission: string): AuthContext { + const a = requireAuth(req); + if (!can(a.principal, permission)) throw forbidden(); + return a; +} +export const clientIp = (req: FastifyRequest): string => req.ip; diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts new file mode 100644 index 0000000..7ffe900 --- /dev/null +++ b/apps/api/src/core/config.ts @@ -0,0 +1,5 @@ +export * from '@kc/platform/config'; +import { config } from '@kc/platform/config'; + +/** Rate-Limits werden im Testmodus praktisch abgeschaltet, damit Tests sich nicht selbst aussperren. */ +export const rl = (max: number, timeWindow: string) => ({ rateLimit: { max: config.env === 'test' ? 100000 : max, timeWindow } }); diff --git a/apps/api/src/core/crypto.ts b/apps/api/src/core/crypto.ts new file mode 100644 index 0000000..509d067 --- /dev/null +++ b/apps/api/src/core/crypto.ts @@ -0,0 +1 @@ +export * from '@kc/platform/crypto'; diff --git a/apps/api/src/core/db.ts b/apps/api/src/core/db.ts new file mode 100644 index 0000000..f57f84d --- /dev/null +++ b/apps/api/src/core/db.ts @@ -0,0 +1 @@ +export * from '@kc/platform/db'; diff --git a/apps/api/src/core/errors.ts b/apps/api/src/core/errors.ts new file mode 100644 index 0000000..8e08325 --- /dev/null +++ b/apps/api/src/core/errors.ts @@ -0,0 +1,8 @@ +export class AppError extends Error { + constructor(public status: number, public code: string, message: string) { super(message); } +} +export const badRequest = (m: string, code = 'BAD_REQUEST') => new AppError(400, code, m); +export const unauthorized = (m = 'Nicht angemeldet', code = 'UNAUTHENTICATED') => new AppError(401, code, m); +export const forbidden = (m = 'Keine Berechtigung', code = 'FORBIDDEN') => new AppError(403, code, m); +export const notFound = (m = 'Nicht gefunden') => new AppError(404, 'NOT_FOUND', m); +export const conflict = (m: string, code = 'CONFLICT') => new AppError(409, code, m); diff --git a/apps/api/src/core/jobs.ts b/apps/api/src/core/jobs.ts new file mode 100644 index 0000000..7a88f99 --- /dev/null +++ b/apps/api/src/core/jobs.ts @@ -0,0 +1 @@ +export * from '@kc/platform/jobs'; diff --git a/apps/api/src/core/mail.ts b/apps/api/src/core/mail.ts new file mode 100644 index 0000000..38927f7 --- /dev/null +++ b/apps/api/src/core/mail.ts @@ -0,0 +1,23 @@ +import nodemailer from 'nodemailer'; +import { config } from './config.js'; +import { run } from './db.js'; + +const transport = config.smtp + ? nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.port === 465, auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined }) + : null; + +/** Versand mit Protokoll. Ohne SMTP wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */ +export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> { + if (!transport || !config.smtp) { + await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']); + return 'not_configured'; + } + try { + await transport.sendMail({ from: config.smtp.from, to, subject, text }); + await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']); + return 'sent'; + } catch (e) { + await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]); + return 'failed'; + } +} diff --git a/apps/api/src/core/module.ts b/apps/api/src/core/module.ts new file mode 100644 index 0000000..647b365 --- /dev/null +++ b/apps/api/src/core/module.ts @@ -0,0 +1,10 @@ +import type { FastifyInstance } from 'fastify'; +import type { OrgRole, StaffRole } from './policy.js'; + +/** Vertrag für Anwendungsmodule. Module kommunizieren nur über core/* und Domain-Events (Jobs), nie über fremde Module. */ +export interface KcModule { + name: string; + /** Zusätzliche Rechte, die das Modul in die Policy-Schicht einträgt. */ + permissions?: { staff?: Partial>; org?: Partial> }; + register(app: FastifyInstance): void | Promise; +} diff --git a/apps/api/src/core/policy.ts b/apps/api/src/core/policy.ts new file mode 100644 index 0000000..dd35dce --- /dev/null +++ b/apps/api/src/core/policy.ts @@ -0,0 +1,42 @@ +/** Zentrale Policy-Schicht: RBAC (Staff-Rollen, Org-Rollen) + objektbezogene Prüfung. */ +export type StaffRole = 'support' | 'accounting' | 'admin' | 'superadmin'; +export type OrgRole = 'owner' | 'admin' | 'member'; + +const STAFF: Record = { + support: ['customers.read', 'users.read'], + accounting: ['customers.read'], + admin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'audit.read', 'jobs.read'], + superadmin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'users.write_privileged', 'audit.read', 'jobs.read', 'settings.write'], +}; +const ORG: Record = { + owner: ['org.read', 'org.members.read', 'org.members.invite'], + admin: ['org.read', 'org.members.read', 'org.members.invite'], + member: ['org.read'], +}; +/** Module dürfen weitere Rechte registrieren (siehe KcModule.permissions). */ +const extra: { staff: Partial>; org: Partial> } = { staff: {}, org: {} }; +export function registerPermissions(p: { staff?: Partial>; org?: Partial> }): void { + for (const [r, l] of Object.entries(p.staff ?? {})) extra.staff[r as StaffRole] = [...(extra.staff[r as StaffRole] ?? []), ...l]; + for (const [r, l] of Object.entries(p.org ?? {})) extra.org[r as OrgRole] = [...(extra.org[r as OrgRole] ?? []), ...l]; +} + +export interface Principal { + userId: string; + kind: 'customer' | 'staff'; + staffRole: StaffRole | null; + memberships: { orgId: string; role: OrgRole }[]; +} + +export function staffPermissions(role: StaffRole | null): string[] { + return role ? [...STAFF[role], ...(extra.staff[role] ?? [])] : []; +} +/** Globales Recht (nur Staff). */ +export function can(p: Principal, permission: string): boolean { + return p.kind === 'staff' && staffPermissions(p.staffRole).includes(permission); +} +/** Objektbezogen: Staff mit globalem Recht ODER Mitglied der Organisation mit passender Org-Rolle. */ +export function canInOrg(p: Principal, orgId: string, orgPermission: string, staffPermission: string): boolean { + if (can(p, staffPermission)) return true; + const m = p.memberships.find((x) => x.orgId === orgId); + return !!m && [...ORG[m.role], ...(extra.org[m.role] ?? [])].includes(orgPermission); +} diff --git a/apps/api/src/modules/audit/index.ts b/apps/api/src/modules/audit/index.ts new file mode 100644 index 0000000..571015d --- /dev/null +++ b/apps/api/src/modules/audit/index.ts @@ -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(); + }); + }, +}; diff --git a/apps/api/src/modules/backup/index.ts b/apps/api/src/modules/backup/index.ts new file mode 100644 index 0000000..3e3bbd1 --- /dev/null +++ b/apps/api/src/modules/backup/index.ts @@ -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; + function split(t: NewTarget) { + const cfg: Record = {}; const sec: Record = {}; + 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' }); + }); + }, +}; diff --git a/apps/api/src/modules/catalog/index.ts b/apps/api/src/modules/catalog/index.ts new file mode 100644 index 0000000..091ac1c --- /dev/null +++ b/apps/api/src/modules/catalog/index.ts @@ -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; +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, forActivation = false): Promise { + 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[2], productId: string, v: VersionIn, by: string): Promise { + 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 { + 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; +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, true); + if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record); + 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 }; + }); + }); + }, +}; diff --git a/apps/api/src/modules/connectors/index.ts b/apps/api/src/modules/connectors/index.ts new file mode 100644 index 0000000..896c9ae --- /dev/null +++ b/apps/api/src/modules/connectors/index.ts @@ -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) { + const fields = getConnector(key).configFields; const config: Record = {}; const secrets: Record = {}; + 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; 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' }; + }); + }, +}; diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts new file mode 100644 index 0000000..bf9ae1f --- /dev/null +++ b/apps/api/src/modules/customers/index.ts @@ -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; +/** Fachregeln je Kundenart: Privatkunde ohne Firma/USt-IdNr., Geschäftskunde mit Firmenname. */ +function applyTypeRules(type: 'private' | 'business', name: string, b: Partial, 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[2]): Promise { + 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 = { company: 'company', contactName: 'contact_name', street: 'street', zip: 'zip', city: 'city', country: 'country', vatId: 'vat_id', billingEmail: 'billing_email', phone: 'phone' }; + const patch: Record = {}; + 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[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 })), + }; +} diff --git a/apps/api/src/modules/domains/index.ts b/apps/api/src/modules/domains/index.ts new file mode 100644 index 0000000..1a16cd5 --- /dev/null +++ b/apps/api/src/modules/domains/index.ts @@ -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 }; + }); + }, +}; diff --git a/apps/api/src/modules/domains/logic.ts b/apps/api/src/modules/domains/logic.ts new file mode 100644 index 0000000..79de065 --- /dev/null +++ b/apps/api/src/modules/domains/logic.ts @@ -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(); + 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): { 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 = { de: 'https://rdap.denic.de/' }; +let boot: { at: number; map: Map } | null = null; +async function rdapBase(tld: string): Promise { + 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(); + 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 { + 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 { + 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); +} diff --git a/apps/api/src/modules/identity/index.ts b/apps/api/src/modules/identity/index.ts new file mode 100644 index 0000000..fa7529f --- /dev/null +++ b/apps/api/src/modules/identity/index.ts @@ -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 { + 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 { + 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' }; + }); + }, +}; diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts new file mode 100644 index 0000000..1d542a0 --- /dev/null +++ b/apps/api/src/modules/index.ts @@ -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]; diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts new file mode 100644 index 0000000..2e2c0e0 --- /dev/null +++ b/apps/api/src/modules/orders/index.ts @@ -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 { + 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 { + 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' }; + }); + }, +}; diff --git a/apps/api/src/modules/resources/index.ts b/apps/api/src/modules/resources/index.ts new file mode 100644 index 0000000..204dfae --- /dev/null +++ b/apps/api/src/modules/resources/index.ts @@ -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 = (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(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(r.customer_actions, []).includes(x)) : []; +} +const childCapsTop = (r: any): string[] => json(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(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(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 = {}; + 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(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(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(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' }; + }); + }, +}; diff --git a/apps/api/src/modules/system/index.ts b/apps/api/src/modules/system/index.ts new file mode 100644 index 0000000..9336775 --- /dev/null +++ b/apps/api/src/modules/system/index.ts @@ -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() }; + }); + }, +}; diff --git a/apps/api/src/ops/backup.ts b/apps/api/src/ops/backup.ts new file mode 100644 index 0000000..ccb6eb1 --- /dev/null +++ b/apps/api/src/ops/backup.ts @@ -0,0 +1,209 @@ +import { spawn } from 'node:child_process'; +import { createReadStream, createWriteStream, existsSync } from 'node:fs'; +import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, basename } from 'node:path'; +import mysql from 'mysql2/promise'; +import { config } from '../core/config.js'; +import { query, one } from '../core/db.js'; +import { audit, verifyAuditChain } from '../core/audit.js'; +import { enqueue } from '../core/jobs.js'; +import { fileName, parseName, selectDeletions, type Keep } from './retention.js'; +import { getBackupPassword } from './settings.js'; +import { buildRemote, loadTargets, friendly, type Remote } from './targets.js'; + +/** Konfiguration aus /etc/kundencenter/backup.env (nur Namen/Pfade, keine Zugangsdaten der Ziele; die liegen in der rclone-Konfiguration). */ +export interface BackupConfig { dir: string; recipient: string | null; identity: string | null; remotes: string[]; rclone: string; rcloneConfig: string | null; keep: Keep; statusFile: string; envDir: string } +export function loadBackupConfig(env = process.env): BackupConfig { + return { + dir: env.BACKUP_DIR ?? '/var/backups/kundencenter', recipient: env.BACKUP_AGE_RECIPIENT || null, identity: env.BACKUP_AGE_IDENTITY || null, + remotes: (env.BACKUP_REMOTES ?? '').split(',').map((s) => s.trim()).filter(Boolean), rclone: env.BACKUP_RCLONE_BIN ?? 'rclone', rcloneConfig: env.RCLONE_CONFIG || null, + keep: { daily: Number(env.BACKUP_KEEP_DAILY ?? 14), weekly: Number(env.BACKUP_KEEP_WEEKLY ?? 8), monthly: Number(env.BACKUP_KEEP_MONTHLY ?? 12) }, + statusFile: env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json', envDir: env.KC_BACKUP_ENV_DIR ?? '/etc/kundencenter', + }; +} + +/** Externes Programm ohne Shell starten. Fehlertext enthält nur stderr (ohne Umgebung/Passwörter). */ +function run(cmd: string, args: string[], o: { env?: Record; stdin?: string; stdinText?: string; stdout?: string; input?: string } = {}): Promise { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/root', ...(o.env ?? {}) }, stdio: [o.stdin || o.stdinText !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); + let out = ''; let err = ''; + if (o.stdout) p.stdout!.pipe(createWriteStream(o.stdout, { mode: 0o600 })); else p.stdout!.on('data', (d) => (out += d)); + p.stderr!.on('data', (d) => { err += d; if (err.length > 4000) err = err.slice(-4000); }); + if (o.stdin) createReadStream(o.stdin).pipe(p.stdin!); else if (o.stdinText !== undefined) { p.stdin!.on('error', () => undefined); p.stdin!.end(o.stdinText); } + p.on('error', (e) => reject(new Error(`${basename(cmd)} konnte nicht gestartet werden: ${e.message}`))); + p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${basename(cmd)} Fehler (Exit ${code}): ${err.trim().split('\n').slice(-3).join(' | ')}`)))); + }); +} +/** Passwortverschlüsselung mit gpg (AES-256, Integritätsschutz). Das Passwort geht über stdin, nie über Argumente. Eigenes Arbeitsverzeichnis, kein Agent-Rückstand. */ +async function gpgEncrypt(work: string, input: string, output: string, password: string): Promise { + const home = await mkdtemp(join(work, 'gnupg-')); await chmod(home, 0o700); + await run('gpg', ['--homedir', home, '--batch', '--yes', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '--symmetric', '--cipher-algo', 'AES256', '--s2k-mode', '3', '--s2k-count', '65011712', '--s2k-digest-algo', 'SHA512', '-o', output, input], { stdinText: password }); +} +async function gpgDecrypt(work: string, input: string, output: string, password: string): Promise { + const home = await mkdtemp(join(work, 'gnupg-')); await chmod(home, 0o700); + await run('gpg', ['--homedir', home, '--batch', '--yes', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', '-o', output, input], { stdinText: password }); +} +const sha256 = async (f: string) => { const h = createHash('sha256'); for await (const c of createReadStream(f)) h.update(c); return h.digest('hex'); }; +const dbEnv = () => ({ MYSQL_PWD: config.db.password }); +const dbArgs = () => ['-h', config.db.host, '-P', String(config.db.port), '-u', config.db.user]; +const rcloneArgs = (c: BackupConfig) => (c.rcloneConfig ? ['--config', c.rcloneConfig] : []); + +export interface RunSummary { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; error?: string } +export interface Status { + running?: { action: 'backup' | 'restore-test'; since: string } | null; + history?: RunSummary[]; + lastRun?: { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; targets: { name: string; ok: boolean; error?: string }[]; error?: string }; + lastRestoreTest?: { at: string; ok: boolean; file?: string; durationMs: number; checks: Record; error?: string }; +} +export async function readStatus(c: BackupConfig): Promise { try { return JSON.parse(await readFile(c.statusFile, 'utf8')); } catch { return {}; } } +async function writeStatus(c: BackupConfig, patch: Partial): Promise { + const s = { ...(await readStatus(c)), ...patch }; + if (patch.lastRun) { const r = patch.lastRun; s.history = [{ at: r.at, ok: r.ok, file: r.file, sizeBytes: r.sizeBytes, durationMs: r.durationMs, label: r.label, error: r.error }, ...((await readStatus(c)).history ?? [])].slice(0, 30); } const tmp = `${c.statusFile}.tmp`; + await mkdir(join(c.statusFile, '..'), { recursive: true }); await writeFile(tmp, JSON.stringify(s, null, 2), { mode: 0o644 }); await rename(tmp, c.statusFile); +} +const alertOnce = (event: string, detail: string) => enqueue('discord.notify', { event, detail: detail.slice(0, 200) }, { idempotencyKey: `${event}:${new Date().toISOString().slice(0, 13)}` }).catch(() => undefined); + +const COUNT_TABLES = ['users', 'organizations', 'memberships', 'orders', 'contracts', 'products', 'resources', 'connector_instances', 'audit_events', 'jobs']; +async function tableCounts(q: (sql: string) => Promise): Promise> { + const out: Record = {}; + for (const t of COUNT_TABLES) out[t] = Number((await q(`SELECT COUNT(*) AS n FROM \`${t}\``))[0].n); + return out; +} + +/** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */ +export async function runBackup(c: BackupConfig, now = new Date()): Promise> { + const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record }[] = []; const built: Remote[] = []; + const pw = await getBackupPassword().catch(() => null); const name = fileName(now, pw ? 'gpg' : 'age'); + await mkdir(c.dir, { recursive: true, mode: 0o700 }); + await writeStatus(c, { running: { action: 'backup', since: now.toISOString() } }).catch(() => undefined); + const work = await mkdtemp(join(c.dir, '.work-')); + try { + await mkdir(join(work, 'config')); + await run('mysqldump', [...dbArgs(), '--single-transaction', '--routines', '--triggers', '--events', '--no-tablespaces', '--default-character-set=utf8mb4', config.db.database], { env: dbEnv(), stdout: join(work, 'db.sql') }); + // Konfiguration inkl. Master-Schlüssel (ohne den privaten Backup-Schlüssel!): ohne KC_SECRET_KEY sind TOTP-/Connector-Geheimnisse unlesbar + // Nur die Dateien, die das Kundencenter zum Betrieb braucht (nicht z. B. Plane-Zugang, nie den privaten Backup-Schlüssel) + for (const f of ['app.env', 'db.env', 'discord.env']) if (existsSync(join(c.envDir, f))) await copyFile(join(c.envDir, f), join(work, 'config', f)); + const counts = await tableCounts((sql) => query(sql)); + const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string); + await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2)); + await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']); + const out = join(c.dir, name); + if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password); + else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]); + else throw new Error('Keine Verschlüsselung eingerichtet: bitte unter Einstellungen → Backup ein Passwort festlegen.'); + await chmod(out, 0o600); // nur Besitzer + const size = (await stat(out)).size; + if (size < 512) throw new Error('Backup-Datei ist unplausibel klein'); + await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 }); + targets.push({ name: `lokal (${c.dir})`, ok: true }); + // Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES + try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } } + catch (e) { targets.push({ name: 'Ziele laden', ok: false, error: friendly(e) }); } + for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} }); + const okDests: typeof dests = []; + for (const d of dests) { + try { + const dest = `${d.remote.replace(/\/+$/, '')}/${name}`; + await run(c.rclone, ['copyto', out, dest, '--retries', '3', '--low-level-retries', '5', '--timeout', '120s', '--contimeout', '30s'], { env: d.env }); + const ls = JSON.parse(await run(c.rclone, ['lsjson', dest], { env: d.env })) as { Size: number }[]; + if (ls[0]?.Size !== size) throw new Error(`Größe am Ziel weicht ab (${ls[0]?.Size ?? 'fehlt'} statt ${size})`); + await run(c.rclone, ['copyto', `${out}.sha256`, `${dest}.sha256`], { env: d.env }); + targets.push({ name: d.label, ok: true }); okDests.push(d); + } catch (e) { targets.push({ name: d.label, ok: false, error: friendly(e) }); } + } + await applyRetention(c, now, okDests, targets); + const failed = targets.filter((t) => !t.ok); + const res = { at: now.toISOString(), ok: failed.length === 0, file: name, sizeBytes: size, durationMs: Date.now() - t0, label: parseName(name)?.label, targets, ...(failed.length ? { error: `${failed.length} Ziel(e) fehlgeschlagen` } : {}) }; + await writeStatus(c, { lastRun: res, running: null }); + await audit({ actorType: 'system', action: 'backup.run', resourceType: 'backup', resourceId: name, result: res.ok ? 'success' : 'failure', errorClass: res.ok ? undefined : 'target_failed', after: { sizeBytes: size, targets: targets.map((t) => ({ name: t.name, ok: t.ok })) } }).catch(() => undefined); + if (!res.ok) await alertOnce('backup.failed', res.error ?? 'Ziel fehlgeschlagen'); + return res; + } catch (e) { + const res = { at: now.toISOString(), ok: false, durationMs: Date.now() - t0, targets, error: (e as Error).message.slice(0, 400) }; + await writeStatus(c, { lastRun: res, running: null }).catch(() => undefined); + await audit({ actorType: 'system', action: 'backup.run', resourceType: 'backup', result: 'failure', errorClass: 'backup_error' }).catch(() => undefined); + await alertOnce('backup.failed', res.error); + return res; + } finally { await rm(work, { recursive: true, force: true }); for (const r of built) await r.cleanup().catch(() => undefined); } +} + +async function applyRetention(c: BackupConfig, now: Date, dests: { label: string; remote: string; env: Record }[], targets: { name: string; ok: boolean; error?: string }[]): Promise { + const localNames = (await readdir(c.dir)).filter((n) => parseName(n)); + for (const n of selectDeletions(localNames, now, c.keep)) { await rm(join(c.dir, n), { force: true }); await rm(join(c.dir, `${n}.sha256`), { force: true }); } + for (const d of dests) { + const t = targets.find((x) => x.name === d.label); + try { + const base = d.remote.replace(/\/+$/, ''); + const names = (await run(c.rclone, ['lsf', d.remote, '--files-only'], { env: d.env })).split('\n').map((x) => x.trim()).filter(Boolean); + for (const n of selectDeletions(names.filter((x) => parseName(x)), now, c.keep)) { await run(c.rclone, ['deletefile', `${base}/${n}`], { env: d.env }); await run(c.rclone, ['deletefile', `${base}/${n}.sha256`], { env: d.env }).catch(() => undefined); } + } catch (e) { if (t) t.error = `Aufräumen fehlgeschlagen: ${friendly(e)}`; } + } +} + +/** Neueste lokale Sicherung finden. */ +export async function latestLocal(c: BackupConfig): Promise { + const names = (await readdir(c.dir)).map(parseName).filter((x): x is NonNullable> => !!x).sort((a, b) => b.at.getTime() - a.at.getTime()); + return names[0]?.name ?? null; +} + +/** + * Wiederherstellungstest: entschlüsselt eine Sicherung, spielt sie in eine Wegwerf-Datenbank ein und prüft Prüfsumme, Zeilenzahlen, + * Migrationen, Audit-Hash-Kette und ob die gesicherten Geheimnisse mit dem gesicherten Master-Schlüssel entschlüsselbar sind. Produktivdaten bleiben unberührt. + */ +export async function runRestoreTest(c: BackupConfig, file?: string): Promise> { + const t0 = Date.now(); const checks: Record = {}; const scratch = `${config.db.database}_restoretest`; + const work = await mkdtemp(join(tmpdir(), 'kc-restore-')); + await writeStatus(c, { running: { action: 'restore-test', since: new Date().toISOString() } }).catch(() => undefined); + let name = file; + try { + name = name ?? (await latestLocal(c)) ?? undefined; if (!name) throw new Error('Keine Sicherung gefunden'); + const src = join(c.dir, name); checks.datei = name; + const expect = (await readFile(`${src}.sha256`, 'utf8')).split(/\s+/)[0]; checks.pruefsumme = (await sha256(src)) === expect; + if (!checks.pruefsumme) throw new Error('Prüfsumme der Sicherung stimmt nicht'); + if (name.endsWith('.gpg')) { + const pw = await getBackupPassword(); if (!pw) throw new Error('Kein Backup-Passwort gespeichert: die Sicherung ist passwortverschlüsselt.'); + try { await gpgDecrypt(work, src, join(work, 'archive.tar.gz'), pw.password); } catch { throw new Error('Entschlüsselung fehlgeschlagen: Das gespeicherte Passwort passt nicht zu dieser Sicherung (wurde das Passwort inzwischen geändert?).'); } + } else { + if (!c.identity || !existsSync(c.identity)) throw new Error('Privater Backup-Schlüssel (BACKUP_AGE_IDENTITY) nicht vorhanden'); + await run('age', ['-d', '-i', c.identity, '-o', join(work, 'archive.tar.gz'), src]); + } + await run('tar', ['-xzf', join(work, 'archive.tar.gz'), '-C', work]); + const manifest = JSON.parse(await readFile(join(work, 'manifest.json'), 'utf8')); checks.dumpPruefsumme = (await sha256(join(work, 'db.sql'))) === manifest.dumpSha256; + if (!checks.dumpPruefsumme) throw new Error('Prüfsumme des Datenbank-Dumps stimmt nicht'); + const admin = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password }); + try { + await admin.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await admin.query(`CREATE DATABASE \`${scratch}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); + } finally { await admin.end(); } + await run('mysql', [...dbArgs(), scratch], { env: dbEnv(), stdin: join(work, 'db.sql') }); + const conn = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password, database: scratch, timezone: 'Z' }); // UTC wie im Betrieb, sonst stimmt die Hash-Kette der Zeitstempel nicht + try { + const q = async (sql: string) => (await conn.query(sql))[0] as any[]; + const counts = await tableCounts(q); const mism = Object.entries(manifest.counts as Record).filter(([t, n]) => counts[t] !== n); + checks.zeilenzahlen = mism.length === 0; if (mism.length) throw new Error(`Zeilenzahlen weichen ab: ${mism.map(([t]) => t).join(', ')}`); + const mig = (await q('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name); checks.migrationen = JSON.stringify(mig) === JSON.stringify(manifest.migrations); if (!checks.migrationen) throw new Error('Migrationen weichen ab'); + const chain = await verifyAuditChain(q); checks.auditKette = chain.brokenAt === null; checks.auditEintraege = chain.checked; if (chain.brokenAt !== null) throw new Error(`Audit-Kette defekt ab Eintrag ${chain.brokenAt}`); + // Geheimnisse mit dem GESICHERTEN Schlüssel entschlüsseln (beweist, dass die Schlüsselsicherung brauchbar ist) + const key = /^KC_SECRET_KEY=(.+)$/m.exec(await readFile(join(work, 'config', 'app.env'), 'utf8'))?.[1]; + if (!key) throw new Error('Master-Schlüssel fehlt in der Sicherung'); + const { createDecipheriv } = await import('node:crypto'); + const dec = (blob: string) => { const [v, iv, tag, ct] = blob.split(':'); const d = createDecipheriv('aes-256-gcm', Buffer.from(key, 'base64'), Buffer.from(iv!, 'base64url')); d.setAuthTag(Buffer.from(tag!, 'base64url')); return Buffer.concat([d.update(Buffer.from(ct!, 'base64url')), d.final()]).length > 0 && v === 'v1'; }; + const sample = [...(await q('SELECT secret_enc AS s FROM mfa_totp LIMIT 3')), ...(await q('SELECT secrets_enc AS s FROM connector_instances WHERE secrets_enc IS NOT NULL LIMIT 3'))]; + checks.geheimnisseGeprueft = sample.length; checks.geheimnisseOk = sample.every((r) => dec(r.s)); if (!checks.geheimnisseOk) throw new Error('Gesicherte Geheimnisse sind mit dem gesicherten Schlüssel nicht entschlüsselbar'); + } finally { await conn.end(); } + const res = { at: new Date().toISOString(), ok: true, file: name, durationMs: Date.now() - t0, checks }; + await writeStatus(c, { lastRestoreTest: res, running: null }); + await audit({ actorType: 'system', action: 'backup.restore_test', resourceType: 'backup', resourceId: name, result: 'success' }).catch(() => undefined); + return res; + } catch (e) { + const res = { at: new Date().toISOString(), ok: false, file: name, durationMs: Date.now() - t0, checks, error: (e as Error).message.slice(0, 400) }; + await writeStatus(c, { lastRestoreTest: res, running: null }).catch(() => undefined); + await audit({ actorType: 'system', action: 'backup.restore_test', resourceType: 'backup', resourceId: name, result: 'failure', errorClass: 'restore_test_failed' }).catch(() => undefined); + await alertOnce('restoretest.failed', res.error); + return res; + } finally { + await rm(work, { recursive: true, force: true }); + try { const a = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password }); await a.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await a.end(); } catch { /* Aufräumen best effort */ } + } +} diff --git a/apps/api/src/ops/retention.ts b/apps/api/src/ops/retention.ts new file mode 100644 index 0000000..0ccc14a --- /dev/null +++ b/apps/api/src/ops/retention.ts @@ -0,0 +1,26 @@ +/** Benennung und Aufbewahrung von Backups (rein, ohne Dateizugriff). Zeiten in UTC. */ +export type Label = 'daily' | 'weekly' | 'monthly'; +export interface Keep { daily: number; weekly: number; monthly: number } +export const FILE_RE = /^kundencenter-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(daily|weekly|monthly)\.tar\.gz\.(age|gpg)$/; + +/** Monatserster = monthly, Sonntag = weekly, sonst daily. */ +export const labelFor = (d: Date): Label => (d.getUTCDate() === 1 ? 'monthly' : d.getUTCDay() === 0 ? 'weekly' : 'daily'); +const pad = (n: number, l = 2) => String(n).padStart(l, '0'); +export const fileName = (d: Date, ext: 'age' | 'gpg' = 'age'): string => `kundencenter-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${labelFor(d)}.tar.gz.${ext}`; +export function parseName(n: string): { name: string; at: Date; label: Label; ext: 'age' | 'gpg' } | null { + const m = FILE_RE.exec(n); if (!m) return null; + return { name: n, at: new Date(Date.UTC(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +m[6]!)), label: m[7] as Label, ext: m[8] as 'age' | 'gpg' }; +} +/** + * Welche Dateien dürfen gelöscht werden? Täglich: keep.daily Tage, wöchentlich: keep.weekly Wochen, monatlich: keep.monthly Monate. + * Fremde Dateinamen werden nie angefasst. Es bleiben immer mindestens `minKeep` Sicherungen erhalten (die neuesten). + */ +export function selectDeletions(names: string[], now: Date, keep: Keep, minKeep = 3): string[] { + const all = names.map(parseName).filter((x): x is NonNullable> => !!x); + const ageDays = (d: Date) => (now.getTime() - d.getTime()) / 86400000; + const limit: Record = { daily: keep.daily, weekly: keep.weekly * 7, monthly: keep.monthly * 31 }; + const del = all.filter((b) => ageDays(b.at) > limit[b.label]).sort((a, b) => b.at.getTime() - a.at.getTime()); // neueste zuerst + let survivors = all.length - del.length; + while (survivors < minKeep && del.length) { del.shift(); survivors++; } // neueste "Löschkandidaten" behalten + return del.map((b) => b.name); +} diff --git a/apps/api/src/ops/settings.ts b/apps/api/src/ops/settings.ts new file mode 100644 index 0000000..e3231a7 --- /dev/null +++ b/apps/api/src/ops/settings.ts @@ -0,0 +1,28 @@ +import { one, run } from '../core/db.js'; +import { decrypt, encrypt } from '../core/crypto.js'; + +/** Backup-Passwort: Mindestlänge laut Vorgabe "länger als 10 Zeichen". */ +export const MIN_PASSWORD = 11; +export function passwordProblem(pw: string): string | null { + if (pw.length < MIN_PASSWORD) return `Das Passwort muss länger als 10 Zeichen sein (mindestens ${MIN_PASSWORD}).`; + if (pw.length > 200) return 'Das Passwort darf höchstens 200 Zeichen lang sein.'; + if (/^(.)\1+$/.test(pw)) return 'Das Passwort darf nicht nur aus einem wiederholten Zeichen bestehen.'; + if (/[\r\n\0]/.test(pw)) return 'Das Passwort darf keine Zeilenumbrüche enthalten.'; + return null; +} +interface PasswordRecord { password: string; version: number; updatedAt: string } +/** Liefert das gespeicherte Backup-Passwort (mit dem Master-Schlüssel verschlüsselt in der Datenbank) oder null. */ +export async function getBackupPassword(): Promise { + const r = await one('SELECT value_enc FROM backup_settings WHERE `key` = ?', ['password']); + return r ? (JSON.parse(decrypt(r.value_enc)) as PasswordRecord) : null; +} +export async function passwordMeta(): Promise<{ set: boolean; version: number; updatedAt: string | null }> { + const r = await getBackupPassword(); return { set: !!r, version: r?.version ?? 0, updatedAt: r?.updatedAt ?? null }; +} +export async function setBackupPassword(pw: string, userId: string | null): Promise { + const problem = passwordProblem(pw); if (problem) throw new Error(problem); + const cur = await getBackupPassword(); const version = (cur?.version ?? 0) + 1; + await run('INSERT INTO backup_settings (`key`, value_enc, updated_by) VALUES (?,?,?) ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_by = VALUES(updated_by)', + ['password', encrypt(JSON.stringify({ password: pw, version, updatedAt: new Date().toISOString() } satisfies PasswordRecord)), userId]); + return version; +} diff --git a/apps/api/src/ops/targets.ts b/apps/api/src/ops/targets.ts new file mode 100644 index 0000000..0c72bd3 --- /dev/null +++ b/apps/api/src/ops/targets.ts @@ -0,0 +1,94 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { decrypt } from '../core/crypto.js'; +import { query } from '../core/db.js'; + +export type TargetType = 'sftp' | 'ftp' | 'gdrive' | 'local'; +export interface TargetRow { id: string; name: string; type: TargetType; path: string; config: Record; secrets: Record; hostKey: string | null; enabled: boolean } +/** Ein einsatzbereites Ziel: rclone-Zielangabe + Umgebung (keine Konfigurationsdatei, keine Passwörter in Argumenten). */ +export interface Remote { name: string; remote: string; env: Record; cleanup: () => Promise } + +export async function loadTargets(onlyEnabled = true): Promise { + const rows = await query(`SELECT * FROM backup_targets ${onlyEnabled ? 'WHERE enabled = 1' : ''} ORDER BY name`); + return rows.map((r) => ({ + id: r.id, name: r.name, type: r.type, path: r.path, enabled: !!r.enabled, hostKey: r.host_key ?? null, + config: typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json, secrets: r.secrets_enc ? JSON.parse(decrypt(r.secrets_enc)) : {}, + })); +} + +/** Kleines Hilfsprogramm mit Zeitlimit; Fehlertext nur aus stderr. */ +export function exec(cmd: string, args: string[], o: { env?: Record; input?: string; timeoutMs?: number } = {}): Promise { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', ...(o.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = ''; let err = ''; const timer = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(`${cmd} hat nicht rechtzeitig geantwortet`)); }, o.timeoutMs ?? 60_000); + p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => { err += d; if (err.length > 4000) err = err.slice(-4000); }); + p.on('error', (e) => { clearTimeout(timer); reject(new Error(`${cmd} konnte nicht gestartet werden: ${e.message}`)); }); + p.on('close', (code) => { clearTimeout(timer); code === 0 ? resolve(out) : reject(new Error(`${cmd} Fehler (Exit ${code}): ${err.trim().split('\n').slice(-2).join(' | ')}`)); }); + p.stdin.on('error', () => undefined); p.stdin.end(o.input ?? ''); + }); +} +const obscure = async (rclone: string, pw: string) => (await exec(rclone, ['obscure', '-'], { input: pw, timeoutMs: 10_000 })).trim(); + +/** Verständliche Fehlermeldung ohne Zugangsdaten. */ +export function friendly(e: unknown): string { + const m = String((e as Error)?.message ?? e); + if (/host key|knownhosts|key mismatch|REMOTE HOST IDENTIFICATION/i.test(m)) return 'Der Server-Schlüssel stimmt nicht mit dem gemerkten überein. Wurde der Server neu aufgesetzt? Dann den Server-Schlüssel neu erfassen.'; + if (/auth|permission denied|login|530|password/i.test(m)) return 'Anmeldung fehlgeschlagen (Benutzer, Passwort oder Schlüssel prüfen).'; + if (/no such host|lookup|name or service/i.test(m)) return 'Der Servername wurde nicht gefunden.'; + if (/refused|unreachable|timed out|timeout|nicht rechtzeitig|i\/o timeout/i.test(m)) return 'Der Server ist nicht erreichbar (Adresse, Port und Firewall prüfen).'; + if (/directory not found|not found|no such file/i.test(m)) return 'Der Zielordner wurde nicht gefunden und konnte nicht angelegt werden.'; + if (/quota|storage|space/i.test(m)) return 'Kein Speicherplatz mehr am Ziel.'; + return m.replace(/\s+/g, ' ').slice(0, 300); +} + +export async function buildRemote(t: TargetRow, rclone = 'rclone', hostKeyOverride?: string): Promise { + if (t.type === 'local') return { name: t.name, remote: t.path, env: {}, cleanup: async () => undefined }; + const rn = `KCT${t.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`; const P = `RCLONE_CONFIG_${rn}_`; const env: Record = {}; + // Eigenes, temporäres Verzeichnis (Server-Schlüssel, leere rclone-Konfiguration): rclone darf nichts in Benutzerordner schreiben und findet keine fremde Konfiguration + const tmp: string = await mkdtemp(join(tmpdir(), 'kc-rc-')); await writeFile(join(tmp, 'rclone.conf'), '', { mode: 0o600 }); env.RCLONE_CONFIG = join(tmp, 'rclone.conf'); + const set = (k: string, v: string | number | boolean | undefined | null) => { if (v !== undefined && v !== null && v !== '') env[`${P}${k}`] = String(v); }; + if (t.type === 'sftp') { + set('TYPE', 'sftp'); set('HOST', t.config.host); set('PORT', t.config.port ?? 22); set('USER', t.config.user); + if (t.config.authType === 'key') { set('KEY_PEM', (t.secrets.privateKey ?? '').replace(/\r/g, '').trim().replace(/\n/g, '\\n')); if (t.secrets.keyPassphrase) set('KEY_FILE_PASS', await obscure(rclone, t.secrets.keyPassphrase)); } + else if (t.secrets.password) set('PASS', await obscure(rclone, t.secrets.password)); + const hk = hostKeyOverride ?? t.hostKey; + if (hk) { const f = join(tmp, 'known_hosts'); await writeFile(f, `${hk.trim()}\n`, { mode: 0o600 }); set('KNOWN_HOSTS_FILE', f); } + set('DISABLE_HASHCHECK', 'true'); // auch für reine SFTP-Zugänge ohne Shell + } else if (t.type === 'ftp') { + set('TYPE', 'ftp'); set('HOST', t.config.host); set('PORT', t.config.port ?? 21); set('USER', t.config.user); + if (t.secrets.password) set('PASS', await obscure(rclone, t.secrets.password)); + if (t.config.tls === 'explicit') set('EXPLICIT_TLS', 'true'); else if (t.config.tls === 'implicit') set('TLS', 'true'); + } else if (t.type === 'gdrive') { + set('TYPE', 'drive'); set('SCOPE', t.config.scope ?? 'drive.file'); set('TOKEN', t.secrets.token); set('CLIENT_ID', t.secrets.clientId); set('CLIENT_SECRET', t.secrets.clientSecret); + } + return { name: t.name, remote: `${rn}:${t.path.replace(/^\/+/, '')}`, env, cleanup: async () => { await rm(tmp, { recursive: true, force: true }); } }; +} + +/** Server-Schlüssel eines SFTP-Ziels holen (Erstkontakt: "Trust on first use"; Fingerabdruck wird angezeigt). */ +export async function scanHostKey(host: string, port: number): Promise<{ line: string; fingerprint: string }> { + const out = await exec('ssh-keyscan', ['-T', '10', '-t', 'ed25519,ecdsa,rsa', '-p', String(port), host], { timeoutMs: 20_000 }); + const lines = out.split('\n').filter((l) => l && !l.startsWith('#')); if (!lines.length) throw new Error('Der Server hat keinen Schlüssel geliefert (Adresse/Port prüfen).'); + const pick = lines.find((l) => l.includes('ssh-ed25519')) ?? lines.find((l) => l.includes('ecdsa')) ?? lines[0]!; + const fp = (await exec('ssh-keygen', ['-lf', '-'], { input: pick + '\n', timeoutMs: 10_000 })).trim().split(/\s+/)[1] ?? ''; + return { line: pick, fingerprint: fp }; +} + +/** Verbindungstest: Ordner anlegen, kleine Testdatei schreiben und wieder löschen (belegt echte Schreibrechte). */ +export async function testTarget(t: TargetRow, rclone = 'rclone'): Promise<{ ok: boolean; error?: string; hostKey?: string; fingerprint?: string }> { + let hostKey: string | undefined; let fingerprint: string | undefined; + try { + if (t.type === 'local') { const st = await stat(t.path).catch(() => null); if (!st?.isDirectory()) throw new Error('Der Ordner existiert nicht (bei Netzlaufwerken: ist es eingehängt?)'); } + if (t.type === 'sftp' && !t.hostKey) { const k = await scanHostKey(String(t.config.host), Number(t.config.port ?? 22)); hostKey = k.line; fingerprint = k.fingerprint; } + const r = await buildRemote(t, rclone, hostKey); + try { + const flags = ['--timeout', '30s', '--contimeout', '15s', '--retries', '1', '--low-level-retries', '1']; + await exec(rclone, ['mkdir', r.remote, ...flags], { env: r.env, timeoutMs: 60_000 }); + const f = `${r.remote.replace(/\/+$/, '')}/.kc-schreibtest`; + await exec(rclone, ['rcat', f, ...flags], { env: r.env, input: 'ok', timeoutMs: 60_000 }); + await exec(rclone, ['deletefile', f, ...flags], { env: r.env, timeoutMs: 60_000 }); + } finally { await r.cleanup(); } + return { ok: true, hostKey, fingerprint }; + } catch (e) { return { ok: false, error: friendly(e) }; } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..1bd7274 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,43 @@ +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import helmet from '@fastify/helmet'; +import rateLimit from '@fastify/rate-limit'; +import { ZodError } from 'zod'; +import { config } from './core/config.js'; +import { registerAuth } from './core/auth.js'; +import { registerPermissions } from './core/policy.js'; +import { AppError } from './core/errors.js'; +import { InvalidTransition } from '@kc/platform/statemachine'; +import { modules } from './modules/index.js'; + +export async function buildApp() { + const app = Fastify({ + trustProxy: true, + bodyLimit: 1024 * 1024, + logger: { level: config.isProd ? 'info' : 'debug', redact: ['req.headers.cookie', 'req.headers.authorization', 'req.body.password', 'req.body.current', 'req.body.next'] }, + genReqId: () => crypto.randomUUID(), + }); + await app.register(helmet, { contentSecurityPolicy: false }); // CSP setzt das Web-Frontend + await app.register(cookie); + await app.register(rateLimit, { global: true, max: 300, timeWindow: '1 minute' }); + registerAuth(app); + + app.setErrorHandler((err, req, reply) => { + if (err instanceof AppError) return reply.code(err.status).send({ error: { code: err.code, message: err.message }, correlationId: req.correlationId }); + if (err instanceof InvalidTransition) return reply.code(409).send({ error: { code: 'INVALID_TRANSITION', message: 'Diese Aktion ist im aktuellen Zustand nicht möglich. Bitte Ansicht neu laden.' }, correlationId: req.correlationId }); + if (err instanceof ZodError) return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Eingabe ungültig', details: err.issues.map((i) => ({ path: i.path.join('.'), message: i.message })) }, correlationId: req.correlationId }); + const status = (err as { statusCode?: number }).statusCode; + if (status && status < 500) return reply.code(status).send({ error: { code: 'REQUEST_ERROR', message: (err as Error).message }, correlationId: req.correlationId }); + req.log.error({ err, correlationId: req.correlationId }, 'unhandled'); + return reply.code(500).send({ error: { code: 'INTERNAL', message: 'Interner Fehler' }, correlationId: req.correlationId }); + }); + + for (const m of modules) registerPermissions(m.permissions ?? {}); + for (const m of modules) await app.register(async (scope) => { await m.register(scope); }, { prefix: '/v1' }); + return app; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const app = await buildApp(); + await app.listen({ port: config.port, host: '127.0.0.1' }); +} diff --git a/apps/api/test/backup-api.test.ts b/apps/api/test/backup-api.test.ts new file mode 100644 index 0000000..e03e855 --- /dev/null +++ b/apps/api/test/backup-api.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildApp } from '../src/server.js'; +import { query } from '../src/core/db.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkapi-')); const statusFile = join(root, 'status.json'); const reqDir = join(root, 'requests'); +beforeAll(async () => { process.env.BACKUP_STATUS_FILE = statusFile; process.env.BACKUP_REQUEST_DIR = reqDir; process.env.BACKUP_REMOTES = 'nas:kundencenter'; app = await buildApp(); await app.ready(); }); +afterAll(() => { rmSync(root, { recursive: true, force: true }); delete process.env.BACKUP_STATUS_FILE; delete process.env.BACKUP_REQUEST_DIR; delete process.env.BACKUP_REMOTES; }); +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; +} + +describe('Backup-Seite (API)', () => { + it('zeigt Zustand nur Berechtigten, ohne Statusdatei als "noch nie gelaufen"', async () => { + const admin = await staff('bk-adm@x.test', 'admin'); const sup = await staff('bk-sup@x.test', 'support'); + expect((await call(app, sup, 'GET', '/admin/backup')).statusCode).toBe(403); + expect((await app.inject({ method: 'GET', url: '/v1/admin/backup' })).statusCode).toBe(401); + const s0 = (await call(app, admin, 'GET', '/admin/backup')).json(); + expect(s0.lastRun).toBeNull(); expect(s0.stale).toBe(true); expect(s0.hasExternalTarget).toBe(true); expect(s0.remotes).toEqual(['nas:kundencenter']); expect(s0.retention).toEqual({ daily: 14, weekly: 8, monthly: 12 }); + mkdirSync(join(root), { recursive: true }); + writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date().toISOString(), ok: true, file: 'x.age', sizeBytes: 1000, durationMs: 900, targets: [{ name: 'lokal', ok: true }] }, lastRestoreTest: { at: new Date().toISOString(), ok: true, checks: { auditKette: true } }, history: [{ at: new Date().toISOString(), ok: true }] })); + const s1 = (await call(app, admin, 'GET', '/admin/backup')).json(); expect(s1.stale).toBe(false); expect(s1.restoreStale).toBe(false); expect(s1.history).toHaveLength(1); + // veraltet, wenn der letzte Erfolg zu alt ist + writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date(Date.now() - 30 * 3600000).toISOString(), ok: true, targets: [] } })); + expect((await call(app, admin, 'GET', '/admin/backup')).json().stale).toBe(true); + }); + it('legt Anfragen nur für feste Aktionen ab, nur einmal gleichzeitig, mit Audit', async () => { + const admin = await staff('bk-adm2@x.test', 'admin'); const sup = await staff('bk-sup2@x.test', 'support'); + expect((await call(app, sup, 'POST', '/admin/backup/run', { action: 'backup' })).statusCode).toBe(403); + expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'rm -rf /' })).statusCode).toBe(400); // nur feste Aktionen + expect(existsSync(reqDir) ? readdirSyncSafe(reqDir) : []).toEqual([]); + const ok = await call(app, admin, 'POST', '/admin/backup/run', { action: 'backup' }); expect(ok.statusCode).toBe(202); + expect(readdirSyncSafe(reqDir)).toEqual(['backup-run']); + expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_PENDING'); // eine Anfrage nach der anderen + expect((await call(app, admin, 'GET', '/admin/backup')).json().pendingRequests).toEqual(['backup']); + unlinkSync(join(reqDir, 'backup-run')); + writeFileSync(statusFile, JSON.stringify({ running: { action: 'backup', since: new Date().toISOString() } })); + expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_RUNNING'); + writeFileSync(statusFile, JSON.stringify({})); + expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).statusCode).toBe(202); + expect(readdirSyncSafe(reqDir)).toEqual(['backup-restore-test']); + expect((await query("SELECT action FROM audit_events WHERE action LIKE 'backup.request.%' ORDER BY id")).map((r) => r.action)).toEqual(['backup.request.backup', 'backup.request.restore-test']); + }); +}); +import { readdirSync } from 'node:fs'; +function readdirSyncSafe(d: string): string[] { try { return readdirSync(d); } catch { return []; } } diff --git a/apps/api/test/backup-settings.test.ts b/apps/api/test/backup-settings.test.ts new file mode 100644 index 0000000..478c00b --- /dev/null +++ b/apps/api/test/backup-settings.test.ts @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildApp } from '../src/server.js'; +import { one, query } from '../src/core/db.js'; +import { parseName, fileName } from '../src/ops/retention.js'; +import { passwordProblem, setBackupPassword } from '../src/ops/settings.js'; +import { buildRemote, friendly, type TargetRow } from '../src/ops/targets.js'; +import { loadBackupConfig, runBackup, runRestoreTest } from '../src/ops/backup.js'; +import { config } from '../src/core/config.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkset-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const dest = join(root, 'ziel'); const status = join(root, 'status.json'); +beforeAll(async () => { delete process.env.BACKUP_AGE_RECIPIENT; delete process.env.BACKUP_REMOTES; app = await buildApp(); await app.ready(); mkdirSync(envDir); mkdirSync(dest); writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); }); +afterAll(() => rmSync(root, { recursive: true, force: true })); +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; +} +const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }); + +describe('Backup-Passwort', () => { + it('verlangt mehr als 10 Zeichen', () => { + expect(passwordProblem('1234567890')).toMatch(/länger als 10/); // genau 10: zu kurz + expect(passwordProblem('12345678901')).toBeNull(); // 11: ok + expect(passwordProblem('aaaaaaaaaaaa')).toMatch(/wiederholt/); + expect(passwordProblem('abc\ndefghijkl')).toMatch(/Zeilenumbr/); + expect(passwordProblem('x'.repeat(201))).toMatch(/höchstens/); + }); + it('setzt es nur mit Superadmin, Wiederholung und eigenem Anmeldepasswort; speichert es verschlüsselt', async () => { + const admin = await staff('bs-adm@x.test', 'admin'); const sa = await staff('bs-sa@x.test', 'superadmin'); + const body = { password: 'Sehr-langes-Backup-Passwort-1', repeat: 'Sehr-langes-Backup-Passwort-1', currentPassword: 'correct-horse-battery' }; + expect((await call(app, admin, 'PUT', '/admin/backup/password', body)).statusCode).toBe(403); // Admin darf nicht + expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, currentPassword: 'falsch-falsch-falsch' })).json().error.code).toBe('INVALID_CREDENTIALS'); + expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, repeat: 'anderes-Passwort-12' })).json().error.code).toBe('PASSWORD_MISMATCH'); + expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: '1234567890', repeat: '1234567890' })).json().error.code).toBe('WEAK_PASSWORD'); + const s0 = (await call(app, sa, 'GET', '/admin/backup/settings')).json(); expect(s0.encryption).toMatchObject({ mode: 'none', passwordSet: false, minLength: 11 }); + expect((await call(app, sa, 'PUT', '/admin/backup/password', body)).json().version).toBe(1); + expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: 'Noch-ein-anderes-Passwort-2', repeat: 'Noch-ein-anderes-Passwort-2' })).json().version).toBe(2); + const s1 = (await call(app, admin, 'GET', '/admin/backup/settings')).json(); expect(s1.encryption).toMatchObject({ mode: 'password', passwordSet: true, version: 2 }); + expect(JSON.stringify(s1)).not.toContain('Passwort-2'); + const row = await one("SELECT value_enc FROM backup_settings WHERE `key` = 'password'"); expect(row!.value_enc).toMatch(/^v1:/); expect(row!.value_enc).not.toContain('Passwort'); + expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toMatch(/Backup-Passwort-1|Passwort-2/); // nie im Audit + expect((await query("SELECT result FROM audit_events WHERE action = 'backup.password.change' ORDER BY id")).map((r) => r.result)).toEqual(['denied', 'success', 'success']); + }); +}); + +describe('Externe Ziele', () => { + it('legt Ziele an, prüft Eingaben, liefert nie Geheimnisse und speichert sie verschlüsselt', async () => { + const sa = await staff('bt-sa@x.test', 'superadmin'); const admin = await staff('bt-adm@x.test', 'admin'); + const ftp = { type: 'ftp', name: 'NAS-FTP', host: 'nas.example.test', user: 'backup', password: 'geheimes-ftp-passwort', tls: 'explicit', path: 'kundencenter' }; + expect((await call(app, admin, 'POST', '/admin/backup/targets', ftp)).statusCode).toBe(403); + expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: 'localhost' })).json().error.code).toBe('BAD_HOST'); // keine Zugriffe auf den Server selbst + expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: '169.254.169.254' })).json().error.code).toBe('BAD_HOST'); + expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, path: '../../etc' })).json().error.code).toBe('BAD_PATH'); + expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Etc', path: '/etc/cron.d' })).json().error.code).toBe('BAD_PATH'); + expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'sftp', name: 'S', host: 'sftp.example.test', user: 'u', authType: 'key', privateKey: 'kein schlüssel' })).json().error.code).toBe('SECRET_MISSING'); + expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'gdrive', name: 'G', token: 'kein-json-token-abc' })).json().error.code).toBe('BAD_TOKEN'); + const ok = await call(app, sa, 'POST', '/admin/backup/targets', ftp); expect(ok.statusCode).toBe(200); + expect((await call(app, sa, 'POST', '/admin/backup/targets', ftp)).json().error.code).toBe('NAME_EXISTS'); + const list = (await call(app, admin, 'GET', '/admin/backup/settings')).json().targets; // Admin sieht Ziele, aber keine Geheimnisse + expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ name: 'NAS-FTP', type: 'ftp', hasSecret: true, enabled: true }); + expect(JSON.stringify(list)).not.toContain('geheimes-ftp'); expect(JSON.stringify(list)).not.toContain('secrets_enc'); + const raw = await one('SELECT secrets_enc FROM backup_targets'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('geheimes'); + expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('geheimes-ftp'); + // aktivieren/deaktivieren, löschen + const id = list[0].id; expect((await call(app, sa, 'PATCH', `/admin/backup/targets/${id}`, { enabled: false })).statusCode).toBe(200); + expect((await call(app, admin, 'GET', '/admin/backup')).json().hasExternalTarget).toBe(false); // deaktiviert zählt nicht + expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(200); + expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(404); + }); + it('testet Ziele mit echtem Schreibtest und meldet Fehler verständlich', async () => { + const sa = await staff('bt2-sa@x.test', 'superadmin'); + const good = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Laufwerk', path: dest })).json().id; + const t1 = (await call(app, sa, 'POST', `/admin/backup/targets/${good}/test`)).json(); expect(t1.ok).toBe(true); expect(readdirSync(dest)).toEqual([]); // Testdatei wurde wieder gelöscht + const bad = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Fehlt', path: join(root, 'gibt-es-nicht') })).json().id; + const t2 = (await call(app, sa, 'POST', `/admin/backup/targets/${bad}/test`)).json(); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/existiert nicht/); + const s = (await call(app, sa, 'GET', '/admin/backup/settings')).json().targets; expect(s.find((x: any) => x.name === 'Laufwerk').lastTestOk).toBe(true); expect(s.find((x: any) => x.name === 'Fehlt').lastTestOk).toBe(false); + expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'backup.target.test'"))[0]!.n).toBe(2); + await call(app, sa, 'DELETE', `/admin/backup/targets/${bad}`); + }); + it('baut rclone-Umgebung ohne Passwörter im Klartext und mit festem Server-Schlüssel', async () => { + const t: TargetRow = { id: '12345678-aaaa-bbbb-cccc-1234567890ab', name: 'x', type: 'sftp', path: 'kc', enabled: true, hostKey: 'sftp.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc', config: { host: 'sftp.example.test', port: 2222, user: 'bk', authType: 'password' }, secrets: { password: 'klartext-passwort-9' } }; + const r = await buildRemote(t); + const P = 'RCLONE_CONFIG_KCT12345678_'; expect(r.remote).toBe('KCT12345678:kc'); expect(r.env[`${P}TYPE`]).toBe('sftp'); expect(r.env[`${P}PORT`]).toBe('2222'); + expect(r.env[`${P}PASS`]).toBeTruthy(); expect(r.env[`${P}PASS`]).not.toContain('klartext'); // obscured + const kh = r.env[`${P}KNOWN_HOSTS_FILE`]!; expect(readFileSync(kh, 'utf8')).toContain('ssh-ed25519'); expect(r.env.RCLONE_CONFIG).toBeTruthy(); await r.cleanup(); expect(existsSync(kh)).toBe(false); expect(existsSync(r.env.RCLONE_CONFIG!)).toBe(false); + expect(friendly(new Error('ssh: handshake failed: knownhosts: key mismatch'))).toMatch(/Server-Schlüssel/); + expect(friendly(new Error('Permission denied (publickey,password)'))).toMatch(/Anmeldung/); + expect(friendly(new Error('dial tcp: lookup nas.x: no such host'))).toMatch(/nicht gefunden/); + }); +}); + +describe('Backup mit Passwort und Ziel aus der Oberfläche', () => { + it('verschlüsselt mit gpg, lädt zum definierten Ziel, Restore-Test besteht; falsches/geändertes Passwort wird erkannt', async () => { + const sa = await staff('bp-sa@x.test', 'superadmin'); + await query('DELETE FROM backup_targets'); // Ziele früherer Tests entfernen + await setBackupPassword('Mein-Backup-Passwort-2026', null); + await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Ziel-Laufwerk', path: dest }); + const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true); + expect(r.file).toMatch(/\.tar\.gz\.gpg$/); expect(parseName(r.file!)?.ext).toBe('gpg'); expect(fileName(new Date(), 'gpg')).toMatch(/\.gpg$/); + expect(r.targets.map((t) => [t.name.startsWith('lokal') ? 'lokal' : t.name, t.ok])).toEqual([['lokal', true], ['Ziel-Laufwerk', true]]); + expect(readdirSync(dest).sort()).toEqual([r.file, `${r.file}.sha256`].sort()); + // Ohne Passwort unlesbar, mit Standard-gpg lesbar (Notfall-Weg ohne unser Tool) + const file = join(dest, r.file!); const home = mkdtempSync(join(tmpdir(), 'gh-')); + const dec = (pw: string) => execFileSync('gpg', ['--homedir', home, '--batch', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', file], { input: pw, stdio: ['pipe', 'pipe', 'pipe'] }); + expect(() => dec('falsches-Passwort-123')).toThrow(); expect(dec('Mein-Backup-Passwort-2026').length).toBeGreaterThan(500); + const raw = readFileSync(file); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false); + const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true); expect(t.checks).toMatchObject({ auditKette: true, geheimnisseOk: true }); + // Passwort ändern: alte Sicherung ist mit dem neuen Passwort nicht mehr lesbar (klare Meldung), neue Sicherung besteht + await setBackupPassword('Ganz-Neues-Passwort-2027', null); + const old = await runRestoreTest(cfg(), r.file); expect(old.ok).toBe(false); expect(old.error).toMatch(/Passwort passt nicht/); + const r2 = await runBackup(cfg(), new Date(Date.now() + 120000)); expect(r2.ok).toBe(true); + expect((await runRestoreTest(cfg(), r2.file)).ok).toBe(true); + }); + it('sichert nie unverschlüsselt: ohne Passwort und Schlüssel bricht der Lauf ab', async () => { + await query("DELETE FROM backup_settings"); + const r = await runBackup(cfg(), new Date(Date.now() + 240000)); expect(r.ok).toBe(false); expect(r.error).toMatch(/Keine Verschlüsselung/); + }); +}); diff --git a/apps/api/test/backup.test.ts b/apps/api/test/backup.test.ts new file mode 100644 index 0000000..b214873 --- /dev/null +++ b/apps/api/test/backup.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { statSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, appendFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { encrypt } from '../src/core/crypto.js'; +import { audit } from '../src/core/audit.js'; +import { run as dbRun } from '../src/core/db.js'; +import { config } from '../src/core/config.js'; +import { labelFor, parseName, selectDeletions, fileName } from '../src/ops/retention.js'; +import { loadBackupConfig, runBackup, runRestoreTest, readStatus, latestLocal } from '../src/ops/backup.js'; + +const d = (s: string) => new Date(`${s}T03:00:00Z`); +describe('Aufbewahrung', () => { + it('benennt und erkennt Sicherungen', () => { + expect(fileName(new Date('2026-09-26T02:30:05Z'))).toBe('kundencenter-20260926-023005-daily.tar.gz.age'); + expect(labelFor(d('2026-09-27'))).toBe('weekly'); // Sonntag + expect(labelFor(d('2026-10-01'))).toBe('monthly'); // Monatserster hat Vorrang vor Wochentag + expect(labelFor(d('2026-09-30'))).toBe('daily'); + expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age')?.label).toBe('daily'); + expect(parseName('fremd.txt')).toBeNull(); + expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age.sha256')).toBeNull(); + }); + it('löscht nach Alter je Kategorie, nie Fremdes, nie unter den Mindestbestand', () => { + const mk = (s: string) => fileName(d(s)); const now = d('2026-09-30'); const keep = { daily: 14, weekly: 8, monthly: 12 }; + const names = [mk('2026-09-29'), mk('2026-09-10'), mk('2026-08-02'), mk('2026-08-01'), mk('2025-08-01'), mk('2025-10-01'), 'notizen.txt']; + const del = selectDeletions(names, now, keep); + expect(del).toContain(mk('2026-09-10')); // daily, 20 Tage + expect(del).toContain(mk('2025-08-01')); // monthly, älter als 12 Monate + expect(del).not.toContain(mk('2026-09-29')); expect(del).not.toContain(mk('2026-08-01')); expect(del).not.toContain(mk('2025-10-01')); expect(del).not.toContain('notizen.txt'); + // Mindestbestand: auch wenn alles alt ist, bleiben die 3 neuesten + const old = ['2024-01-05', '2024-01-06', '2024-01-08', '2024-01-09'].map(mk); + expect(selectDeletions(old, now, keep)).toEqual([old[0]]); + expect(selectDeletions([], now, keep)).toEqual([]); + }); +}); + +describe('Backup und Wiederherstellungstest (echte Werkzeuge, Testdatenbank)', () => { + const root = mkdtempSync(join(tmpdir(), 'kc-bk-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const remote = join(root, 'remote'); const idFile = join(root, 'age-key.txt'); const status = join(root, 'state', 'status.json'); + let recipient = ''; + beforeAll(async () => { + mkdirSync(envDir); mkdirSync(remote); + writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); writeFileSync(join(envDir, 'db.env'), 'DB_NAME=x\n'); + execFileSync('age-keygen', ['-o', idFile], { stdio: 'pipe' }); recipient = /public key: (age1\w+)/.exec(readFileSync(idFile, 'utf8'))![1]!; + // echte Beispieldaten inkl. verschlüsselter Geheimnisse und Audit-Kette + await dbRun("INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc) VALUES (UUID(), 'mock', 'bk-test', '{}', ?)", [encrypt(JSON.stringify({ token: 'geheim' }))]); + await audit({ actorType: 'system', action: 'test.eins' }); await audit({ actorType: 'system', action: 'test.zwei' }); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: idFile, BACKUP_REMOTES: remote, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }); + + it('erstellt ein verschlüsseltes Backup, lädt es zum Ziel und der Wiederherstellungstest besteht', async () => { + const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true); + const local = readdirSync(dir).filter((n) => parseName(n)); expect(local).toHaveLength(1); + expect(statSync(join(dir, local[0]!)).mode & 0o077).toBe(0); // keine Rechte für Gruppe/Andere + expect(existsSync(join(dir, `${local[0]}.sha256`))).toBe(true); expect(readdirSync(remote).sort()).toEqual([local[0], `${local[0]}.sha256`].sort()); + // Klartext darf nirgends liegen: weder SQL noch Schlüssel in der Datei + const raw = readFileSync(join(dir, local[0]!)); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false); expect(raw.includes(Buffer.from(config.secretKey.toString('base64')))).toBe(false); + expect(raw.subarray(0, 21).toString()).toContain('age-encryption.org'); + expect(readdirSync(dir).some((n) => n.startsWith('.work-'))).toBe(false); // Arbeitsordner aufgeräumt + const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true); + expect(t.checks).toMatchObject({ pruefsumme: true, dumpPruefsumme: true, zeilenzahlen: true, migrationen: true, auditKette: true, geheimnisseOk: true }); expect(Number(t.checks.geheimnisseGeprueft)).toBeGreaterThanOrEqual(1); + const s = await readStatus(cfg()); expect(s.lastRun?.ok).toBe(true); expect(s.lastRestoreTest?.ok).toBe(true); + expect(await latestLocal(cfg())).toBe(local[0]); + }); + it('meldet einen defekten Ziel-Ablauf als Fehler, behält aber das lokale Backup', async () => { + const bad = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_REMOTES: '/proc/nicht/schreibbar', BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }); + const r = await runBackup(bad, new Date(Date.now() + 60000)); expect(r.ok).toBe(false); + expect(r.targets.find((x) => x.name.startsWith('/proc'))?.ok).toBe(false); expect(r.targets[0]!.ok).toBe(true); + expect((await readStatus(cfg())).lastRun?.ok).toBe(false); + // ohne Passwort und ohne age-Schlüssel wird nichts unverschlüsselt gesichert + const none = await runBackup(loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }), new Date(Date.now() + 120000)); + expect(none.ok).toBe(false); expect(none.error).toMatch(/Keine Verschlüsselung/); + }); + it('erkennt manipulierte Sicherungen und fehlende Schlüssel', async () => { + const name = (await latestLocal(cfg()))!; const file = join(dir, name); + const before = readFileSync(file); appendFileSync(file, 'x'); + const t1 = await runRestoreTest(cfg(), name); expect(t1.ok).toBe(false); expect(t1.error).toMatch(/Prüfsumme/); + writeFileSync(file, before); + const other = join(root, 'other.txt'); execFileSync('age-keygen', ['-o', other], { stdio: 'pipe' }); + const wrong = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: other, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }); + const t2 = await runRestoreTest(wrong, name); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/age/); + const none = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }); + expect((await runRestoreTest(none, name)).error).toMatch(/Schlüssel/); + expect((await runRestoreTest(cfg(), name)).ok).toBe(true); // Original wieder heil -> Test besteht + }); +}); diff --git a/apps/api/test/connectors.test.ts b/apps/api/test/connectors.test.ts new file mode 100644 index 0000000..7a9f5ba --- /dev/null +++ b/apps/api/test/connectors.test.ts @@ -0,0 +1,120 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { one, query, run } from '../src/core/db.js'; +import { syncInstance } from '@kc/connectors'; +import { resetMock } from '@kc/connector-mock'; +import { runOnce } from '../../worker/src/jobs.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; +beforeAll(async () => { app = await buildApp(); await app.ready(); }); + +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); + const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); + return client; +} +async function customer(admin: any, name: string, mail: string) { + const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json(); + await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } }); + return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client }; +} +const drain = async () => { for (let i = 0; i < 20 && (await runOnce(() => undefined)); i++); }; + +describe('Connectoren, Ressourcen und Aufträge', () => { + it('Ende-zu-Ende: Verbindung, Abgleich, Zuweisung, Rechte, idempotenter Auftrag, Ausfall mit letztem Stand', async () => { + resetMock(); + const admin = await staff('adm@conn.test', 'admin'); const sup = await staff('sa@conn.test', 'superadmin'); + const A = await customer(admin, 'Firma A', 'a@conn.test'); const B = await customer(admin, 'Firma B', 'b@conn.test'); + + // Nur Superadmin darf Verbindungen (mit Geheimnissen) anlegen + const body = { connector: 'mock', name: 'Mock-Test', values: { instance: 'e2e' } }; + expect((await call(app, admin, 'POST', '/admin/connectors', body)).statusCode).toBe(403); + const created = (await call(app, sup, 'POST', '/admin/connectors', body)).json(); + const list = (await call(app, sup, 'GET', '/admin/connectors')).json(); + expect(list[0].hasSecrets).toBe(false); expect(JSON.stringify(list)).not.toMatch(/secrets_enc|password/); + + // Abgleich über Worker-Job (angelegt beim Erstellen) + await drain(); + const inst = (await call(app, sup, 'GET', '/admin/connectors')).json()[0]; + expect(inst.health).toBe('ok'); expect(inst.resources).toBe(3); + const all = (await call(app, admin, 'GET', '/resources')).json(); + expect(all).toHaveLength(3); + + // Nicht zugewiesene Ressourcen sind für Kunden unsichtbar + expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(0); + const res = all.find((r: any) => r.name.includes('Pro')); + expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404); + + // Zuweisung an A: A sieht sie, B nicht + expect((await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org })).statusCode).toBe(200); + expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(1); + expect((await call(app, B.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404); + expect((await call(app, B.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).statusCode).toBe(404); + + // Aktionen: standardmäßig keine für Kunden (Ressourcenregel), Staff sieht alle unterstützten + expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual([]); + expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).json().error.code).toBe('ACTION_NOT_ALLOWED'); + expect((await call(app, admin, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend', 'extend']); + await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { customerActions: ['suspend', 'unsuspend'] }); + expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend']); + expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'extend', days: 30 })).statusCode).toBe(403); + + // Idempotenz: gleicher Schlüssel => ein Auftrag + const hdr = { cookie: A.client.cookie, 'x-csrf-token': A.client.csrf, 'idempotency-key': 'klick-0001-abcdef' }; + const r1 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr }); + const r2 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr }); + expect(r1.statusCode).toBe(202); expect(r2.json().jobId).toBe(r1.json().jobId); expect(r2.json().duplicate).toBe(true); + expect((await query("SELECT id FROM jobs WHERE type='connector.execute'")).length).toBe(1); + expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('scheduled'); + expect((await call(app, B.client, 'GET', `/jobs/${r1.json().jobId}`)).statusCode).toBe(404); + + // Worker führt aus; Zustand + Audit + await drain(); + expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('succeeded'); + expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().state).toBe('suspended'); + const au = await one("SELECT actor_id, connector, result FROM audit_events WHERE action='resource.suspend' ORDER BY id DESC LIMIT 1"); + expect(au!.connector).toBe('mock'); expect(au!.result).toBe('success'); + + // Providerausfall: letzter Stand bleibt sichtbar, mit Hinweis + await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'true' } }); + await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain(); + const down = (await call(app, sup, 'GET', '/admin/connectors')).json()[0]; + expect(down.health).toBe('down'); expect(down.lastOkAt).toBeTruthy(); + const stale = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json(); + expect(stale.stale).toBe(true); expect(stale.state).toBe('suspended'); expect(stale.staleReason).toMatch(/nicht erreichbar/); + expect(stale.allowedActions).toEqual([]); // keine Aktionen während des Ausfalls + expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.down'"))!.n).toBe(1); + + // Aktion bei Ausfall: wird wiederholt (retrying), nicht verloren – nach Erholung erfolgreich + await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'false' } }); + await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain(); + expect((await call(app, sup, 'GET', '/admin/connectors')).json()[0].health).toBe('ok'); + expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.recovered'"))!.n).toBe(1); + }); + + it('wiederholt fehlgeschlagene Aufträge begrenzt, destruktive nie', async () => { + const inst = await one("SELECT id FROM connector_instances LIMIT 1"); + await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]); + const res = await one('SELECT id FROM resources LIMIT 1'); + const mk = async (payload: object, max = 2) => { const id = crypto.randomUUID(); await run('INSERT INTO jobs (id,type,payload,max_attempts) VALUES (?,?,?,?)', [id, 'connector.execute', JSON.stringify({ resourceId: res!.id, actorUserId: null, ...payload }), max]); return id; }; + const retry = await mk({ action: 'suspend' }); + await run("UPDATE jobs SET status='succeeded' WHERE type='connector.execute' AND id <> ?", [retry]); + await runOnce(() => undefined); + expect((await one('SELECT status, attempts, last_error FROM jobs WHERE id = ?', [retry]))).toMatchObject({ status: 'retrying', attempts: 1 }); + await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE id = ?", [retry]); + await runOnce(() => undefined); + expect((await one('SELECT status FROM jobs WHERE id = ?', [retry]))!.status).toBe('needs_review'); // Limit erreicht -> manuelle Prüfung + const term = await mk({ action: 'terminate', destructive: true }, 5); + await runOnce(() => undefined); + const t = await one('SELECT status, attempts FROM jobs WHERE id = ?', [term]); + expect(t).toMatchObject({ status: 'needs_review', attempts: 1 }); // nicht automatisch wiederholt + // Manuelle Wiederholung: normaler Auftrag ja, destruktiver nein + const adm = await staff('adm2@conn.test', 'admin'); + expect((await call(app, adm, 'POST', `/admin/jobs/${retry}/retry`)).statusCode).toBe(200); + expect((await call(app, adm, 'POST', `/admin/jobs/${term}/retry`)).json().error.code).toBe('DESTRUCTIVE'); + }); +}); diff --git a/apps/api/test/core.test.ts b/apps/api/test/core.test.ts new file mode 100644 index 0000000..d1f493f --- /dev/null +++ b/apps/api/test/core.test.ts @@ -0,0 +1,218 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { one, query, run } from '../src/core/db.js'; +import { verifyAuditChain } from '../src/core/audit.js'; +import { call, code, login, makeUser, nextCode } from './helpers.js'; + +let app: FastifyInstance; +beforeAll(async () => { app = await buildApp(); await app.ready(); }); + +async function staffWithMfa(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); + const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + const conf = await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) }); + expect(conf.statusCode).toBe(200); + expect(conf.json().recoveryCodes).toHaveLength(10); + return { client, secret: setup.secret as string }; +} + +describe('Login & Sitzungen', () => { + it('lehnt falsches Passwort generisch ab und protokolliert', async () => { + await makeUser({ email: 'a@example.test' }); + const r = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'a@example.test', password: 'falsch-falsch-falsch' } }); + expect(r.statusCode).toBe(401); + const r2 = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'unbekannt@example.test', password: 'falsch-falsch-falsch' } }); + expect(r2.json().error.code).toBe(r.json().error.code); + expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='auth.login' AND result='denied'"))!.n).toBeGreaterThanOrEqual(2); + }); + it('sperrt das Konto nach 5 Fehlversuchen', async () => { + await makeUser({ email: 'lock@example.test' }); + for (let i = 0; i < 5; i++) await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'falsch-falsch-falsch' } }); + const ok = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'correct-horse-battery' } }); + expect(ok.statusCode).toBe(401); + }); + it('verlangt CSRF-Token bei schreibenden Requests', async () => { + await makeUser({ email: 'csrf@example.test' }); + const { client } = await login(app, 'csrf@example.test'); + const r = await app.inject({ method: 'POST', url: '/v1/auth/logout', headers: { cookie: client.cookie } }); + expect(r.statusCode).toBe(403); + expect(r.json().error.code).toBe('BAD_CSRF'); + }); + it('kann Sitzungen widerrufen', async () => { + await makeUser({ email: 's@example.test' }); + const a = (await login(app, 's@example.test')).client; + const b = (await login(app, 's@example.test')).client; + const list = (await call(app, a, 'GET', '/auth/sessions')).json(); + expect(list).toHaveLength(2); + const other = list.find((s: any) => !s.current); + expect((await call(app, a, 'DELETE', `/auth/sessions/${other.id}`)).statusCode).toBe(200); + expect((await call(app, b, 'GET', '/auth/me')).statusCode).toBe(401); + }); +}); + +describe('2FA', () => { + it('erzwingt 2FA-Einrichtung für Staff und TOTP beim Login', async () => { + await makeUser({ email: 'staff1@example.test', kind: 'staff', staffRole: 'admin' }); + const { client } = await login(app, 'staff1@example.test'); + expect((await call(app, client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_ENROLL_REQUIRED'); + const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + expect((await call(app, client, 'POST', '/auth/mfa/confirm', { code: '000000' })).statusCode).toBe(400); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) }); + expect((await call(app, client, 'GET', '/admin/customers')).statusCode).toBe(200); + // neuer Login: Passwort allein reicht nicht + const l2 = await login(app, 'staff1@example.test'); + expect(l2.res.json().status).toBe('mfa_required'); + expect((await call(app, l2.client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_REQUIRED'); + expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: '123456' })).statusCode).toBe(401); + expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: nextCode(setup.secret) })).statusCode).toBe(200); + expect((await call(app, l2.client, 'GET', '/admin/customers')).statusCode).toBe(200); + }); + it('speichert das TOTP-Secret nur verschlüsselt', async () => { + const r = await one('SELECT secret_enc FROM mfa_totp LIMIT 1'); + expect(r!.secret_enc).toMatch(/^v1:/); + }); +}); + +describe('Kundenanlage & Mandantentrennung', () => { + it('legt Kunden an, lädt Owner ein, und trennt Mandanten strikt', async () => { + const { client: admin } = await staffWithMfa('admin@example.test', 'admin'); + const mk = async (name: string, mail: string) => (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name }, billing: { city: 'Berlin' } })).json(); + const A = await mk('Firma A', 'owner-a@example.test'); + const B = await mk('Firma B', 'owner-b@example.test'); + expect(A.customerNumber).toMatch(/^K-\d+$/); + expect(A.customerNumber).not.toBe(B.customerNumber); + expect(A.inviteLink).toContain('/einladung?token='); + // Einladung annehmen + for (const [inv, pw] of [[A.inviteLink, 'passwort-owner-a1'], [B.inviteLink, 'passwort-owner-b1']] as const) { + const token = new URL(inv).searchParams.get('token'); + expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: pw, repeat: pw } })).statusCode).toBe(200); + } + const ownerA = (await login(app, 'owner-a@example.test', 'passwort-owner-a1')).client; + expect((await call(app, ownerA, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200); + expect((await call(app, ownerA, 'GET', `/orgs/${B.id}`)).statusCode).toBe(404); // fremde Org + expect((await call(app, ownerA, 'GET', `/orgs/${B.id}/members`)).statusCode).toBe(404); + expect((await call(app, ownerA, 'POST', `/orgs/${B.id}/invitations`, { email: 'x@example.test', name: 'X', role: 'member' })).statusCode).toBe(404); + expect((await call(app, ownerA, 'GET', '/admin/customers')).statusCode).toBe(403); // keine Staff-Rechte + expect((await call(app, ownerA, 'GET', '/admin/users')).statusCode).toBe(403); + // Einladung nur einmal nutzbar + const token = new URL(A.inviteLink).searchParams.get('token'); + expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: 'anderes-passwort-12', repeat: 'anderes-passwort-12' } })).statusCode).toBe(400); + // Mitglied einladen; Member darf nicht einladen + const inv = (await call(app, ownerA, 'POST', `/orgs/${A.id}/invitations`, { email: 'm@example.test', name: 'M', role: 'member' })).json(); + await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } }); + const member = (await login(app, 'm@example.test', 'member-passwort-1')).client; + expect((await call(app, member, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200); + expect((await call(app, member, 'POST', `/orgs/${A.id}/invitations`, { email: 'y@example.test', name: 'Y', role: 'member' })).statusCode).toBe(403); + // Discord-Job wurde idempotent eingereiht, ohne personenbezogene Daten + const jobs = await query("SELECT payload FROM jobs WHERE type='discord.notify' AND idempotency_key IN (?, ?)", [`customer.created:${A.id}`, `customer.created:${B.id}`]); + expect(jobs).toHaveLength(2); + expect(JSON.stringify(jobs)).not.toContain('example.test'); + }); + it('verhindert doppelte E-Mail bei Kundenanlage', async () => { + const { client: admin } = await staffWithMfa('admin2@example.test', 'admin'); + const r = await call(app, admin, 'POST', '/admin/customers', { type: 'business', name: 'Dup', owner: { email: 'owner-a@example.test', name: 'Dup' } }); + expect(r.statusCode).toBe(409); + }); +}); + +describe('Rechte', () => { + it('support darf lesen, aber nicht Kunden anlegen; admin darf keine Admins anlegen', async () => { + const { client: sup } = await staffWithMfa('support@example.test', 'support'); + expect((await call(app, sup, 'GET', '/admin/customers')).statusCode).toBe(200); + expect((await call(app, sup, 'POST', '/admin/customers', { type: 'business', name: 'N', owner: { email: 'n@example.test', name: 'N' } })).statusCode).toBe(403); + expect((await call(app, sup, 'GET', '/admin/audit')).statusCode).toBe(403); + const { client: adm } = await staffWithMfa('admin3@example.test', 'admin'); + expect((await call(app, adm, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).json().error.code).toBe('PRIVILEGED_ONLY'); + expect((await call(app, adm, 'POST', '/admin/users', { email: 'newsup@example.test', name: 'N', staffRole: 'support' })).statusCode).toBe(200); + const { client: sa } = await staffWithMfa('super@example.test', 'superadmin'); + expect((await call(app, sa, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).statusCode).toBe(200); + }); +}); + +describe('Audit', () => { + it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt Manipulation', async () => { + expect((await verifyAuditChain()).brokenAt).toBeNull(); + const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events')); + expect(dump).not.toMatch(/passwort-owner|correct-horse/); + const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3'); + await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]); + expect((await verifyAuditChain()).brokenAt).toBe(first!.id); + }); +}); + +describe('Passwort-Wiederholung und 2FA zurücksetzen', () => { + it('verlangt die Wiederholung des neuen Passworts', async () => { + await makeUser({ email: 'pw@example.test' }); + const { client } = await login(app, 'pw@example.test'); + const bad = await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-124' }); + expect(bad.statusCode).toBe(400); expect(bad.json().error.code).toBe('PASSWORD_MISMATCH'); + expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123' })).statusCode).toBe(400); // repeat fehlt + expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-123' })).statusCode).toBe(200); + expect((await login(app, 'pw@example.test', 'neues-passwort-123')).res.statusCode).toBe(200); + }); + it('erlaubt Kunden, 2FA zu entfernen und mit neuem Gerät neu einzurichten', async () => { + await makeUser({ email: 'phone@example.test' }); + const { client } = await login(app, 'phone@example.test'); + const s1 = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s1.secret) }); + expect((await call(app, client, 'POST', '/auth/mfa/setup')).json().error.code).toBe('MFA_ALREADY_ENABLED'); + // ohne korrektes Passwort / Code nicht möglich + expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'falsch-falsch-falsch', code: nextCode(s1.secret) })).statusCode).toBe(403); + expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: '000000' })).statusCode).toBe(403); + expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: nextCode(s1.secret) })).statusCode).toBe(200); + expect((await one('SELECT COUNT(*) n FROM mfa_totp m JOIN users u ON u.id = m.user_id WHERE u.email = ?', ['phone@example.test']))!.n).toBe(0); + // Login ohne 2FA, danach neu einrichten mit neuem Secret + const l2 = await login(app, 'phone@example.test'); expect(l2.res.json().status).toBe('ok'); + const s2 = (await call(app, l2.client, 'POST', '/auth/mfa/setup')).json(); + expect(s2.secret).not.toBe(s1.secret); + expect((await call(app, l2.client, 'POST', '/auth/mfa/confirm', { code: code(s2.secret) })).statusCode).toBe(200); + expect((await login(app, 'phone@example.test')).res.json().status).toBe('mfa_required'); + }); + it('erlaubt Support-Reset der 2FA für Kunden, für Mitarbeiter nur Superadmin', async () => { + const { client: adm } = await staffWithMfa('resetadm@example.test', 'admin'); + const cust = await makeUser({ email: 'lost@example.test' }); + const cl = (await login(app, 'lost@example.test')).client; + const st = (await call(app, cl, 'POST', '/auth/mfa/setup')).json(); await call(app, cl, 'POST', '/auth/mfa/confirm', { code: code(st.secret) }); + expect((await call(app, adm, 'POST', `/admin/users/${cust}/mfa-reset`)).statusCode).toBe(200); + expect((await call(app, cl, 'GET', '/auth/me')).statusCode).toBe(401); // Sitzungen beendet + expect((await login(app, 'lost@example.test')).res.json().status).toBe('ok'); + const staffId = await makeUser({ email: 'st@example.test', kind: 'staff', staffRole: 'support' }); + expect((await call(app, adm, 'POST', `/admin/users/${staffId}/mfa-reset`)).json().error.code).toBe('PRIVILEGED_ONLY'); + const { client: sa } = await staffWithMfa('resetsa@example.test', 'superadmin'); + expect((await call(app, sa, 'POST', `/admin/users/${staffId}/mfa-reset`)).statusCode).toBe(200); + expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='user.mfa.reset'"))!.n).toBe(2); + }); +}); + +describe('Privat- und Geschäftskunden', () => { + it('erzwingt die Regeln je Kundenart und erlaubt Filter', async () => { + const { client: adm } = await staffWithMfa('type-adm@example.test', 'admin'); + const mk = (b: object) => call(app, adm, 'POST', '/admin/customers', b); + // Typ ist Pflicht + expect((await mk({ name: 'X', owner: { email: 'x1@example.test' } })).statusCode).toBe(400); + // Privatkunde: keine Firma / USt-IdNr. + expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { company: 'Firma GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY'); + expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { vatId: 'DE123456789' } })).json().error.code).toBe('PRIVATE_NO_COMPANY'); + const priv = (await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p1@example.test' }, billing: { street: 'Weg 1', zip: '10115', city: 'Berlin' } })).json(); + expect(priv.customerNumber).toMatch(/^K-/); + // Ansprechpartner-Name = Kunde (Owner-Name entfällt) + expect((await one("SELECT name FROM users WHERE email = 'p1@example.test'"))!.name).toBe('Erika Muster'); + // Geschäftskunde: ungültige USt-IdNr. abgelehnt, gültige normalisiert; Firma = Name + expect((await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b0@example.test', name: 'Max' }, billing: { vatId: '123' } })).json().error.code).toBe('INVALID_VAT_ID'); + const biz = (await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b1@example.test', name: 'Max Muster' }, billing: { vatId: 'de 123.456.789' } })).json(); + const d = (await call(app, adm, 'GET', `/admin/customers/${biz.id}`)).json(); + expect(d.customerType).toBe('business'); expect(d.billing.vatId).toBe('DE123456789'); expect(d.billing.company).toBe('Muster GmbH'); + // Filter + const onlyPriv = (await call(app, adm, 'GET', '/admin/customers?type=private')).json(); + expect(onlyPriv.every((c: any) => c.customerType === 'private')).toBe(true); + expect(onlyPriv.some((c: any) => c.id === priv.id)).toBe(true); + // Wechsel Geschäft -> Privat nur, wenn Firma/USt-IdNr. entfernt werden + expect((await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private' })).json().error.code).toBe('PRIVATE_NO_COMPANY'); + const sw = await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private', billing: { company: '', vatId: '' } }); + expect(sw.statusCode).toBe(200); expect(sw.json().customerType).toBe('private'); expect(sw.json().billing.vatId).toBeNull(); + // Privatkunde kann keine Firma bekommen + expect((await call(app, adm, 'PATCH', `/admin/customers/${priv.id}`, { billing: { company: 'Neu GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY'); + }); +}); diff --git a/apps/api/test/domains.test.ts b/apps/api/test/domains.test.ts new file mode 100644 index 0000000..0dd4fda --- /dev/null +++ b/apps/api/test/domains.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { breakdown, normalizeDomain, parsePriceList, roundPrice, sellPrice, splitDomain } from '../src/modules/domains/logic.js'; +import { call, code, login, makeUser } from './helpers.js'; + +const PASTE = `Domaintyp\tLaufzeit\t0-49\t50-199\t200-999\t1000+\tSetup +com\t12\t12,50\t12,00\t11,00\t10,50\t +com.au\t24\t69,00\t69,00\t68,00\t68,00\t +cy\t12\t69,00\t69,00\t69,00\t69,00\t75,00 € +voting\t12\t1.380,00\t1.360,00\t1.340,00\t1.320,00\t +de\t12\t4,50\t4,00\t3,50\t3,00\t +Laufzeit in Monaten, alle Preise zzgl. Ust.`; + +describe('Domain-Logik', () => { + it('liest das eingefügte Preislistenformat (deutsche Zahlen, Setup, Kopf/Fuß)', () => { + const { rows, skipped } = parsePriceList(PASTE); + expect(rows.map((r) => r.tld)).toEqual(['com', 'com.au', 'cy', 'voting', 'de']); + expect(rows[1]).toMatchObject({ termMonths: 24, costs: [6900, 6900, 6800, 6800] }); + expect(rows[2]!.setupCents).toBe(7500); expect(rows[3]!.costs[0]).toBe(138000); expect(rows[0]!.setupCents).toBe(0); + expect(skipped.length).toBe(1); + }); + it('Verkaufspreis = Einkauf + Aufschlag; ohne Aufschlag kein Preis', () => { + const none = { type: null, value: null }; + expect(sellPrice(1250, none, none)).toBeNull(); + expect(sellPrice(1250, none, { type: 'percent', value: 2500 })).toBe(1563); + expect(sellPrice(1250, none, { type: 'fixed', value: 300 })).toBe(1550); + expect(sellPrice(1250, { type: 'fixed', value: 100 }, { type: 'percent', value: 2500 })).toBe(1350); // Endungs-Aufschlag hat Vorrang + expect(sellPrice(400, { type: 'percent', value: 0 }, none)).toBe(400); + }); + it('Liste brutto: Netto wird herausgerechnet, keine zweite USt; Liste netto: USt wird aufgeschlagen', () => { + const m = { type: 'percent' as const, value: 2000 }, none = { type: null, value: null }; + expect(breakdown(1190, none, m, false, 'gross', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 }); + expect(breakdown(1000, none, m, false, 'net', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 }); + }); + it('rundet auf ,50 bzw. ,99', () => { + expect([1200, 1230, 1250, 1251, 1298, 1299, 450, 475, 1500].map(roundPrice)).toEqual([1250, 1250, 1250, 1299, 1299, 1299, 450, 499, 1550]); + expect(sellPrice(1250, { type: null, value: null }, { type: 'percent', value: 2000 }, true)).toBe(1550); // 15,00 → 15,50 + }); + it('normalisiert und zerlegt Domainnamen', () => { + expect(normalizeDomain(' https://www.Beispiel.DE/pfad?x=1 ')).toBe('beispiel.de'); + expect(normalizeDomain('müller.de')).toBe('xn--mller-kva.de'); + expect(normalizeDomain('a b.de')).toBeNull(); expect(normalizeDomain('-x.de')).toBeNull(); + const known = new Set(['uk', 'co.uk', 'de']); + expect(splitDomain('firma.co.uk', known)).toEqual({ label: 'firma', tld: 'co.uk' }); + expect(splitDomain('firma.de', known)).toEqual({ label: 'firma', tld: 'de' }); + }); +}); + +let app: FastifyInstance; const realFetch = globalThis.fetch; +beforeAll(async () => { + vi.stubGlobal('fetch', (async (url: any, init: any) => { + const u = String(url); + if (u.includes('data.iana.org')) return new Response(JSON.stringify({ services: [[['de', 'com', 'net'], ['https://rdap.test/']]] }), { status: 200 }); + if (u.startsWith('https://rdap.test/domain/')) return new Response('{}', { status: u.includes('vergeben') ? 200 : 404 }); + return realFetch(url, init); + }) as typeof fetch); + app = await buildApp(); await app.ready(); +}); +afterAll(() => vi.unstubAllGlobals()); +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email); + const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; +} +describe('Domains: Preisliste, Aufschlag, Prüfung', () => { + it('Import, Aufschlag, Verkaufspreis, Verfügbarkeit; Kunden sehen keinen Einkauf', async () => { + const admin = await staff('dom-admin@example.com', 'admin'); const sup = await staff('dom-support@example.com', 'support'); + const imp = await call(app, admin, 'POST', '/admin/domain-tlds/import', { text: PASTE }); expect(imp.statusCode).toBe(200); expect(imp.json()).toMatchObject({ rows: 5, created: 5 }); + expect((await call(app, sup, 'POST', '/admin/domain-tlds/import', { text: PASTE })).statusCode).toBe(403); + expect((await call(app, sup, 'GET', '/admin/domain-tlds')).statusCode).toBe(200); + + const before = (await call(app, admin, 'GET', '/domains/check?name=frei.com')).json().results[0]; + expect(before).toMatchObject({ domain: 'frei.com', status: 'available', method: 'rdap', offered: true, offer: null }); // noch kein Aufschlag → kein Preis + expect((await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: false, margin: { type: 'percent', value: 2000 } })).statusCode).toBe(200); + expect((await call(app, admin, 'PATCH', '/admin/domain-tlds/de', { margin: { type: 'fixed', value: 500 } })).statusCode).toBe(200); + const list = (await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds; const com = list.find((t: any) => t.tld === 'com'); const de = list.find((t: any) => t.tld === 'de'); + expect(com).toMatchObject({ costGrossCents: 1250, costNetCents: 1050, priceGrossCents: 1500, priceNetCents: 1261, profitNetCents: 211 }); expect(de).toMatchObject({ costGrossCents: 450, priceGrossCents: 950, priceNetCents: 798 }); + await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: true, margin: { type: 'percent', value: 2000 } }); + expect((await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds.find((t: any) => t.tld === 'com')).toMatchObject({ priceGrossCents: 1550, priceNetCents: 1303, profitNetCents: 253 }); + + await makeUser({ email: 'dom-kunde@example.com' }); const { client: cust } = await login(app, 'dom-kunde@example.com'); + const r = (await call(app, cust, 'GET', '/domains/check?name=vergeben.com')).json().results[0]; + expect(r).toMatchObject({ status: 'registered', offer: { priceGrossCents: 1550, priceNetCents: 1303, termMonths: 12 } }); expect(JSON.stringify(r)).not.toContain('cost'); + const multi = (await call(app, cust, 'GET', '/domains/check?name=firma')).json().results; expect(multi.map((x: any) => x.domain).sort()).toEqual(['firma.com', 'firma.de']); + expect((await call(app, cust, 'GET', '/domains/check?name=a%20b.de')).statusCode).toBe(400); + expect((await call(app, cust, 'GET', '/admin/domain-tlds')).statusCode).toBe(403); + // Aufstellung: Einkauf netto, Verkauf netto/brutto (19 %), Beschaffungsstatus + const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200); + expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400); + const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' }); + expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200); + expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' }); + expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403); + expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter + await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false }); + expect((await call(app, cust, 'GET', '/domains/check?name=frei.com')).json().results[0]).toMatchObject({ offered: false, offer: null }); + }); +}); diff --git a/apps/api/test/global-setup.ts b/apps/api/test/global-setup.ts new file mode 100644 index 0000000..39d8e3a --- /dev/null +++ b/apps/api/test/global-setup.ts @@ -0,0 +1,21 @@ +import mysql from 'mysql2/promise'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import '../src/core/config.js'; + +/** Legt eine frische Testdatenbank an (niemals die produktive) und wendet alle Migrationen an. */ +export default async function setup() { + const base = { host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD! }; + const admin = await mysql.createConnection(base); + await admin.query('DROP DATABASE IF EXISTS kundencenter_test'); + await admin.query('CREATE DATABASE kundencenter_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); + await admin.end(); + const c = await mysql.createConnection({ ...base, database: 'kundencenter_test' }); + await c.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))'); + const dir = join(process.cwd(), '../../migrations'); + for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { + await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]); + for (const st of readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) await c.query(st); + } + await c.end(); +} diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts new file mode 100644 index 0000000..c280c99 --- /dev/null +++ b/apps/api/test/helpers.ts @@ -0,0 +1,23 @@ +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { hash } from '@node-rs/argon2'; +import * as OTPAuth from 'otpauth'; +import { run } from '../src/core/db.js'; + +export interface Client { cookie: string; csrf: string } +export async function makeUser(o: { email: string; kind?: 'customer' | 'staff'; staffRole?: string; password?: string }) { + const id = randomUUID(); + await run("INSERT INTO users (id,email,name,password_hash,kind,staff_role,status) VALUES (?,?,?,?,?,?, 'active')", [id, o.email, o.email, await hash(o.password ?? 'correct-horse-battery'), o.kind ?? 'customer', o.staffRole ?? null]); + return id; +} +export async function login(app: FastifyInstance, email: string, password = 'correct-horse-battery'): Promise<{ res: any; client: Client }> { + const res = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email, password } }); + const cookie = (res.cookies[0] ? `${res.cookies[0].name}=${res.cookies[0].value}` : ''); + const body = res.json(); + return { res, client: { cookie, csrf: body.csrf } }; +} +export const call = (app: FastifyInstance, c: Client, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', url: string, payload?: unknown) => + app.inject({ method, url: `/v1${url}`, payload: payload as never, headers: { cookie: c.cookie, 'x-csrf-token': c.csrf } }); +export const code = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate(); +/** Nächster gültiger Code (für zweiten Schritt innerhalb desselben Tests, da Wiederverwendung verboten ist). */ +export const nextCode = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate({ timestamp: Date.now() + 30000 }); diff --git a/apps/api/test/keyhelp.test.ts b/apps/api/test/keyhelp.test.ts new file mode 100644 index 0000000..c22bf10 --- /dev/null +++ b/apps/api/test/keyhelp.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { one, query, run } from '../src/core/db.js'; +import { runOnce } from '../../worker/src/jobs.js'; +import { createFake, type Fake } from '../../../packages/connector-keyhelp/test/fake.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; let fake: Fake; const realFetch = globalThis.fetch; +beforeAll(async () => { + fake = createFake('kh-test-key'); + vi.stubGlobal('fetch', ((url: any, init: any) => (String(url).startsWith('https://kh.test') ? fake.fetch(url, init) : realFetch(url, init))) as typeof fetch); + app = await buildApp(); await app.ready(); +}); +afterAll(() => vi.unstubAllGlobals()); +const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } }; +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; +} +const accept = async (link: string, pw: string) => app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(link).searchParams.get('token'), password: pw, repeat: pw } }); + +describe('KeyHelp: Verbindung, Kundenübernahme, neue Tarife, Hosting-Funktionen', () => { + it('Ende-zu-Ende gegen einen nach der Definition nachgebauten KeyHelp-Server', async () => { + const admin = await staff('kh-adm@x.test', 'admin'); const sa = await staff('kh-sa@x.test', 'superadmin'); const sup = await staff('kh-sup@x.test', 'support'); + // ---- Verbindung: nur Superadmin, Geheimnis verschlüsselt, Abgleich liest Konten mit Nutzung + const types = (await call(app, sa, 'GET', '/admin/connector-types')).json(); expect(types.find((t: any) => t.key === 'keyhelp').fields.map((f: any) => f.name)).toEqual(['baseUrl', 'apiKey', 'verifyTls', 'tlsFingerprint']); + const conn = await call(app, sa, 'POST', '/admin/connectors', { connector: 'keyhelp', name: 'KeyHelp-Test', values: { baseUrl: 'https://kh.test', apiKey: 'kh-test-key' } }); expect(conn.statusCode).toBe(200); + const raw = await one('SELECT secrets_enc FROM connector_instances'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('kh-test-key'); + await drain(); + const inst = (await call(app, sa, 'GET', '/admin/connectors')).json()[0]; expect(inst.health).toBe('ok'); expect(inst.resources).toBe(2); + expect(inst.capabilities).toEqual(expect.arrayContaining(['customers.list', 'catalog.write', 'children.read', 'children.write', 'sso.login', 'lifecycle.create', 'plan.change'])); + const res = (await call(app, admin, 'GET', '/resources')).json(); expect(res.map((r: any) => r.state).sort()).toEqual(['active', 'suspended']); + const alphaRes = res.find((r: any) => r.name.includes('alpha')); const det = (await call(app, admin, 'GET', `/resources/${alphaRes.id}`)).json(); + expect(det.usage).toMatchObject({ domains: 2 }); expect(det.limits).toMatchObject({ disk_space: 10737418240 }); expect(det.details.plan).toBe('Starter'); + // ---- neuen Tarif anlegen + Produkt definieren + const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id; + const planBody = { plan: { name: 'Neu Web 20', limits: { diskSpaceGb: 20, trafficGb: 200, domains: 3, emailAccounts: 10, databases: 3, ftpUsers: 2 }, permissions: { ftp: true, ssh: false } }, + product: { sku: 'HOST-NEU-20', status: 'draft', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'email.manage'], version: { name: 'Webhosting Neu 20', taxRateId: t19, priceBasis: 'gross', recurringCents: 499, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30 } } }; + expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody)).statusCode).toBe(403); + const created = await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody); expect(created.statusCode).toBe(200); + expect(created.json().plan.features).toEqual(expect.arrayContaining(['Speicher: 20 GB', 'Domains: 3', 'FTP'])); + const newPlan = fake.state.plans.find((p) => p.name === 'Neu Web 20')!; expect(newPlan.resources.disk_space).toBe(20 * 1024 ** 3); + const prod = (await call(app, admin, 'GET', `/admin/products/${created.json().productId}`)).json(); expect(prod).toMatchObject({ sku: 'HOST-NEU-20', status: 'draft', externalRef: `plan:${newPlan.id}`, provisioning: { hostingPlanId: newPlan.id } }); + expect((await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, { ...planBody, product: { ...planBody.product, sku: 'HOST-NEU-21' } })).json().error.code).toBe('PLAN_EXISTS'); // Name schon vergeben + // ---- Produkt zum vorhandenen Tarif "Starter" (Übernahme aus Katalog) für die Bestandsverträge + const starter = (await call(app, admin, 'POST', '/admin/products', { sku: 'HOST-STARTER', category: 'hosting', connectorInstanceId: conn.json().id, externalRef: 'plan:1', status: 'active', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'domain.manage', 'email.manage', 'suspend'], + version: { name: 'Starter', taxRateId: t19, priceBasis: 'gross', recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: { hostingPlanId: 1, language: 'de', createSystemDomain: true, sendLoginCredentials: true } } })).json(); + // ---- Kunden aus KeyHelp lesen (Kontaktdaten), Rechte + fake.state.clients[0]!.contact_data = { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha', telephone: '0123', address: 'Weg 1', zip: '10115', city: 'Berlin', country: 'DE', client_id: 'K-77' }; + expect((await call(app, sup, 'GET', `/admin/connectors/${conn.json().id}/customers`)).statusCode).toBe(403); + const cust = (await call(app, admin, 'GET', `/admin/connectors/${conn.json().id}/customers`)).json(); + expect(cust.map((c: any) => c.externalRef)).toEqual(['1', '2']); expect(cust[0]).toMatchObject({ displayName: 'Alpha GmbH', imported: null, resourceKnown: true, planRef: 'plan:1' }); expect(cust[0].suggestedProducts.map((p: any) => p.sku)).toEqual(['HOST-STARTER']); + expect(JSON.stringify(cust)).not.toMatch(/GEHEIM|password/i); + // ---- Übernahme: Kunde, Anschrift, Inhaber (eingeladen, KEINE Mail), Konto zugeordnet, Bestandsvertrag + const imp = (items: any[]) => call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items }); + expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items: [{ externalRef: '1', type: 'business' }] })).statusCode).toBe(403); + const r1 = (await imp([{ externalRef: '1', type: 'business', contract: { productId: starter.id, startedAt: '2025-01-15T00:00:00Z' } }, { externalRef: '2', type: 'private', name: 'Bernd Beta' }])).json().results; + expect(r1.map((r: any) => r.status)).toEqual(['created', 'created']); expect(r1[0].contractNumber).toMatch(/^V-/); expect(r1[1].contractNumber).toBeUndefined(); + const orgA = await one("SELECT o.*, b.company, b.street, b.zip, b.city, b.billing_email FROM organizations o JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?", [r1[0].orgId]); + expect(orgA).toMatchObject({ name: 'Alpha GmbH', customer_type: 'business', company: 'Alpha GmbH', street: 'Weg 1', zip: '10115', city: 'Berlin', billing_email: 'alpha@example.test' }); expect(orgA!.legacy_ref).toContain(`keyhelp:${conn.json().id}:1`); + const ownerA = await one("SELECT u.* FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.org_id = ?", [r1[0].orgId]); expect(ownerA).toMatchObject({ email: 'alpha@example.test', status: 'invited', kind: 'customer' }); + expect((await one('SELECT COUNT(*) n FROM mail_log'))!.n).toBe(0); // es wurde nichts versendet + const ct = (await call(app, admin, 'GET', `/contracts?org=${r1[0].orgId}`)).json()[0]; expect(ct).toMatchObject({ status: 'active', productName: 'Starter' }); expect(ct.price.imported).toBe(true); expect(ct.price.recurring.gross).toBe(999); expect(ct.resourceId).toBe(alphaRes.id); + expect(new Date(ct.termEnd).getTime()).toBeGreaterThan(Date.now()); // Laufzeit bis in die Zukunft fortgeschrieben + const betaRes = (await call(app, admin, 'GET', `/resources?org=${r1[1].orgId}`)).json(); expect(betaRes).toHaveLength(1); + // Wiederholung / Konflikte + expect((await imp([{ externalRef: '1', type: 'business' }])).json().results[0]).toMatchObject({ status: 'skipped', reason: 'Bereits übernommen' }); + expect((await imp([{ externalRef: '999', type: 'business' }])).json().results[0].status).toBe('skipped'); + // Verknüpfen mit vorhandenem Kunden statt neu anlegen + fake.state.clients.push({ id: 3, status: 1, username: 'gamma', email: 'alpha@example.test', is_suspended: false, id_hosting_plan: 1, created_at: '2026-03-01 10:00:00', contact_data: { company: 'Gamma' }, permissions: {} }); fake.state.domains.push({ id: 70, id_user: 3, domain: 'gamma.example.test', status: 1, security: {} }); + await run("UPDATE connector_instances SET last_sync_at = NULL"); await call(app, sa, 'POST', `/admin/connectors/${conn.json().id}/sync`); await drain(); + const dup = (await imp([{ externalRef: '3', type: 'business' }])).json().results[0]; expect(dup.status).toBe('skipped'); expect(dup.reason).toMatch(/bereits einem Benutzer/); // E-Mail vergeben + const lk = (await imp([{ externalRef: '3', type: 'business', linkToOrgId: r1[0].orgId }])).json().results[0]; expect(lk).toMatchObject({ status: 'linked', orgId: r1[0].orgId }); + // ---- Einladung gezielt nachträglich senden + expect((await call(app, sup, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).statusCode).toBe(403); + const inv = (await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json(); expect(inv.mail).toBe('not_configured'); expect(inv.inviteLink).toContain('/einladung?token='); + expect((await accept(inv.inviteLink, 'passwort-alpha-123')).statusCode).toBe(200); + expect((await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json().error.code).toBe('NOT_INVITED'); + const owner = (await login(app, 'alpha@example.test', 'passwort-alpha-123')).client; + // ---- Kunde nutzt sein Hosting: Panel-Login, Unterobjekte lesen (Mandantentrennung), Änderungen als Auftrag + const cres = (await call(app, owner, 'GET', '/resources')).json().find((r: any) => r.name.includes('alpha')); + const d2 = (await call(app, owner, 'GET', `/resources/${cres.id}`)).json(); expect(d2).toMatchObject({ hasChildren: true, canLogin: true }); + const lg = await call(app, owner, 'POST', `/resources/${cres.id}/login`); expect(lg.statusCode).toBe(200); expect(lg.json()).toEqual({ url: 'https://kh.test/login?token=EINMALIG-1', validForSec: 3600 }); expect(lg.headers['cache-control']).toBe('no-store'); + expect((await call(app, owner, 'POST', `/resources/${betaRes[0].id}/login`)).statusCode).toBe(404); // Konto eines anderen Kunden + expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'resource.login'"))[0]!.n).toBe(1); expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('EINMALIG'); + const kinds = (await call(app, owner, 'GET', `/resources/${cres.id}/children`)).json(); expect(kinds.kinds.find((k: any) => k.kind === 'email').canWrite).toBe(true); expect(kinds.kinds.find((k: any) => k.kind === 'database').canWrite).toBe(false); expect(kinds.kinds.find((k: any) => k.kind === 'certificate').canWrite).toBe(false); + const doms = (await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`)).json(); expect(doms.map((d: any) => d.name)).toContain('alpha.example.test'); expect(doms.map((d: any) => d.name)).not.toContain('beta.example.test'); + const impd = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd.statusCode).toBe(200); expect(impd.json().added).toBeGreaterThanOrEqual(1); expect(impd.json().unpriced).toBeGreaterThanOrEqual(1); + const impd2 = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd2.json().added).toBe(0); // zweiter Lauf legt nichts doppelt an + expect((await call(app, owner, 'GET', `/resources/${cres.id}/children/email`)).json().map((e: any) => e.name)).toEqual(['info@alpha.example.test']); + // Schreiben: Passwortregeln, Freigabe je Art, Ausführung im Hintergrund, Passwort nirgends gespeichert + const post = (kind: string, body: object, r = cres.id) => call(app, owner, 'POST', `/resources/${r}/children/${kind}`, body); + expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'kurz' })).json().error.code).toBe('WEAK_PASSWORD'); + expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' } })).json().error.code).toBe('PASSWORD_REQUIRED'); + expect((await post('database', { op: 'create', data: {}, password: 'ein-langes-passwort-1' })).json().error.code).toBe('ACTION_NOT_ALLOWED'); // database.manage nicht freigegeben + expect((await post('certificate', { op: 'delete', id: '60', data: {} })).statusCode).toBe(403); + const ok = await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' }); expect(ok.statusCode).toBe(202); + const jobRow = await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]); expect(JSON.stringify(jobRow!.payload)).not.toContain('sehr-langes'); expect(JSON.stringify(jobRow!.payload)).toContain('secretEnc'); // nur verschlüsselt im Auftrag + await drain(); + expect((await one('SELECT status, last_error, payload FROM jobs WHERE id = ?', [ok.json().jobId]))).toMatchObject({ status: 'succeeded' }); + expect(JSON.stringify((await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]))!.payload)).not.toContain('secretEnc'); // nach Ausführung entfernt + expect(fake.state.emails.map((e) => e.email)).toContain('kontakt@alpha.example.test'); + expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('sehr-langes'); expect(JSON.stringify(await query('SELECT payload FROM jobs'))).not.toContain('sehr-langes'); + // Doppelklick: gleicher Schlüssel = ein Auftrag + const hdr = { cookie: owner.cookie, 'x-csrf-token': owner.csrf, 'idempotency-key': 'mail-doppelt-0001' }; const body = { op: 'create', data: { local: 'zwei', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' }; + const a1 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr }); const a2 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr }); + expect(a2.json().jobId).toBe(a1.json().jobId); expect(a2.json().duplicate).toBe(true); await drain(); expect(fake.state.emails.filter((e) => e.email === 'zwei@alpha.example.test').length).toBe(1); + // Fremdes Objekt löschen: der Auftrag scheitert, nichts wird verändert + const before = JSON.stringify(fake.state.emails); + const del = await post('email', { op: 'delete', id: '31', data: {} }); expect(del.statusCode).toBe(202); await drain(); + expect((await one('SELECT status, last_error FROM jobs WHERE id = ?', [del.json().jobId]))).toMatchObject({ status: 'failed' }); expect(JSON.stringify(fake.state.emails)).toBe(before); + // Eigenes Objekt löschen + const delOwn = await post('email', { op: 'delete', id: String(fake.state.emails.find((e) => e.email === 'zwei@alpha.example.test')!.id), data: {} }); await drain(); + expect(fake.state.emails.some((e) => e.email === 'zwei@alpha.example.test')).toBe(false); expect((await one('SELECT status FROM jobs WHERE id = ?', [delOwn.json().jobId]))!.status).toBe('succeeded'); + // Sperren durch Kunden (freigegeben) läuft über den bestehenden Auftrag + const sus = await call(app, owner, 'POST', `/resources/${cres.id}/actions`, { action: 'suspend' }); expect(sus.statusCode).toBe(202); await drain(); expect(fake.state.clients.find((c) => c.id === 1)!.is_suspended).toBe(true); + // Anbieter-Ausfall: verständlicher Fehler, kein Absturz + fake.opts.failGet503 = 50; const down = await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`); expect(down.statusCode).toBe(502); fake.opts.failGet503 = 0; + }); +}); diff --git a/apps/api/test/orders.test.ts b/apps/api/test/orders.test.ts new file mode 100644 index 0000000..f7e4b8c --- /dev/null +++ b/apps/api/test/orders.test.ts @@ -0,0 +1,268 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { one, query, run } from '../src/core/db.js'; +import { enqueue } from '../src/core/jobs.js'; +import { processContractLifecycle, provisionOrder } from '@kc/connectors'; +import { resetMock } from '@kc/connector-mock'; +import { runOnce } from '../../worker/src/jobs.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; +beforeAll(async () => { app = await buildApp(); await app.ready(); }); +const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } }; +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); + const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); + return client; +} +async function customer(admin: any, type: 'private' | 'business', name: string, mail: string) { + const c = (await call(app, admin, 'POST', '/admin/customers', { type, name, owner: { email: mail, name } })).json(); + await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } }); + return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client }; +} +const version = (over: object = {}) => ({ name: 'Lizenz Pro', taxRateId: '', setupCents: 1000, recurringCents: 4999, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {}, ...over }); + +describe('Produkte, Bestellungen, Verträge', () => { + it('Katalog, Bestellung, Freigabe, Bereitstellung, Snapshot, Kündigung, Mandantentrennung', async () => { + resetMock(); + const admin = await staff('adm@shop.test', 'admin'); const sa = await staff('sa@shop.test', 'superadmin'); const sup = await staff('sup@shop.test', 'support'); + const A = await customer(admin, 'business', 'Firma A', 'a@shop.test'); const B = await customer(admin, 'business', 'Firma B', 'b@shop.test'); const P = await customer(admin, 'private', 'Erika Muster', 'p@shop.test'); + const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Shop', values: { instance: 'shop' } })).json(); + await drain(); + const tax = (await call(app, admin, 'GET', '/admin/tax-rates')).json(); const t19 = tax.find((t: any) => t.rateBp === 1900).id; + expect(tax.map((t: any) => t.rateBp).sort((x: number, y: number) => x - y)).toEqual([0, 700, 1900]); + + // Rechte + Regeln beim Anlegen + const mkBody = (v: object, o: object = {}) => ({ sku: 'LIC-PRO', category: 'license', connectorInstanceId: conn.id, orderableByCustomer: true, requiresApproval: true, customerActions: ['suspend'], status: 'active', version: { ...version({ taxRateId: t19 }), ...v }, ...o }); + expect((await call(app, sup, 'POST', '/admin/products', mkBody({}))).statusCode).toBe(403); + expect((await call(app, admin, 'POST', '/admin/products', mkBody({ billingInterval: 'once', recurringCents: 100, termMonths: 0, renewal: 'none' }))).json().error.code).toBe('BAD_TERMS'); + expect((await call(app, admin, 'POST', '/admin/products', mkBody({ termMonths: 5 }))).json().error.code).toBe('BAD_TERMS'); // 5 kein Vielfaches von 12 + const prod = (await call(app, admin, 'POST', '/admin/products', mkBody({}))).json(); + expect((await call(app, admin, 'POST', '/admin/products', mkBody({}))).json().error.code).toBe('SKU_EXISTS'); + + // Kundenkatalog: Preise serverseitig (Netto/Brutto), nur für eigene Organisation + const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json(); + expect(cat).toHaveLength(1); expect(cat[0].price.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 }); + expect((await call(app, A.client, 'GET', `/catalog?org=${B.org}`)).statusCode).toBe(404); + + // Bestellen: Rabatt verboten, ohne Freigabe nicht ausführbar, fremde Org verboten + const order = (over: object = {}, c = A) => call(app, c.client, 'POST', '/orders', { orgId: c.org, items: [{ productId: prod.id }], ...over }); + expect((await order({ items: [{ productId: prod.id, discountBp: 1000 }] })).statusCode).toBe(403); + expect((await call(app, B.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.id }] })).statusCode).toBe(404); + const o1 = (await order()).json(); expect(o1.status).toBe('pending_approval'); expect(o1.number).toMatch(/^B-/); + expect((await call(app, A.client, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403); + expect((await call(app, sup, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403); + expect((await one("SELECT COUNT(*) n FROM resources WHERE org_id = ?", [A.org]))!.n).toBe(0); + expect((await call(app, B.client, 'GET', `/orders/${o1.id}`)).statusCode).toBe(404); + expect((await call(app, B.client, 'GET', '/orders')).json()).toHaveLength(0); + + // Freigabe -> Bereitstellung über Worker -> Vertrag aktiv, Ressource beim Kunden + expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(200); + expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(409); // nicht doppelt freigeben + await drain(); + const done = (await call(app, A.client, 'GET', `/orders/${o1.id}`)).json(); + expect(done.status).toBe('completed'); + const ct = (await call(app, A.client, 'GET', '/contracts')).json()[0]; + expect(ct.status).toBe('active'); expect(ct.number).toMatch(/^V-/); expect(ct.resourceId).toBeTruthy(); + const months = (new Date(ct.termEnd).getTime() - new Date(ct.startedAt).getTime()) / 86400000; expect(months).toBeGreaterThan(360); expect(months).toBeLessThan(370); + const res = (await call(app, A.client, 'GET', `/resources/${ct.resourceId}`)).json(); + expect(res.state).toBe('active'); expect(res.allowedActions).toEqual(['suspend']); // Produktregel begrenzt Kundenaktionen + expect((await call(app, B.client, 'GET', `/resources/${ct.resourceId}`)).statusCode).toBe(404); + expect((await call(app, B.client, 'GET', `/contracts/${ct.id}`)).statusCode).toBe(404); + + // Bereitstellung ist idempotent: erneuter Lauf erzeugt nichts Neues + const before = (await one('SELECT COUNT(*) n FROM resources'))!.n; + await run("UPDATE orders SET status='provisioning' WHERE id = ?", [o1.id]); + await provisionOrder(o1.id, { id: 'x', correlationId: 'c', attempt: 1, maxAttempts: 5 }); + expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(before); + expect((await one('SELECT status FROM orders WHERE id = ?', [o1.id]))!.status).toBe('completed'); + + // Preis-Snapshot bleibt bei Preisänderung unverändert; neue Bestellung nutzt neue Version + expect((await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, version({ taxRateId: t19, recurringCents: 9999 }))).statusCode).toBe(200); + expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().price.recurring.net).toBe(4999); + const cat2 = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json(); expect(cat2[0].price.recurring.net).toBe(9999); + + // Personal bestellt mit Rabatt: sofort freigegeben, serverseitig berechnet + const o2 = (await call(app, admin, 'POST', '/orders', { orgId: B.org, items: [{ productId: prod.id, discountBp: 1000 }] })).json(); expect(o2.status).toBe('provisioning'); + await drain(); + const o2d = (await call(app, admin, 'GET', `/orders/${o2.id}`)).json(); + expect(o2d.status).toBe('completed'); expect(o2d.items[0].snapshot.recurring).toEqual({ net: 8999, tax: 1710, gross: 10709 }); // 99,99 * 0,9 = 89,991 + expect(o2d.items[0].snapshot.setup.net).toBe(900); + + // Privatkunde: Verbraucherregel (Verlängerung 1 Monat, Frist max. 30 Tage) + const o3 = (await order({}, P)).json(); await call(app, admin, 'POST', `/admin/orders/${o3.id}/approve`); await drain(); + const pc = (await call(app, P.client, 'GET', '/contracts')).json()[0]; + expect(pc.renewalTermMonths).toBe(1); expect(pc.noticeDays).toBe(30); + + // Kündigung: zum Laufzeitende, widerrufbar, Kunde darf nicht sofort beenden + expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`, { immediate: true })).json().error.code).toBe('STAFF_ONLY'); + expect((await call(app, B.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(404); + const cn = (await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).json(); + expect(new Date(cn.cancelEffectiveAt).getTime()).toBe(new Date(ct.termEnd).getTime()); + expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(409); + expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel/revoke`)).statusCode).toBe(200); + await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`); + // Zeitsprung: Kündigung wird wirksam -> Vertrag beendet, Ressource gesperrt (nicht gelöscht) + await run('UPDATE contracts SET cancel_effective_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE) WHERE id = ?', [ct.id]); + const life = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); expect(life.ended).toBe(1); + await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); // idempotent + expect((await query("SELECT id FROM jobs WHERE idempotency_key = ?", [`contract-end:${ct.id}`])).length).toBe(1); + await drain(); + expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().status).toBe('cancelled'); + expect((await call(app, admin, 'GET', `/resources/${ct.resourceId}`)).json().state).toBe('suspended'); + expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action IN ('order.create','order.approve','contract.activate','contract.cancel.request','contract.cancel.effective')"))!.n).toBeGreaterThanOrEqual(8); + }); + + it('automatische Verlängerung ohne Kündigung', async () => { + const c = await one("SELECT id FROM contracts WHERE status = 'active' LIMIT 1"); + await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 DAY) WHERE id = ?', [c!.id]); + const r = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); + expect(r.renewed).toBeGreaterThanOrEqual(1); + const after = await one('SELECT term_end, status FROM contracts WHERE id = ?', [c!.id]); + expect(new Date(after!.term_end).getTime()).toBeGreaterThan(Date.now()); expect(after!.status).toBe('active'); + }); + + it('Fehlerfälle: unklarer Ausgang wird nie automatisch wiederholt; sichere Fehler begrenzt', async () => { + const admin = await staff('adm2@shop.test', 'admin'); const sa = await staff('sa2@shop.test', 'superadmin'); + const C = await customer(admin, 'business', 'Firma C', 'c@shop.test'); + const inst = await one("SELECT id FROM connector_instances LIMIT 1"); + const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id; + const mk = async (sku: string, failCreate: string) => (await call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: inst!.id, status: 'active', version: version({ taxRateId: t19, provisioning: { failCreate } }) })).json(); + const resBefore = (await one('SELECT COUNT(*) n FROM resources'))!.n; + // Timeout: unklar, ob angelegt -> sofort failed, ambiguous, kein Retry + const pT = await mk('FAIL-TIMEOUT', 'timeout'); + const oT = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pT.id }] })).json(); await drain(); + const dT = (await call(app, admin, 'GET', `/orders/${oT.id}`)).json(); + expect(dT.status).toBe('failed'); expect(dT.failureAmbiguous).toBe(true); expect(dT.failureNote).toMatch(/unklar/); + expect((await one("SELECT attempts, status FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oT.id]))).toMatchObject({ attempts: 1, status: 'needs_review' }); + expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, {})).json().error.code).toBe('CONFIRM_REQUIRED'); + expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, { confirmChecked: true })).statusCode).toBe(200); + // Verbindung verweigert: sicher nicht gesendet -> begrenzt wiederholt, dann failed (nicht ambiguous) + const pU = await mk('FAIL-UNREACH', 'unreachable'); + const oU = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pU.id }] })).json(); await drain(); + const dU = (await call(app, admin, 'GET', `/orders/${oU.id}`)).json(); + expect(dU.status).toBe('failed'); expect(dU.failureAmbiguous).toBe(false); + expect((await one("SELECT attempts FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oU.id]))!.attempts).toBe(5); + expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(resBefore); + // Verträge der fehlgeschlagenen Bestellung bleiben ausstehend (nicht aktiv) + expect((await call(app, admin, 'GET', `/contracts?org=${C.org}`)).json().every((c: any) => c.status === 'pending')).toBe(true); + // Zurückziehen einer gescheiterten Bestellung beendet die Verträge + expect((await call(app, admin, 'POST', `/orders/${oU.id}/cancel`)).statusCode).toBe(200); + }); +}); + +describe('Produktübernahme aus Verbindungen', () => { + it('liest Vorlagen des Anbieters, übernimmt sie mit Details und zählt Übernahmen', async () => { + resetMock(); + const admin = await staff('imp-adm@shop.test', 'admin'); const sa = await staff('imp-sa@shop.test', 'superadmin'); const sup = await staff('imp-sup@shop.test', 'support'); + const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Import', values: { instance: 'imp' } })).json(); + await drain(); + const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id; + expect((await call(app, sup, 'GET', `/admin/connectors/${conn.id}/catalog`)).statusCode).toBe(403); + const cat = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json(); + expect(cat.map((c: any) => c.externalRef)).toEqual(['mock-prog-1', 'mock-prog-2']); + expect(cat.every((c: any) => c.importedProducts === 0)).toBe(true); + expect(cat[0].hints[0].label).toBe('5 Benutzer'); + // Übernahme mit selbst definierten Details (zwei Varianten desselben Angebots) + const imp = (sku: string, over: object = {}) => call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: conn.id, externalRef: 'mock-prog-1', orderableByCustomer: true, requiresApproval: false, customerActions: ['suspend'], + version: { name: 'Alpha Jahreslizenz', taxRateId: t19, setupCents: 0, recurringCents: 12000, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: { userLimit: 5 }, ...over } }); + const a1 = await imp('ALPHA-Y'); expect(a1.statusCode).toBe(200); + expect((await imp('ALPHA-M', { name: 'Alpha Monatslizenz', recurringCents: 1200, billingInterval: 'monthly', termMonths: 0, renewalTermMonths: 1 })).statusCode).toBe(200); + const cat2 = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json(); + expect(cat2.find((c: any) => c.externalRef === 'mock-prog-1').importedProducts).toBe(2); + expect(cat2.find((c: any) => c.externalRef === 'mock-prog-2').importedProducts).toBe(0); + const p = (await call(app, admin, 'GET', `/admin/products/${a1.json().id}`)).json(); expect(p.externalRef).toBe('mock-prog-1'); + // Herkunft nur mit Verbindung + expect((await call(app, admin, 'POST', '/admin/products', { sku: 'X-1', category: 'license', externalRef: 'x', version: { name: 'X', taxRateId: t19, billingInterval: 'once', provisioning: {} } })).json().error.code).toBe('BAD_CONNECTOR'); + // Ausfall des Anbieters: verständlicher Fehler statt Absturz + await call(app, sa, 'PATCH', `/admin/connectors/${conn.id}`, { values: { simulateOutage: 'true' } }); + const down = await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`); + expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/); + }); +}); + +describe('Bruttopreise', () => { + it('bestellt zu exakten Endkundenpreisen (Privatkunde 9,99 € brutto) und speichert die Basis im Snapshot', async () => { + const admin = await staff('gross-adm@shop.test', 'admin'); + const P = await customer(admin, 'private', 'Erika Muster', 'gross-p@shop.test'); + const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id; + const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'GROSS-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false, + version: { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} } })).json(); + const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json(); + expect(cat[0].price.recurring).toEqual({ net: 839, tax: 160, gross: 999 }); expect(cat[0].price.basis).toBe('gross'); + const o = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] })).json(); + await drain(); + const d = (await call(app, P.client, 'GET', `/orders/${o.id}`)).json(); + expect(d.status).toBe('completed'); expect(d.items[0].snapshot.recurring.gross).toBe(999); expect(d.items[0].snapshot.basis).toBe('gross'); + // Preisänderung gilt nur für neue Bestellungen; alter Vertrag bleibt 9,99 € + await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', recurringCents: 1299, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} }); + expect((await call(app, P.client, 'GET', '/contracts')).json()[0].price.recurring.gross).toBe(999); + }); +}); + +describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => { + it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => { + resetMock(); + const admin = await staff('ft-adm@shop.test', 'admin'); const sa = await staff('ft-sa@shop.test', 'superadmin'); const sup = await staff('ft-sup@shop.test', 'support'); + const P = await customer(admin, 'private', 'Erika Muster', 'ft-p@shop.test'); + const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-FT', values: { instance: 'ft' } })).json(); await drain(); + + expect((await call(app, sup, 'GET', '/admin/product-bundles')).statusCode).toBe(403); + const bundles = (await call(app, admin, 'GET', '/admin/product-bundles')).json(); + const ft = bundles.find((b: any) => b.key === 'familytool-vorlage'); expect(ft.products.map((p: any) => p.sku)).toEqual(['FT-PREMIUM-M', 'FT-PREMIUM-Y', 'FT-UNLIMITED-M', 'FT-UNLIMITED-Y', 'FT-LIFETIME', 'FT-TRIAL']); + expect(ft.products.find((p: any) => p.sku === 'FT-UNLIMITED-M').recurringCents).toBe(999); + const skus = ft.products.map((p: any) => p.sku); + const imp = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json(); + expect(imp.created).toHaveLength(6); expect(imp.skipped).toHaveLength(0); + // nochmals importieren: alles übersprungen (Artikelnummern existieren), nichts doppelt + const again = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json(); + expect(again.created).toHaveLength(0); expect(again.skipped).toHaveLength(6); + const list = (await call(app, admin, 'GET', '/admin/products')).json(); expect(list.filter((p: any) => p.sku.startsWith('FT-')).every((p: any) => p.status === 'draft')).toBe(true); + const byS = (s: string) => list.find((p: any) => p.sku === s); + expect(byS('FT-PREMIUM-Y').provisioning).toMatchObject({ keyPrefix: 'PREMIUM', durationType: 'YEAR' }); + + // Aktivierungssperre: Anbieter meldet Präfix-Unterstützung nicht -> 400, Entwurf bleibt + await run("UPDATE connector_instances SET capabilities_json = JSON_REMOVE(capabilities_json, '$[11]', '$[10]') WHERE id = ?", [conn.id]); + const caps = (await one('SELECT capabilities_json c FROM connector_instances WHERE id = ?', [conn.id]))!.c; expect(JSON.stringify(caps)).not.toContain('license.key_prefix'); + const blocked = await call(app, admin, 'PATCH', `/admin/products/${byS('FT-PREMIUM-M').id}`, { status: 'active' }); + expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('NEEDS_LICENSE_EXTENSION'); + // Unlimited ohne Präfix ist trotzdem aktivierbar + expect((await call(app, admin, 'PATCH', `/admin/products/${byS('FT-UNLIMITED-M').id}`, { status: 'active' })).statusCode).toBe(200); + // Nach Erweiterung (Abgleich meldet Fähigkeit wieder) sind alle aktivierbar + await call(app, sa, 'POST', `/admin/connectors/${conn.id}/sync`); await drain(); + for (const s of ['FT-PREMIUM-M', 'FT-PREMIUM-Y']) expect((await call(app, admin, 'PATCH', `/admin/products/${byS(s).id}`, { status: 'active' })).statusCode).toBe(200); + + // Privatkunde bestellt Premium monatlich: exakt 2,99 € brutto, Edition PREMIUM, rollierender Monatsvertrag + const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json(); + expect(cat.find((c: any) => c.sku === 'FT-PREMIUM-M').price.recurring).toEqual({ net: 251, tax: 48, gross: 299 }); + const oM = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-PREMIUM-M').id }] })).json(); expect(oM.status).toBe('provisioning'); await drain(); + const cM = (await call(app, P.client, 'GET', '/contracts')).json()[0]; + expect(cM.status).toBe('active'); expect(cM.termEnd).toBeTruthy(); // Verlängerungstakt trotz Mindestlaufzeit 0 + const rM = (await call(app, P.client, 'GET', `/resources/${cM.resourceId}`)).json(); expect(rM.details.edition).toBe('PREMIUM'); + // Kunde und Herkunft wurden an den Anbieter gemeldet + expect(rM.details.context).toMatchObject({ source: 'kundencenter', customerName: 'Erika Muster', customerEmail: 'ft-p@shop.test', contractNumber: cM.number }); + expect(rM.details.context.customerNumber).toMatch(/^K-\d+$/); expect(rM.details.context.orderNumber).toMatch(/^B-\d+$/); + // Vertragsverlängerung zieht die Lizenz mit + const until0 = new Date(rM.validUntil).getTime(); + await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY) WHERE id = ?', [cM.id]); + expect((await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o))).renewed).toBe(1); await drain(); + const after = (await call(app, admin, 'GET', `/resources/${cM.resourceId}`)).json(); + const cAfter = (await call(app, admin, 'GET', `/contracts/${cM.id}`)).json(); + expect(new Date(after.validUntil).toISOString()).toBe(new Date(cAfter.termEnd).toISOString()); expect(new Date(after.validUntil).getTime()).toBeGreaterThan(until0 - 86400000); + // Lifetime: Freigabe nötig, Edition LIFETIME, unbefristet, keine Verlängerung + await call(app, admin, 'PATCH', `/admin/products/${byS('FT-LIFETIME').id}`, { status: 'active' }); + const oL = (await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-LIFETIME').id }] })).json(); await drain(); + const cL = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.id !== cM.id); + expect(cL.termEnd).toBeNull(); expect((await call(app, admin, 'GET', `/resources/${cL.resourceId}`)).json().details.edition).toBe('LIFETIME'); + expect(cL.price.setup.gross).toBe(15900); + // Trial: 14 Tage Gültigkeit, Edition TRIAL + await call(app, admin, 'PATCH', `/admin/products/${byS('FT-TRIAL').id}`, { status: 'active' }); + await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-TRIAL').id }] }); await drain(); + const trial = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.price.sku === 'FT-TRIAL'); + const tr = (await call(app, admin, 'GET', `/resources/${trial.resourceId}`)).json(); expect(tr.details.edition).toBe('TRIAL'); + const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1); + }); +}); diff --git a/apps/api/test/reveal.test.ts b/apps/api/test/reveal.test.ts new file mode 100644 index 0000000..8d2d9dc --- /dev/null +++ b/apps/api/test/reveal.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { one, query, run } from '../src/core/db.js'; +import { resetMock } from '@kc/connector-mock'; +import { runOnce } from '../../worker/src/jobs.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; +beforeAll(async () => { app = await buildApp(); await app.ready(); }); +const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } }; +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); + const { client } = await login(app, email); + const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); + await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); + return client; +} +async function customer(admin: any, name: string, mail: string) { + const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json(); + await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } }); + return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client }; +} + +describe('Schlüssel auf Abruf', () => { + it('zeigt den vollständigen Schlüssel nur Berechtigten, nie in Listen/Details/Audit', async () => { + resetMock(); + const admin = await staff('rv-adm@x.test', 'admin'); const sa = await staff('rv-sa@x.test', 'superadmin'); const sup = await staff('rv-sup@x.test', 'support'); + const A = await customer(admin, 'Firma A', 'rv-a@x.test'); const B = await customer(admin, 'Firma B', 'rv-b@x.test'); + // Mitglied ohne Verwaltungsrecht in A + const inv = (await call(app, A.client, 'POST', `/orgs/${A.org}/invitations`, { email: 'rv-m@x.test', name: 'M', role: 'member' })).json(); + await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } }); + const member = (await login(app, 'rv-m@x.test', 'member-passwort-1')).client; + await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Reveal', values: { instance: 'rv' } }); await drain(); + const res = (await call(app, admin, 'GET', '/resources')).json()[0]; + await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org }); + + // Liste: Schlüsselfeld nur für Berechtigte + expect((await call(app, A.client, 'GET', '/resources')).json()[0].canReveal).toBe(true); + expect((await call(app, member, 'GET', '/resources')).json()[0].canReveal).toBe(false); + expect((await call(app, sup, 'GET', '/resources')).json()[0].canReveal).toBe(false); + const d = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json(); + expect(d.canReveal).toBe(true); expect((await call(app, member, 'GET', `/resources/${res.id}`)).json().canReveal).toBe(false); + expect(JSON.stringify(d)).not.toContain('MOCK-KEY'); // Details enthalten den Schlüssel nie + + const ok = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`); + expect(ok.statusCode).toBe(200); expect(ok.json().items).toEqual([{ label: 'Lizenzschlüssel', value: `MOCK-KEY-${res.externalRef ?? 'm-1'}` }]); + expect(ok.headers['cache-control']).toBe('no-store'); expect(ok.json().hideAfterSec).toBe(60); + expect((await call(app, admin, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(200); // Personal mit Schreibrecht + expect((await call(app, member, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Mitglied darf nicht + expect((await call(app, sup, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Support nur lesend + expect((await call(app, B.client, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(404); // fremde Organisation + expect((await call(app, A.client, 'POST', `/resources/${res.id}/reveal`, undefined)).statusCode).toBe(200); + const anon = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/reveal` }); expect(anon.statusCode).toBe(401); + + // Audit: Abruf ist protokolliert, der Schlüssel selbst nirgends + const ev = await query("SELECT actor_id, org_id, after_json FROM audit_events WHERE action = 'resource.reveal'"); expect(ev.length).toBeGreaterThanOrEqual(3); + expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('MOCK-KEY'); + expect(JSON.stringify(await query('SELECT * FROM resources'))).not.toContain('MOCK-KEY'); + // Anbieter-Ausfall: verständlicher Fehler + const inst = await one('SELECT id FROM connector_instances LIMIT 1'); + await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]); + const down = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`); expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/); + }); +}); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts new file mode 100644 index 0000000..dbee3d7 --- /dev/null +++ b/apps/api/test/setup.ts @@ -0,0 +1,17 @@ +import { beforeAll } from 'vitest'; +import mysql from 'mysql2/promise'; +import '../src/core/config.js'; + +/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */ +const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users']; +beforeAll(async () => { + if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen'); + const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' }); + await c.query('SET FOREIGN_KEY_CHECKS = 0'); + for (const t of DATA) await c.query(`TRUNCATE TABLE \`${t}\``); + await c.query('SET FOREIGN_KEY_CHECKS = 1'); + await c.query("UPDATE customer_sequences SET next_value = 10000; UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END".split(';')[0]); + await c.query("UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END"); + await c.query('UPDATE domain_settings SET tier = 1, margin_type = NULL, margin_value = NULL'); + await c.end(); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..370380d --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..53828c6 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { globalSetup: ['test/global-setup.ts'], setupFiles: ['test/setup.ts'], env: { DB_NAME: 'kundencenter_test', KC_NODE_ENV: 'test', KC_BASE_URL: 'http://localhost:4101' }, fileParallelism: false, testTimeout: 30000 } }); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..8abbdf7 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +export default { + poweredByHeader: false, + async redirects() { return [{ source: '/admin/connectors', destination: '/einstellungen/verbindungen', permanent: true }]; }, + async rewrites() { return [{ source: '/api/:path*', destination: `${process.env.KC_API_URL ?? 'http://127.0.0.1:4100'}/v1/:path*` }]; }, + async headers() { + return [{ source: '/:path*', headers: [ + { key: 'Content-Security-Policy', value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'same-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + ] }]; + }, +}; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a854a56 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@kc/web", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev -p 4101 -H 127.0.0.1", + "build": "next build", + "start": "next start -p 4101 -H 127.0.0.1", + "typecheck": "tsc --noEmit", + "test": "echo 'keine Web-Tests'" + }, + "dependencies": { + "next": "^16.3.6", + "qrcode": "^1.5.4", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@types/node": "^26.6.3", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "typescript": "^7.0.2" + } +} diff --git a/apps/web/src/app/(app)/admin/audit/page.tsx b/apps/web/src/app/(app)/admin/audit/page.tsx new file mode 100644 index 0000000..f7ef5aa --- /dev/null +++ b/apps/web/src/app/(app)/admin/audit/page.tsx @@ -0,0 +1,21 @@ +'use client'; +import { useCallback, useEffect, useState } from 'react'; +import { api, errMsg } from '@/lib/api'; +import { Alert, Empty, fmt } from '@/components/ui'; + +interface Ev { id: number; ts: string; actorType: string; actorId: string | null; action: string; resourceType: string | null; resourceId: string | null; result: string; correlationId: string | null; ip: string | null } +export default function Audit() { + const [list, setList] = useState(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null); + const load = useCallback(() => api('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]); + useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]); + return (<> +

Audit-Protokoll

+ {err && {err}} + {chain && (chain.brokenAt === null ? Hash-Kette intakt ({chain.checked} Einträge geprüft). : Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.)} +
setAction(e.target.value)} placeholder="z. B. auth. oder customer." />
+ {list === null ?

Wird geladen …

: list.length === 0 ? : +
+ {list.map((e) => + )}
ZeitAktionAkteurObjektErgebnis
{fmt(e.ts)}{e.action}{e.actorType}{e.actorId ? ` ${e.actorId.slice(0, 8)}` : ''}{e.resourceType ?? ''} {e.resourceId?.slice(0, 8) ?? ''}{e.result === 'success' ? 'Erfolg' : e.result === 'denied' ? 'Abgelehnt' : 'Fehler'}
} +
); +} diff --git a/apps/web/src/app/(app)/admin/auftraege/page.tsx b/apps/web/src/app/(app)/admin/auftraege/page.tsx new file mode 100644 index 0000000..b580704 --- /dev/null +++ b/apps/web/src/app/(app)/admin/auftraege/page.tsx @@ -0,0 +1,23 @@ +'use client'; +import { useCallback, useEffect, useState } from 'react'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Empty, Field, JOB_LABEL, JobStatus, fmt } from '@/components/ui'; + +interface J { id: string; type: string; status: string; attempts: number; maxAttempts: number; error: string | null; createdAt: string; correlationId: string | null } +const TYPE: Record = { 'connector.sync': 'Abgleich', 'connector.execute': 'Aktion beim Anbieter', 'discord.notify': 'Discord-Benachrichtigung' }; +export default function Auftraege() { + const { can } = useSession(); const w = can('resources.write'); + const [list, setList] = useState(null); const [status, setStatus] = useState(''); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); + const load = useCallback(() => api('GET', `/admin/jobs${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [status]); + useEffect(() => { void load(); const t = setInterval(() => void load(), 5000); return () => clearInterval(t); }, [load]); + async function retry(j: J) { try { await api('POST', `/admin/jobs/${j.id}/retry`); setMsg({ k: 'ok', t: 'Auftrag wird erneut ausgeführt.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } + return (<> +

Aufträge

{msg && {msg.t}} +
+ {list === null ?

Wird geladen …

: list.length === 0 ? : +
+ {list.map((j) => + )}
ArtStatusVersucheAngelegtAktion
{TYPE[j.type] ?? j.type}
{j.correlationId?.slice(0, 8)}
{j.error &&
{j.error}
}
{j.attempts}/{j.maxAttempts}{fmt(j.createdAt)}{w && (j.status === 'needs_review' || j.status === 'failed') && }
} +
); +} diff --git a/apps/web/src/app/(app)/admin/benutzer/page.tsx b/apps/web/src/app/(app)/admin/benutzer/page.tsx new file mode 100644 index 0000000..5c058dd --- /dev/null +++ b/apps/web/src/app/(app)/admin/benutzer/page.tsx @@ -0,0 +1,45 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Empty, Field, ROLE_LABEL, Status } from '@/components/ui'; + +interface U { id: string; email: string; name: string; kind: 'customer' | 'staff'; staffRole: string | null; status: string; mfa: boolean } +export default function Benutzer() { + const { me, can } = useSession(); + const [list, setList] = useState(null); const [q, setQ] = useState(''); const [kind, setKind] = useState(''); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [open, setOpen] = useState(false); const [resetFor, setResetFor] = useState(null); + const load = useCallback(() => api('GET', `/admin/users?${new URLSearchParams({ ...(q ? { q } : {}), ...(kind ? { kind } : {}) })}`).then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [q, kind]); + useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]); + const priv = can('users.write_privileged'); + async function create(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); + try { const r = await api<{ mail: string; inviteLink?: string }>('POST', '/admin/users', { name: f.get('name'), email: f.get('email'), staffRole: f.get('role') }); + setMsg(r.mail === 'sent' ? { k: 'ok', t: 'Einladung wurde versendet.' } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${r.inviteLink}` }); setOpen(false); void load(); } + catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + async function toggle(u: U) { + try { await api('PATCH', `/admin/users/${u.id}`, { status: u.status === 'active' ? 'disabled' : 'active' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + async function resetMfa(u: U) { + try { await api('POST', `/admin/users/${u.id}/mfa-reset`); setResetFor(null); setMsg({ k: 'ok', t: `2FA von ${u.name} wurde zurückgesetzt und alle Sitzungen beendet. ${u.kind === 'staff' ? 'Beim nächsten Login muss die 2FA neu eingerichtet werden.' : 'Der Kunde kann sie unter „Mein Konto“ neu einrichten.'}` }); void load(); } + catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + return (<> +

Benutzer

{can('users.write') && }
+ {msg && {msg.t}} + {open &&

Neuer Mitarbeiter

+ +
+

Mitarbeiter müssen bei der ersten Anmeldung 2FA einrichten.

} + {resetFor &&

2FA von {resetFor.name} zurücksetzen? Alle Sitzungen des Benutzers werden beendet. Bitte nur nach Prüfung der Identität ausführen. Die Aktion wird protokolliert.

+
} +
+ setQ(e.target.value)} /> +
+ {list === null ?

Wird geladen …

: list.length === 0 ? : +
+ {list.map((u) => + )}
NameE-MailRolle2FAStatusAktion
{u.name}{u.email}{u.staffRole ? ROLE_LABEL[u.staffRole] : 'Kunde'}{u.mfa ? 'Ja' : 'Nein'}
{can('users.write') && u.id !== me?.user.id && u.status !== 'invited' && (u.staffRole !== 'admin' && u.staffRole !== 'superadmin' || priv) && } + {can('users.write') && u.id !== me?.user.id && u.mfa && (u.kind === 'customer' || priv) && }
} +
); +} diff --git a/apps/web/src/app/(app)/admin/domains/page.tsx b/apps/web/src/app/(app)/admin/domains/page.tsx new file mode 100644 index 0000000..2c51109 --- /dev/null +++ b/apps/web/src/app/(app)/admin/domains/page.tsx @@ -0,0 +1,11 @@ +'use client'; +import Link from 'next/link'; +import { DomainRecords } from '@/components/DomainRecords'; + +export default function Aufstellung() { + return (<> +

Domain-Aufstellung

Preisliste & Aufschlag
+

Je Domain: Einkauf (Preisliste von KCS, dort Bruttopreise inkl. USt; netto herausgerechnet) und Ihr errechneter Verkaufspreis brutto und netto. Die USt wird nicht doppelt aufgeschlagen. Damit sehen Sie, was Sie bei KCS bestellen müssen und was Sie berechnen.

+ + ); +} diff --git a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx new file mode 100644 index 0000000..bf90a5b --- /dev/null +++ b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx @@ -0,0 +1,81 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { SecretField } from '@/components/SecretField'; +import { DomainRecords } from '@/components/DomainRecords'; +import { Alert, ContractStatusBadge, Empty, Field, fmt, OrderStatusBadge, ResState, ROLE_LABEL, Status } from '@/components/ui'; + +interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record; members: { id: string; email: string; name: string; status: string; role: string }[] } +interface CRow { id: string; number: string; status: string; productName: string; termEnd: string | null; cancelEffectiveAt: string | null } +interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] } +interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean } +export default function Kunde() { + const { id } = useParams<{ id: string }>(); const { can } = useSession(); + const [contracts, setContracts] = useState(null); const [orders, setOrders] = useState(null); const [ress, setRess] = useState(null); + const [o, setO] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null); + const load = useCallback(() => api('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); + useEffect(() => { void load(); api('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]); + if (!o) return msg ? {msg.t} :

Wird geladen …

; + const w = can('customers.write'); + async function save(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); const g = (k: string) => (f.get(k) as string | null) ?? ''; + const priv = o!.customerType === 'private'; + try { setO(await api('PATCH', `/admin/customers/${id}`, { name: g('name'), billing: { street: g('street'), zip: g('zip'), city: g('city'), billingEmail: g('billingEmail'), phone: g('phone'), ...(priv ? {} : { company: g('company'), vatId: g('vatId') }) } })); setMsg({ k: 'ok', t: 'Gespeichert.' }); } + catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + async function changeType(t: 'private' | 'business') { + // Beim Wechsel zum Privatkunden werden Firma und USt-IdNr. entfernt (Bestätigung vorher). + try { setO(await api('PATCH', `/admin/customers/${id}`, { customerType: t, ...(t === 'private' ? { billing: { company: '', vatId: '' } } : { billing: { company: o!.name } }) })); setSwitchTo(null); setMsg({ k: 'ok', t: 'Kundenart geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + async function reinvite(uid: string, email: string) { + try { const r = await api<{ mail: string; inviteLink?: string }>('POST', `/admin/users/${uid}/reinvite`); setMsg(r.mail === 'sent' ? { k: 'ok', t: `Die Einladung wurde an ${email} gesendet.` } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${r.inviteLink}` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + async function setStatus(status: string) { + try { setO(await api('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + return (<> +

← Alle Kunden

+

{o.name}

+

{o.customerNumber} · {o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}

+ {msg && {msg.t}} +

Stammdaten & Rechnungsanschrift

+
+ {o.customerType === 'business' && } + + + + {o.customerType === 'business' && } + +
+ {w && }
+

Verträge

{can('orders.write') && Bestellung anlegen}
+ {contracts === null ?

Wird geladen …

: contracts.length === 0 ? : +
+ {contracts.map((c) => )}
Nr.ProduktLaufzeit bisStatus
{c.number}{c.productName}{c.termEnd ? fmt(c.termEnd) : 'unbefristet'}{c.cancelEffectiveAt &&
Gekündigt zum {fmt(c.cancelEffectiveAt)}
}
}
+

Ressourcen und Lizenzen

+ {ress === null ?

Wird geladen …

: ress.length === 0 ? : +
+ {ress.map((r) => )}
NameStatusGültig bisLizenzschlüssel
{r.name}{r.stale && <> Veraltet}{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}{r.canReveal ? : –}
}
+ {can('domains.read') &&

Domains

} +

Bestellungen

+ {orders === null ?

Wird geladen …

: orders.length === 0 ? : +
+ {orders.map((x) => )}
Nr.PositionenBestellt amStatus
{x.number}{x.items.map((i) => i.snapshot.name).join(', ')}{fmt(x.createdAt)}
}
+

Benutzer

+ {o.members.map((m) => )}
NameE-MailRolleStatusAktionen
{m.name}{m.email}{ROLE_LABEL[m.role]}{w && m.status === 'invited' && }
+ {w &&

Kundenart

+ {switchTo ? (

Zu {switchTo === 'private' ? 'Privatkunde' : 'Geschäftskunde'} wechseln? {switchTo === 'private' ? 'Firma und USt-IdNr. werden entfernt.' : 'Als Firma wird der bisherige Name übernommen.'} Das kann Auswirkungen auf Rechnungen und Steuer haben. Die Änderung wird protokolliert.

+
) + : }
} + {w &&

Gefährliche Aktionen

+ {confirm ? (

{confirm === 'suspended' ? 'Kunde sperren?' : confirm === 'closed' ? 'Kunde beenden?' : 'Kunde entsperren?'} Die Aktion wird protokolliert.

+
) + :
{o.status !== 'active' && o.status !== 'closed' && } + {o.status === 'active' && } + {o.status !== 'closed' && }
} +

Hinweis: Sperren wirkt aktuell nur auf den Kundenstatus. Provider-Ressourcen werden erst mit den Connectoren gesperrt.

} + ); +} diff --git a/apps/web/src/app/(app)/admin/kunden/page.tsx b/apps/web/src/app/(app)/admin/kunden/page.tsx new file mode 100644 index 0000000..2eb6eda --- /dev/null +++ b/apps/web/src/app/(app)/admin/kunden/page.tsx @@ -0,0 +1,53 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import Link from 'next/link'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Empty, Field, Status } from '@/components/ui'; + +interface Cust { id: string; customerNumber: string; name: string; customerType: 'private' | 'business'; status: string; city: string | null; billingEmail: string | null; members: number } +export default function Kunden() { + const { can } = useSession(); + const [list, setList] = useState(null); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [open, setOpen] = useState(false); const [ctype, setCtype] = useState<'private' | 'business'>('business'); const [typeFilter, setTypeFilter] = useState(''); + const [created, setCreated] = useState<{ id: string; customerNumber: string; mail: string; inviteLink?: string } | null>(null); + const load = useCallback(() => api('GET', `/admin/customers?${new URLSearchParams({ ...(q ? { q } : {}), ...(typeFilter ? { type: typeFilter } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [q, typeFilter]); + useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]); + + async function create(e: FormEvent) { + e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget); const s = (k: string) => (f.get(k) as string) || undefined; + try { + const priv = ctype === 'private'; + const name = priv ? `${f.get('firstName')} ${f.get('lastName')}`.trim() : (f.get('name') as string); + const r = await api>('POST', '/admin/customers', { + type: ctype, name, owner: priv ? { email: f.get('ownerEmail') } : { name: f.get('ownerName'), email: f.get('ownerEmail') }, + billing: { street: s('street'), zip: s('zip'), city: s('city'), country: s('country') ?? 'DE', phone: s('phone'), ...(priv ? {} : { vatId: s('vatId') }) } }); + setCreated(r); setOpen(false); void load(); + } catch (x) { setErr(errMsg(x)); } + } + return (<> +

Kunden

{can('customers.write') &&
Aus Verbindung übernehmen
}
+ {err && {err}} + {created && Kunde {created.customerNumber} angelegt. {created.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : <>E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): {created.inviteLink}}} + {open &&

Neuer Kunde

+
Kundenart +
{([['business', 'Geschäftskunde'], ['private', 'Privatkunde']] as const).map(([v, l]) => )}
+ {ctype === 'business' ? (<> +
+

Ansprechpartner (erhält die Einladung)

+
+ ) : (<> +
+
+ )} +

Rechnungsanschrift

+
+
+
} +
+
setQ(e.target.value)} placeholder="Name, Kundennummer oder E-Mail" /> +
+ {list === null ?

Wird geladen …

: list.length === 0 ? {!q && 'Legen Sie den ersten Kunden über „Kunde anlegen“ an.'} : +
+ {list.map((c) => )}
Nr.NameArtOrtBenutzerStatus
{c.customerNumber}{c.name}{c.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}{c.city ?? '–'}{c.members}
} +
); +} diff --git a/apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx b/apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx new file mode 100644 index 0000000..7bf1103 --- /dev/null +++ b/apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx @@ -0,0 +1,70 @@ +'use client'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Empty, Field, fmt } from '@/components/ui'; + +interface Cust { externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null; + imported: { orgId: string; customerNumber: string } | null; resourceKnown: boolean; resourceOrgId: string | null; suggestedProducts: { id: string; sku: string; name: string; status: string }[]; possibleOrgs: { id: string; customerNumber: string; name: string }[] } +interface Conn { id: string; name: string; connector: string; capabilities: string[]; health: string } +interface Row { sel: boolean; type: 'private' | 'business'; productId: string; start: string; link: string } +interface Result { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string } + +export default function KundenUebernahme() { + const { can } = useSession(); + const [conns, setConns] = useState([]); const [connId, setConnId] = useState(''); const [list, setList] = useState(null); const [err, setErr] = useState(''); + const [rows, setRows] = useState>({}); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [results, setResults] = useState(null); + useEffect(() => { api('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('customers.list')); setConns(c); setConnId(c[0]?.id ?? ''); if (!c.length) setList([]); }).catch((e) => setErr(errMsg(e))); }, []); + const load = useCallback(() => { + if (!connId) return; setList(null); setErr(''); setResults(null); + api('GET', `/admin/connectors/${connId}/customers`).then((l) => { + setList(l); + setRows(Object.fromEntries(l.map((c) => [c.externalRef, { sel: false, type: c.company ? 'business' : 'private', productId: c.suggestedProducts[0]?.id ?? '', start: (c.createdAt ?? new Date().toISOString()).slice(0, 10), link: '' } as Row]))); + }).catch((e) => { setErr(errMsg(e)); setList([]); }); + }, [connId]); + useEffect(() => { load(); }, [load]); + const set = (ref: string, p: Partial) => setRows((r) => ({ ...r, [ref]: { ...r[ref]!, ...p } })); + const visible = useMemo(() => (list ?? []).filter((c) => !q || [c.displayName, c.email, c.planName].some((x) => x?.toLowerCase().includes(q.toLowerCase()))), [list, q]); + const open = visible.filter((c) => !c.imported && (!c.resourceOrgId)); + const chosen = (list ?? []).filter((c) => rows[c.externalRef]?.sel); + if (!can('customers.write')) return Keine Berechtigung.; + + async function run() { + setBusy(true); setErr(''); + try { + const items = chosen.map((c) => { const r = rows[c.externalRef]!; return { externalRef: c.externalRef, type: r.type, ...(r.link ? { linkToOrgId: r.link } : {}), ...(r.productId ? { contract: { productId: r.productId, startedAt: new Date(r.start).toISOString() } } : {}) }; }); + const r = await api<{ results: Result[] }>('POST', `/admin/connectors/${connId}/customers/import`, { items }); setResults(r.results); load(); + } catch (e) { setErr(errMsg(e)); } finally { setBusy(false); } + } + return (<> +

← Alle Kunden

+

Kunden übernehmen

+

Die Kunden werden live aus der Verbindung gelesen. Beim Anbieter wird nichts verändert, und es werden keine E-Mails an die Kunden versendet. Die Einladung zum Kundencenter senden Sie später gezielt.

+ {err && {err} } +
+ {results &&

Ergebnis

+
{results.map((r) => { const c = list?.find((x) => x.externalRef === r.externalRef); return + + ; })}
KontoErgebnisKundeVertrag
{c?.displayName ?? r.externalRef}{r.status === 'created' ? 'Angelegt' : r.status === 'linked' ? 'Verknüpft' : 'Übersprungen'}{r.reason &&
{r.reason}
}
{r.orgId ? {r.customerNumber} : '–'}{r.contractNumber ?? '–'}
+

Die Inhaber wurden als „eingeladen“ angelegt. Zum Zugang senden Sie die Einladung auf der Kundenseite unter „Benutzer“.

} + {conns.length === 0 && list !== null &&
Legen Sie unter Einstellungen → Verbindungen eine Verbindung an, die Kunden bereitstellt (z. B. KeyHelp).
} + {list === null ?

Kunden werden beim Anbieter gelesen …

: list.length > 0 && ( +
setQ(e.target.value)} placeholder="Name, E-Mail oder Tarif" /> +
+
+ {visible.map((c) => { const r = rows[c.externalRef]; if (!r) return null; const done = !!c.imported || !!c.resourceOrgId; return ( + + + + + + ); })}
AuswahlKontoTarifArtVertrag (Bestand)Verknüpfen mit
set(c.externalRef, { sel: e.target.checked })} />{c.displayName}
{c.email ?? 'keine E-Mail'} · seit {c.createdAt ? fmt(c.createdAt).split(',')[0] : '–'}{c.state === 'suspended' && <> · gesperrt}
+ {c.imported &&
Übernommen {c.imported.customerNumber}
} + {!c.imported && c.resourceOrgId &&
Konto bereits zugeordnet
}
{c.planName ?? '–'} + {r.productId &&
set(c.externalRef, { start: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }} />
} + {c.suggestedProducts.length === 0 &&
Kein Produkt zum Tarif vorhanden (erst Tarif als Produkt übernehmen).
}
{c.possibleOrgs.length > 0 ? : Neuer Kunde}
+
+ Bestandsverträge übernehmen Preis und Bedingungen des gewählten Produkts. Abrechnung folgt mit dem Rechnungsmodul.
)} + ); +} diff --git a/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx b/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx new file mode 100644 index 0000000..459d694 --- /dev/null +++ b/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx @@ -0,0 +1,50 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui'; +import type { Prod } from '../page'; + +type D = Prod & { versions: NonNullable[] }; +export default function Produkt() { + const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write'); + const [d, setD] = useState(null); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [open, setOpen] = useState(false); + const load = useCallback(() => api('GET', `/admin/products/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); + useEffect(() => { void load(); api('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); }, [load]); + if (!d) return msg ? {msg.t} :

Wird geladen …

; + const cur = d.current!; + async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } + async function newVersion(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once'; + try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval, + termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning }); + setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } + } + return (<> +

← Alle Produkte

+

{cur.name}

{d.sku}

+ {msg && {msg.t}} +

Aktuelle Konditionen (Version {cur.version})

+

{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}
}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %

+

{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}

+ {w &&
{d.status !== 'active' && } + {d.status === 'active' && } +
}
+ {open &&

Neue Version

Preisänderungen gelten nur für neue Bestellungen. Laufzeit und Bereitstellung bleiben unverändert.

+
+ + {cur.billingInterval !== 'once' && } + +
+
} +

Verkaufsregeln

+ +
+
Aktionen, die Kunden selbst ausführen dürfen
+ {CUSTOMER_ACTIONS.map((a) => )}
+

Versionen

+ {d.versions.map((v) => )}
VersionBezeichnungEinrichtungLaufendSteuer
{v.version}{v.id === cur.id && ' (aktuell)'}{v.name}{eur(v.setupCents)}{v.billingInterval === 'once' ? '–' : `${INTERVAL[v.billingInterval]} ${eur(v.recurringCents)}`}{v.taxBp / 100} %
+ ); +} diff --git a/apps/web/src/app/(app)/admin/produkte/domains/page.tsx b/apps/web/src/app/(app)/admin/produkte/domains/page.tsx new file mode 100644 index 0000000..40d7f97 --- /dev/null +++ b/apps/web/src/app/(app)/admin/produkte/domains/page.tsx @@ -0,0 +1,63 @@ +'use client'; +import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import Link from 'next/link'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Field, eur } from '@/components/ui'; + +interface Margin { type: 'percent' | 'fixed' | null; value: number | null } +interface Tld { tld: string; termMonths: number; costGrossCents: number; costNetCents: number; setupGrossCents: number; priceGrossCents: number | null; priceNetCents: number | null; profitNetCents: number | null; margin: Margin; active: boolean } +interface Data { settings: { tier: number; margin: Margin; rounding: boolean; basis: 'gross' | 'net'; taxBp: number }; tlds: Tld[] } +const TIERS = ['0–49 Domains', '50–199 Domains', '200–999 Domains', 'ab 1000 Domains']; +const showMargin = (m: Margin) => m.type === 'percent' ? `${(m.value! / 100).toLocaleString('de-DE')} %` : m.type === 'fixed' ? eur(m.value!) : null; +/** Eingabe (Prozent bzw. Euro, deutsches Komma) → Basispunkte bzw. Cent. */ +const toMargin = (type: string, raw: string): Margin => { if (!type || !raw.trim()) return { type: null, value: null }; const n = Number(raw.replace(',', '.')); if (!Number.isFinite(n) || n < 0) throw new Error('Bitte einen gültigen Aufschlag eingeben.'); return { type: type as 'percent' | 'fixed', value: Math.round(n * 100) }; }; + +function MarginInputs({ idp, def }: { idp: string; def: Margin }) { + return (
+ +
); +} +export default function DomainPreise() { + const { can } = useSession(); const w = can('domains.write'); + const [d, setD] = useState(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [q, setQ] = useState(''); const [edit, setEdit] = useState(null); const [imp, setImp] = useState(false); + const load = useCallback(() => api('GET', '/admin/domain-tlds').then(setD).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + const rows = useMemo(() => (d?.tlds ?? []).filter((t) => !q || t.tld.includes(q.trim().toLowerCase().replace(/^\./, ''))), [d, q]); + async function saveSettings(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); setErr(''); setOk(''); + try { await api('PUT', '/admin/domain-settings', { tier: Number(f.get('tier')), rounding: f.get('rounding') === 'on', basis: String(f.get('basis')), margin: toMargin(String(f.get('mtype')), String(f.get('mvalue'))) }); setOk('Einstellungen gespeichert. Alle Verkaufspreise wurden neu berechnet.'); void load(); } catch (x) { setErr(errMsg(x)); } + } + async function saveTld(e: FormEvent, tld: string) { + e.preventDefault(); const f = new FormData(e.currentTarget); setErr(''); setOk(''); + try { await api('PATCH', `/admin/domain-tlds/${encodeURIComponent(tld)}`, { margin: toMargin(String(f.get('mtype')), String(f.get('mvalue'))) }); setEdit(null); void load(); } catch (x) { setErr(errMsg(x)); } + } + async function toggle(t: Tld) { try { await api('PATCH', `/admin/domain-tlds/${encodeURIComponent(t.tld)}`, { active: !t.active }); void load(); } catch (x) { setErr(errMsg(x)); } } + async function doImport(e: FormEvent) { + e.preventDefault(); setErr(''); setOk(''); + try { const r = await api<{ rows: number; created: number; updated: number; skipped: string[] }>('POST', '/admin/domain-tlds/import', { text: String(new FormData(e.currentTarget).get('text')) }); setOk(`${r.rows} Endungen eingelesen (${r.created} neu, ${r.updated} aktualisiert${r.skipped.length ? `, ${r.skipped.length} Zeilen übersprungen` : ''}). Aufschläge bleiben erhalten.`); setImp(false); void load(); } catch (x) { setErr(errMsg(x)); } + } + const noMargin = d && !d.settings.margin.type; + return (<> +

Domains

Domain-Aufstellung← Produkte
+

Domainpreise setzen sich zusammen aus Einkaufspreis + Ihr Gewinnaufschlag. Die Preisliste von KCS gilt als Bruttopreis (inkl. USt). Darauf wird keine USt mehr aufgeschlagen; der Nettobetrag wird herausgerechnet. Im Angebot erscheint das Hostingprodukt plus die Domain mit dem Verkaufspreis.

+ {err && {err}}{ok && {ok}} + {noMargin && Es ist noch kein Standard-Aufschlag festgelegt. Solange fehlt bei Domains ohne eigenen Aufschlag der Verkaufspreis („Preis auf Anfrage“).} + {d &&

Preisstaffel und Standard-Aufschlag

+
+

Gilt für alle Endungen ohne eigenen Aufschlag. Prozent in ganzen oder halben Schritten, z. B. 25 oder 12,5.

+ + {w && }
} +
setQ(e.target.value)} placeholder="z. B. de oder shop" /> + {w && }
+ {imp &&