Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,81 @@
import { createHash } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { pool, one, query } from './db.js';
const SENSITIVE = /pass|secret|token|hash|code|key|totp/i;
/** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */
export function mask(v: unknown): unknown {
if (Array.isArray(v)) return v.map(mask);
if (v && typeof v === 'object') {
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)]));
}
return v;
}
export interface AuditInput {
actorType: 'user' | 'system' | 'anonymous';
actorId?: string | null;
orgId?: string | null;
action: string;
resourceType?: string;
resourceId?: string;
result?: 'success' | 'denied' | 'failure';
errorClass?: string;
connector?: string;
correlationId?: string;
ip?: string;
before?: unknown;
after?: unknown;
}
const canon = (o: unknown) => JSON.stringify(o);
/** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */
export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void> {
const own = !conn;
const c = conn ?? (await pool.getConnection());
try {
if (own) await c.beginTransaction();
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c);
const prev = last?.hash ?? '0'.repeat(64);
const ts = new Date();
const body = {
ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action,
resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success',
error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null,
before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after),
};
const hash = createHash('sha256').update(prev + canon(body)).digest('hex');
await c.execute(
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null,
body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
);
if (own) await c.commit();
} catch (err) {
if (own) await c.rollback();
throw err;
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
if (own) c.release();
}
}
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> {
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id');
let prev = '0'.repeat(64);
for (const r of rows) {
const body = {
ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action,
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id,
before: r.before_json ?? null, after: r.after_json ?? null,
};
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number };
prev = r.hash as string;
}
return { checked: rows.length, brokenAt: null };
}

View file

@ -0,0 +1,41 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
/** Lädt *.env aus KC_ENV_DIR (Standard /etc/kundencenter), ohne bereits gesetzte Variablen zu überschreiben. */
function loadEnvDir(dir: string): void {
if (!existsSync(dir)) return;
for (const f of readdirSync(dir).filter((n) => n.endsWith('.env')).sort()) {
for (const line of readFileSync(join(dir, f), 'utf8').split('\n')) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (m && m[1] && process.env[m[1]] === undefined) process.env[m[1]] = m[2] ?? '';
}
}
}
loadEnvDir(process.env.KC_ENV_DIR ?? '/etc/kundencenter');
function req(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Konfiguration fehlt: ${name}`);
return v;
}
export const config = {
env: process.env.KC_NODE_ENV ?? 'development',
isProd: (process.env.KC_NODE_ENV ?? 'development') === 'production',
port: Number(process.env.KC_API_PORT ?? 4100),
baseUrl: process.env.KC_BASE_URL ?? 'http://localhost:4101',
/** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */
allowedOrigins: new Set([process.env.KC_BASE_URL ?? 'http://localhost:4101', ...(process.env.KC_ALLOWED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean)]),
secretKey: Buffer.from(req('KC_SECRET_KEY'), 'base64'),
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
database: req('DB_NAME'),
user: req('DB_USER'),
password: req('DB_PASSWORD'),
},
smtp: process.env.SMTP_HOST
? { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD, from: process.env.SMTP_FROM ?? 'kundencenter@localhost' }
: null,
};
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');

View file

@ -0,0 +1,42 @@
/** Laufzeit-, Verlängerungs- und Kündigungslogik (rein, ohne Datenbank). Zeitpunkte in UTC. */
export interface Terms { termEnd: Date | null; renewal: 'auto' | 'none'; renewalTermMonths: number; noticeDays: number }
export function addMonths(d: Date, months: number): Date {
const r = new Date(d.getTime());
const day = r.getUTCDate();
r.setUTCDate(1); r.setUTCMonth(r.getUTCMonth() + months);
const last = new Date(Date.UTC(r.getUTCFullYear(), r.getUTCMonth() + 1, 0)).getUTCDate();
r.setUTCDate(Math.min(day, last)); // 31.01. + 1 Monat = 28./29.02.
return r;
}
const addDays = (d: Date, n: number) => new Date(d.getTime() + n * 86400000);
/**
* Wann endet der Vertrag, wenn jetzt gekündigt wird?
* - ohne Mindestlaufzeit (termEnd null): mit Frist ab jetzt.
* - ohne Verlängerung: zum Laufzeitende.
* - mit Verlängerung: zum nächsten Laufzeitende, das mindestens noticeDays in der Zukunft liegt.
*/
export function effectiveCancelDate(now: Date, t: Terms): Date {
if (!t.termEnd) return addDays(now, t.noticeDays);
if (t.renewal === 'none') return t.termEnd > now ? t.termEnd : now;
let end = t.termEnd; let guard = 0;
while (addDays(end, -t.noticeDays) < now) {
if (t.renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit');
end = addMonths(end, t.renewalTermMonths);
}
return end;
}
/** Verlängert das Laufzeitende, solange es in der Vergangenheit liegt (nur bei auto-Verlängerung und ohne Kündigung). */
export function renewedTermEnd(now: Date, termEnd: Date, renewalTermMonths: number): Date {
let end = termEnd; let guard = 0;
while (end <= now) { if (renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit'); end = addMonths(end, renewalTermMonths); }
return end;
}
/**
* Verbraucherverträge (Privatkunden): Nach der Erstlaufzeit läuft der Vertrag nur auf unbestimmte Zeit weiter
* und ist mit höchstens einem Monat Frist kündbar. HINWEIS: rechtlich vor Produktivbetrieb prüfen lassen.
*/
export function consumerTerms(renewalTermMonths: number, noticeDays: number): { renewalTermMonths: number; noticeDays: number } {
return { renewalTermMonths: renewalTermMonths > 0 ? 1 : 0, noticeDays: Math.min(noticeDays, 30) };
}

View file

@ -0,0 +1,24 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from './config.js';
export const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
export const randomToken = (bytes = 32): string => randomBytes(bytes).toString('base64url');
/** AES-256-GCM, Format v1:<iv>:<tag>:<ciphertext> (base64url). Key-ID im Präfix ermöglicht spätere Rotation. */
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv('aes-256-gcm', config.secretKey, iv);
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
return ['v1', iv.toString('base64url'), c.getAuthTag().toString('base64url'), ct.toString('base64url')].join(':');
}
export function decrypt(blob: string): string {
const [v, iv, tag, ct] = blob.split(':');
if (v !== 'v1' || !iv || !tag || !ct) throw new Error('Unbekanntes Verschlüsselungsformat');
const d = createDecipheriv('aes-256-gcm', config.secretKey, Buffer.from(iv, 'base64url'));
d.setAuthTag(Buffer.from(tag, 'base64url'));
return Buffer.concat([d.update(Buffer.from(ct, 'base64url')), d.final()]).toString('utf8');
}
export function safeEqual(a: string, b: string): boolean {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}

View file

@ -0,0 +1,44 @@
import mysql from 'mysql2/promise';
import type { Pool, PoolConnection, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
import { config } from './config.js';
export const pool: Pool = mysql.createPool({
...config.db,
connectionLimit: 10,
charset: 'utf8mb4',
timezone: 'Z',
dateStrings: false,
namedPlaceholders: false,
});
// Alle Zeiten in UTC, auch die DB-Defaults (CURRENT_TIMESTAMP) – sonst Versatz zur Serverzeit.
(pool as unknown as { on(e: string, f: (c: { query(s: string): void }) => void): void }).on('connection', (c) => c.query("SET time_zone = '+00:00'"));
export type Row = RowDataPacket;
type Exec = Pick<Pool | PoolConnection, 'execute'>;
export async function query<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T[]> {
const [rows] = await c.execute<T[]>(sql, params as never[]);
return rows;
}
export async function one<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T | undefined> {
return (await query<T>(sql, params, c))[0];
}
export async function run(sql: string, params: unknown[] = [], c: Exec = pool): Promise<ResultSetHeader> {
const [res] = await c.execute<ResultSetHeader>(sql, params as never[]);
return res;
}
export async function tx<T>(fn: (c: PoolConnection) => Promise<T>): Promise<T> {
const c = await pool.getConnection();
try {
await c.beginTransaction();
const out = await fn(c);
await c.commit();
return out;
} catch (e) {
await c.rollback();
throw e;
} finally {
c.release();
}
}

View file

@ -0,0 +1,18 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { run } from './db.js';
/** Persistenten Auftrag einreihen. Mit idempotencyKey wird Doppelanlage verhindert. */
export async function enqueue(type: string, payload: unknown, opts: { idempotencyKey?: string; correlationId?: string; runAt?: Date } = {}, c?: PoolConnection): Promise<void> {
await run(
'INSERT IGNORE INTO jobs (id, type, payload, idempotency_key, correlation_id, run_at) VALUES (?,?,?,?,?,?)',
[randomUUID(), type, JSON.stringify(payload), opts.idempotencyKey ?? null, opts.correlationId ?? null, opts.runAt ?? new Date()],
c,
);
}
/** Wird von Job-Handlern geworfen, um die Wiederholungslogik zu steuern. */
export class JobFailure extends Error {
/** retry=false: nie automatisch wiederholen (z. B. destruktive Aktionen); finalStatus bestimmt den Endzustand. */
constructor(message: string, public retry: boolean, public finalStatus: 'failed' | 'needs_review' = 'needs_review', public delaySec?: number) { super(message); }
}

View file

@ -0,0 +1,36 @@
/**
* Serverseitige Preisberechnung in Cent (ganzzahlig, kaufmännisch gerundet je Position).
* Steuer in Basispunkten (1900 = 19 %); Rabatt in Basispunkten (1000 = 10 %).
* Preisbasis `net`: Beträge sind Nettopreise, Steuer wird aufgeschlagen.
* Preisbasis `gross`: Beträge sind Bruttopreise (z. B. Endkundenpreis 9,99 €) und bleiben exakt; Netto und Steuer werden herausgerechnet.
*/
export type Interval = 'once' | 'monthly' | 'yearly';
export type Basis = 'net' | 'gross';
export interface PriceInput {
basis?: Basis; setupCents: number; recurringCents: number; taxBp: number; interval: Interval; quantity: number; discountBp: number; currency?: string;
}
export interface PriceSnapshot {
basis: Basis; currency: string; interval: Interval; quantity: number; taxBp: number; discountBp: number;
unitSetupCents: number; unitRecurringCents: number;
setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number };
}
/** Kaufmännisches Runden (halb auf) für nicht-negative Ganzzahl-Division. */
const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2));
export function calculatePrice(i: PriceInput): PriceSnapshot {
const ints: [string, number][] = [['setupCents', i.setupCents], ['recurringCents', i.recurringCents], ['taxBp', i.taxBp], ['quantity', i.quantity], ['discountBp', i.discountBp]];
for (const [n, v] of ints) if (!Number.isInteger(v) || v < 0) throw new RangeError(`${n} muss eine nicht-negative Ganzzahl sein`);
if (i.quantity < 1 || i.quantity > 10000) throw new RangeError('quantity außerhalb des Bereichs');
if (i.discountBp > 10000) throw new RangeError('discountBp darf höchstens 10000 sein');
if (i.interval === 'once' && i.recurringCents > 0) throw new RangeError('Einmalprodukte haben keinen wiederkehrenden Preis');
const basis: Basis = i.basis ?? 'net';
const line = (unit: number) => {
const amount = divRound(unit * i.quantity * (10000 - i.discountBp), 10000);
if (basis === 'gross') { const net = divRound(amount * 10000, 10000 + i.taxBp); return { net, tax: amount - net, gross: amount }; }
const tax = divRound(amount * i.taxBp, 10000);
return { net: amount, tax, gross: amount + tax };
};
return { basis, currency: i.currency ?? 'EUR', interval: i.interval, quantity: i.quantity, taxBp: i.taxBp, discountBp: i.discountBp,
unitSetupCents: i.setupCents, unitRecurringCents: i.recurringCents, setup: line(i.setupCents), recurring: line(i.recurringCents) };
}
export const formatEuro = (cents: number): string => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });

View file

@ -0,0 +1,27 @@
/** Statusautomaten für Bestellung und Vertrag. Zustandsänderungen laufen ausschließlich über `transition`. */
export type OrderStatus = 'pending_approval' | 'approved' | 'provisioning' | 'completed' | 'failed' | 'rejected' | 'cancelled';
export type OrderEvent = 'approve' | 'reject' | 'cancel' | 'start_provisioning' | 'complete' | 'fail' | 'retry';
export type ContractStatus = 'pending' | 'active' | 'suspended' | 'cancelled' | 'expired' | 'failed';
export type ContractEvent = 'activate' | 'suspend' | 'unsuspend' | 'cancel' | 'expire' | 'fail';
type Machine<S extends string, E extends string> = Partial<Record<S, Partial<Record<E, S>>>>;
export const ORDER_MACHINE: Machine<OrderStatus, OrderEvent> = {
pending_approval: { approve: 'approved', reject: 'rejected', cancel: 'cancelled' },
approved: { start_provisioning: 'provisioning', cancel: 'cancelled' },
provisioning: { complete: 'completed', fail: 'failed' },
failed: { retry: 'provisioning', cancel: 'cancelled' },
};
export const CONTRACT_MACHINE: Machine<ContractStatus, ContractEvent> = {
pending: { activate: 'active', fail: 'failed', cancel: 'cancelled' },
active: { suspend: 'suspended', cancel: 'cancelled', expire: 'expired' },
suspended: { unsuspend: 'active', cancel: 'cancelled', expire: 'expired' },
};
export class InvalidTransition extends Error {
constructor(public from: string, public event: string) { super(`Ungültiger Statuswechsel: ${event} ist im Zustand ${from} nicht erlaubt`); }
}
export function transition<S extends string, E extends string>(m: Machine<S, E>, from: S, event: E): S {
const to = m[from]?.[event];
if (!to) throw new InvalidTransition(from, event);
return to;
}
export const terminalOrder = (s: OrderStatus) => ['completed', 'rejected', 'cancelled'].includes(s);