Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
81
packages/platform/src/audit.ts
Normal file
81
packages/platform/src/audit.ts
Normal 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 };
|
||||
}
|
||||
41
packages/platform/src/config.ts
Normal file
41
packages/platform/src/config.ts
Normal 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');
|
||||
42
packages/platform/src/contractterms.ts
Normal file
42
packages/platform/src/contractterms.ts
Normal 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) };
|
||||
}
|
||||
24
packages/platform/src/crypto.ts
Normal file
24
packages/platform/src/crypto.ts
Normal 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);
|
||||
}
|
||||
44
packages/platform/src/db.ts
Normal file
44
packages/platform/src/db.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
18
packages/platform/src/jobs.ts
Normal file
18
packages/platform/src/jobs.ts
Normal 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); }
|
||||
}
|
||||
36
packages/platform/src/pricing.ts
Normal file
36
packages/platform/src/pricing.ts
Normal 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' });
|
||||
27
packages/platform/src/statemachine.ts
Normal file
27
packages/platform/src/statemachine.ts
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue