'use strict'; /** * MPM Modul-SDK (Phase 8) * ======================= * Standardbibliothek für MPM-Module. Ein Modul ist ein eigenständiger * Node-Prozess innerhalb des Management-Containers, der ausschließlich * über den Modul-Gateway der Plattform erreichbar ist. * * Das SDK stellt bereit: * - Authentication : Identität aus den sicheren Gateway-Headern * - Current User : /api/me-Antwort nach Vertrag * - Permissions : Modulrechte in der /api/me-Antwort * - API Client : HTTP-Client für Plattform-Aufrufe (intern) * - Module Config : Manifest-Laden und Laufzeit-Konfiguration * - Logging : Strukturiertes Logging ohne Secrets * - Healthcheck : /health- und /api/manifest-Endpunkte nach Vertrag * - createModuleServer : HTTP-Server, der den gesamten Vertrag verdrahtet * * Sicherheitsregeln: * - Module lauschen nur auf 127.0.0.1 (nie öffentlich erreichbar) * - Der Gateway entfernt das Session-Cookie vor dem Proxy * - Identitäts-Header werden ausschließlich vom Gateway gesetzt * - Logs enthalten niemals Passwörter, Tokens oder personenbezogene Daten */ const http = require('node:http'); const fs = require('node:fs'); /** Identitäts-Header, die der Modul-Gateway setzt. */ const GATEWAY_HEADERS = Object.freeze({ userId: 'x-user-id', username: 'x-user-username', displayName: 'x-user-display-name', role: 'x-user-role', }); /** Erlaubte Plattform-Rollen. */ const PLATFORM_ROLES = Object.freeze(['ADMIN', 'USER']); /** Schlüssel, die beim Logging geschwärzt werden. */ const SENSITIVE_KEYS = Object.freeze([ 'password', 'passwort', 'token', 'secret', 'authorization', 'cookie', 'apikey', 'api_key', ]); /** Liest einen Header case-insensitive (erster Wert). */ function readHeader(headers, name) { const lowerName = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === lowerName) { return Array.isArray(value) ? value[0] ?? null : value ?? null; } } return null; } /** * Extrahiert die Benutzer-Identität aus den Gateway-Headern. * @returns {object|null} Identität oder null, wenn der Request nicht * über den Gateway kam (z. B. direkter Aufruf ohne Plattform). */ function extractIdentity(headers) { const userId = readHeader(headers, GATEWAY_HEADERS.userId); const username = readHeader(headers, GATEWAY_HEADERS.username); const displayName = readHeader(headers, GATEWAY_HEADERS.displayName); const role = readHeader(headers, GATEWAY_HEADERS.role); if (!userId || !username || !role) { return null; } if (!PLATFORM_ROLES.includes(role)) { return null; } return { userId, username, displayName: displayName ?? username, role }; } /** Erstellt die /health-Antwort nach Vertrag. */ function healthResponse(moduleId, version) { return { moduleId, version, status: 'healthy' }; } /** Erstellt die /api/manifest-Antwort nach Vertrag. */ function manifestResponse(manifest) { return { moduleId: manifest.id, name: manifest.name, version: manifest.version, slug: manifest.slug, apiVersion: manifest.apiVersion, status: 'healthy', }; } /** Erstellt die /api/me-Antwort nach Vertrag. */ function meResponse(identity, moduleId, permissions = []) { return { user: { id: identity.userId, username: identity.username, displayName: identity.displayName, platformRole: identity.role, }, module: { id: moduleId }, permissions, }; } /** * Lädt und validiert ein Modul-Manifest (module.json). * @param {string} manifestPath Absoluter Pfad zur module.json * @returns {object} Validiertes, eingefrorenes Manifest * @throws {Error} Bei fehlender/ungültiger Datei */ function loadManifest(manifestPath) { let raw; try { raw = fs.readFileSync(manifestPath, 'utf8'); } catch (error) { throw new Error(`Modul-Manifest nicht lesbar: ${manifestPath} (${error.message})`); } let manifest; try { manifest = JSON.parse(raw); } catch (error) { throw new Error(`Modul-Manifest ist kein gültiges JSON: ${error.message}`); } const requiredFields = ['id', 'name', 'version', 'slug', 'port', 'healthcheck']; const missing = requiredFields.filter((field) => manifest[field] === undefined); if (missing.length > 0) { throw new Error(`Modul-Manifest unvollständig, fehlt: ${missing.join(', ')}`); } if (manifest.runtime !== 'node') { throw new Error(`Nicht unterstützte Runtime: ${manifest.runtime} (erwartet: node)`); } if (manifest.apiVersion !== 'v1') { throw new Error(`Nicht unterstützte apiVersion: ${manifest.apiVersion} (erwartet: v1)`); } return Object.freeze({ ...manifest }); } /** * Lädt die Laufzeit-Konfiguration eines Moduls. * Der Prozess-Manager setzt PORT; PLATFORM_INTERNAL_URL ist optional. */ function loadModuleConfig(env = process.env) { return Object.freeze({ port: Number(env.PORT ?? 0), nodeEnv: env.NODE_ENV ?? 'production', platformBaseUrl: env.PLATFORM_INTERNAL_URL ?? 'http://127.0.0.1:3000', serviceToken: env.MODULE_SERVICE_TOKEN ?? null, }); } /** Schwärzt sensible Schlüssel in Log-Daten (rekursiv, flach begrenzt). */ function redactSensitive(data) { if (data === null || typeof data !== 'object') { return data; } const redacted = Array.isArray(data) ? [] : {}; for (const [key, value] of Object.entries(data)) { if (SENSITIVE_KEYS.includes(key.toLowerCase())) { redacted[key] = '[geschwärzt]'; } else if (value !== null && typeof value === 'object') { redacted[key] = redactSensitive(value); } else { redacted[key] = value; } } return redacted; } /** * Erstellt einen strukturierten Logger (JSON-Zeilen auf stdout/stderr). * Logs helfen beim Debuggen und enthalten niemals Secrets. */ function createLogger(options = {}) { const moduleId = options.moduleId ?? 'unbekannt'; const minimumLevel = options.level ?? (options.debug ? 'debug' : 'info'); const levels = { debug: 10, info: 20, warn: 30, error: 40 }; function write(level, message, data) { if (levels[level] < levels[minimumLevel]) { return; } const entry = { ts: new Date().toISOString(), level, moduleId, message, ...(data !== undefined ? { data: redactSensitive(data) } : {}), }; const line = JSON.stringify(entry); if (level === 'error' || level === 'warn') { console.error(line); } else { console.log(line); } } return { debug: (message, data) => write('debug', message, data), info: (message, data) => write('info', message, data), warn: (message, data) => write('warn', message, data), error: (message, data) => write('error', message, data), }; } /** * HTTP-Client für Aufrufe vom Modul zur Plattform (internes Netz). * Der Plattform-Service-Token wird per Umgebungsvariable übergeben, * sobald die Plattform Service-Endpunkte bereitstellt. */ function createPlatformClient(options = {}) { const config = loadModuleConfig(); const baseUrl = options.baseUrl ?? config.platformBaseUrl; const serviceToken = options.serviceToken ?? config.serviceToken; const timeoutMs = options.timeoutMs ?? 5_000; async function request(method, requestPath, body) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${baseUrl}${requestPath}`, { method, headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(serviceToken ? { Authorization: `Bearer ${serviceToken}` } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!response.ok) { throw new Error(`Plattform-Aufruf fehlgeschlagen: ${method} ${requestPath} → HTTP ${response.status}`); } return await response.json(); } finally { clearTimeout(timeout); } } return { get: (requestPath) => request('GET', requestPath), post: (requestPath, body) => request('POST', requestPath, body), patch: (requestPath, body) => request('PATCH', requestPath, body), delete: (requestPath) => request('DELETE', requestPath), }; } /** * Erstellt einen HTTP-Server, der den vollständigen Modul-API-Vertrag * verdrahtet: * GET /health → Liveness/Readiness * GET /api/manifest → Manifest zur Laufzeit * GET /api/me → Aktueller Benutzer (vom Gateway übergeben) * alles andere → options.routes(req, res, context) * * @param {object} options * manifest – validiertes Manifest (loadManifest) * routes – (req, res, context) für fachliche Endpunkte * permissions – Modulrechte für /api/me (Default: []) * logger – Logger-Instanz (Default: createLogger) * identityRequired – 401 für fachliche Endpunkte ohne Identität (Default: true) * @returns {http.Server} Noch nicht gestarteter Server */ function createModuleServer(options) { const manifest = options.manifest; const routes = options.routes ?? null; const permissions = options.permissions ?? []; const logger = options.logger ?? createLogger({ moduleId: manifest.id }); const identityRequired = options.identityRequired ?? true; function sendJson(response, status, body) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); } function handleRequest(request, response) { const url = new URL(request.url ?? '/', `http://127.0.0.1:${manifest.port}`); const pathname = url.pathname; if (request.method === 'GET' && pathname === '/health') { sendJson(response, 200, healthResponse(manifest.id, manifest.version)); return; } if (request.method === 'GET' && pathname === '/api/manifest') { sendJson(response, 200, manifestResponse(manifest)); return; } if (request.method === 'GET' && pathname === '/api/me') { const identity = extractIdentity(request.headers); if (!identity) { sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' }); return; } sendJson(response, 200, meResponse(identity, manifest.id, permissions)); return; } const identity = extractIdentity(request.headers); if (identityRequired && routes && !identity) { sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' }); return; } if (routes) { try { routes(request, response, { identity, path: pathname, url, logger }); } catch (error) { logger.error('Fehler im Routen-Handler', { path: pathname, error: error.message }); if (!response.headersSent) { sendJson(response, 500, { statusCode: 500, message: 'Interner Modulfehler' }); } } return; } sendJson(response, 404, { statusCode: 404, message: 'Nicht gefunden' }); } return http.createServer(handleRequest); } module.exports = { GATEWAY_HEADERS, PLATFORM_ROLES, extractIdentity, healthResponse, manifestResponse, meResponse, loadManifest, loadModuleConfig, createLogger, createPlatformClient, createModuleServer, };