From e6219e8cf9708d573654be75faeec385467068ad Mon Sep 17 00:00:00 2001 From: MPM Dev Date: Wed, 7 Oct 2026 16:29:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=208=20=E2=80=93=20Modul-SDK=20(cr?= =?UTF-8?q?eateModuleServer,=20Config,=20Logger,=20Platform-Client)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- .../test/platform-module-sdk.spec.ts | 261 ++++++++++++- docs/PHASES.md | 33 +- modules/demo/backend/platform-module-sdk.js | 335 ++++++++++++++--- modules/demo/backend/server.js | 76 ++-- modules/demo/demo-module.zip | Bin 2766 -> 5267 bytes packages/platform-module-sdk/README.md | 111 ++++++ packages/platform-module-sdk/index.js | 347 ++++++++++++++++++ packages/platform-module-sdk/package.json | 20 + 9 files changed, 1081 insertions(+), 107 deletions(-) create mode 100644 packages/platform-module-sdk/README.md create mode 100644 packages/platform-module-sdk/index.js create mode 100644 packages/platform-module-sdk/package.json diff --git a/README.md b/README.md index 5f69e1d..da17044 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können. -**Status: Phase 6 – Modul-API (abgeschlossen)** +**Status: Phase 8 – Modul-SDK (abgeschlossen)** ## Architektur-Überblick @@ -109,6 +109,9 @@ Zweistufiges Rechtekonzept: Plattform-Rollen (ADMIN/USER) + Modul-Berechtigungen ### Phase 6 – Modul-API & SDK Verbindlicher Modul-API-Vertrag (`/health`, `/api/manifest`, `/api/me`) mit `platform-module-sdk`: Module erhalten die Benutzer-Identität sicher über Gateway-Header (Session-Cookie wird nie weitergereicht). Demo-Modul als Referenzimplementierung. +### Phase 8 – Modul-SDK +`packages/platform-module-sdk`: `createModuleServer` verdrahtet den kompletten Vertrag plus fachliche Routen; dazu Manifest-Validierung, Laufzeit-Config, strukturiertes Logging (mit Secret-Schwärzung) und ein interner Plattform-API-Client. Module binden das SDK als Einzeldatei ein (Vendor-Pattern). Phase 7 (Kalender) wurde zugunsten der Einbindung bestehender Projekte übersprungen. + ## Annahme „ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). \ No newline at end of file diff --git a/apps/platform-backend/test/platform-module-sdk.spec.ts b/apps/platform-backend/test/platform-module-sdk.spec.ts index 4b7d1cb..a1a9e08 100644 --- a/apps/platform-backend/test/platform-module-sdk.spec.ts +++ b/apps/platform-backend/test/platform-module-sdk.spec.ts @@ -1,13 +1,46 @@ /** - * SDK-Tests (Phase 6): Vertrags-Helper des Modul-SDKs. + * SDK-Tests (Phase 8): Vollständige Abdeckung des Modul-SDKs. * Das SDK ist CommonJS und wird direkt mit Node getestet. */ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const { GATEWAY_HEADERS, extractIdentity, healthResponse, + manifestResponse, meResponse, -} = require('../../../modules/demo/backend/platform-module-sdk'); + loadManifest, + loadModuleConfig, + createLogger, + createModuleServer, +} = require('../../../packages/platform-module-sdk'); + +/** Schreibt ein Test-Manifest in ein temporäres Verzeichnis. */ +function writeTestManifest(overrides = {}): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mpm-sdk-')); + const manifestPath = path.join(directory, 'module.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + id: 'testmodul', + name: 'Test-Modul', + version: '1.0.0', + slug: 'test-modul', + description: 'Test', + author: 'MPM', + runtime: 'node', + entrypoint: 'server.js', + port: 41001, + healthcheck: '/health', + apiVersion: 'v1', + ...overrides, + }), + 'utf8', + ); + return manifestPath; +} describe('platform-module-sdk', () => { describe('extractIdentity', () => { @@ -103,4 +136,228 @@ describe('platform-module-sdk', () => { expect(meResponse(identity, 'demo').permissions).toEqual([]); }); }); + + describe('manifestResponse', () => { + it('erzeugt eine vertragskonforme /api/manifest-Antwort', () => { + const manifest = loadManifest(writeTestManifest()); + expect(manifestResponse(manifest)).toEqual({ + moduleId: 'testmodul', + name: 'Test-Modul', + version: '1.0.0', + slug: 'test-modul', + apiVersion: 'v1', + status: 'healthy', + }); + }); + }); + + describe('loadManifest', () => { + it('lädt ein gültiges Manifest und friert es ein', () => { + const manifest = loadManifest(writeTestManifest()); + expect(manifest.id).toBe('testmodul'); + expect(Object.isFrozen(manifest)).toBe(true); + }); + + it('wirft bei fehlender Datei', () => { + expect(() => loadManifest('/nicht/existent/module.json')).toThrow( + /nicht lesbar/, + ); + }); + + it('wirft bei unvollständigem Manifest', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mpm-sdk-')); + const manifestPath = path.join(directory, 'module.json'); + fs.writeFileSync(manifestPath, JSON.stringify({ id: 'x' }), 'utf8'); + expect(() => loadManifest(manifestPath)).toThrow(/unvollständig/); + }); + + it('wirft bei nicht unterstützter Runtime', () => { + expect(() => loadManifest(writeTestManifest({ runtime: 'python' }))).toThrow( + /Runtime/, + ); + }); + + it('wirft bei nicht unterstützter apiVersion', () => { + expect(() => loadManifest(writeTestManifest({ apiVersion: 'v2' }))).toThrow( + /apiVersion/, + ); + }); + }); + + describe('loadModuleConfig', () => { + it('liest PORT und Defaults', () => { + const config = loadModuleConfig({ PORT: '41042', NODE_ENV: 'production' }); + expect(config.port).toBe(41042); + expect(config.nodeEnv).toBe('production'); + expect(config.platformBaseUrl).toBe('http://127.0.0.1:3000'); + expect(config.serviceToken).toBeNull(); + }); + + it('liest PLATFORM_INTERNAL_URL und MODULE_SERVICE_TOKEN', () => { + const config = loadModuleConfig({ + PORT: '41001', + PLATFORM_INTERNAL_URL: 'http://plattform:3000', + MODULE_SERVICE_TOKEN: 'test-token', + }); + expect(config.platformBaseUrl).toBe('http://plattform:3000'); + expect(config.serviceToken).toBe('test-token'); + }); + }); + + describe('createLogger', () => { + it('schreibt strukturierte JSON-Logs auf stdout', () => { + const logLines: string[] = []; + const originalLog = console.log; + console.log = (line: string) => logLines.push(line); + + const logger = createLogger({ moduleId: 'testmodul' }); + logger.info('Testmeldung', { benutzer: 'max' }); + + console.log = originalLog; + + expect(logLines).toHaveLength(1); + const entry = JSON.parse(logLines[0]); + expect(entry.level).toBe('info'); + expect(entry.moduleId).toBe('testmodul'); + expect(entry.message).toBe('Testmeldung'); + expect(entry.data).toEqual({ benutzer: 'max' }); + }); + + it('schwärzt sensible Schlüssel', () => { + const logLines: string[] = []; + const originalError = console.error; + console.error = (line: string) => logLines.push(line); + + const logger = createLogger({ moduleId: 'testmodul' }); + logger.warn('Login-Versuch', { password: 'geheim', username: 'max' }); + + console.error = originalError; + + const entry = JSON.parse(logLines[0]); + expect(entry.data.password).toBe('[geschwärzt]'); + expect(entry.data.username).toBe('max'); + }); + + it('filtert Meldungen unterhalb des Minimal-Levels', () => { + const logLines: string[] = []; + const originalLog = console.log; + console.log = (line: string) => logLines.push(line); + + const logger = createLogger({ moduleId: 'testmodul', level: 'warn' }); + logger.debug('unsichtbar'); + logger.info('auch unsichtbar'); + + console.log = originalLog; + expect(logLines).toHaveLength(0); + }); + }); + + describe('createModuleServer', () => { + const manifest = loadManifest(writeTestManifest()); + const identityHeaders = { + [GATEWAY_HEADERS.userId]: 'user-1', + [GATEWAY_HEADERS.username]: 'max', + [GATEWAY_HEADERS.displayName]: 'Max', + [GATEWAY_HEADERS.role]: 'USER', + }; + + function request( + server: ReturnType, + requestPath: string, + headers: Record = {}, + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const httpRequest = require('node:http'); + const req = httpRequest.get( + { host: '127.0.0.1', port, path: requestPath, headers }, + (res: import('node:http').IncomingMessage) => { + let body = ''; + res.on('data', (chunk: string) => { + body += chunk; + }); + res.on('end', () => { + server.close(); + resolve({ status: res.statusCode ?? 0, body }); + }); + }, + ); + req.on('error', (error: Error) => { + server.close(); + reject(error); + }); + }); + }); + } + + it('beantwortet /health nach Vertrag', async () => { + const server = createModuleServer({ manifest }); + const response = await request(server, '/health'); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ + moduleId: 'testmodul', + version: '1.0.0', + status: 'healthy', + }); + }); + + it('beantwortet /api/manifest nach Vertrag', async () => { + const server = createModuleServer({ manifest }); + const response = await request(server, '/api/manifest'); + expect(response.status).toBe(200); + expect(JSON.parse(response.body).moduleId).toBe('testmodul'); + }); + + it('beantwortet /api/me mit Identität', async () => { + const server = createModuleServer({ manifest, permissions: ['test.read'] }); + const response = await request(server, '/api/me', identityHeaders); + expect(response.status).toBe(200); + const body = JSON.parse(response.body); + expect(body.user.username).toBe('max'); + expect(body.permissions).toEqual(['test.read']); + }); + + it('antwortet 401 auf /api/me ohne Identität', async () => { + const server = createModuleServer({ manifest }); + const response = await request(server, '/api/me'); + expect(response.status).toBe(401); + }); + + it('leitet fachliche Routen an den routes-Handler weiter', async () => { + const server = createModuleServer({ + manifest, + routes: ( + _req: unknown, + res: import('node:http').ServerResponse, + context: { path: string; identity: { username: string } }, + ) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ path: context.path, user: context.identity.username })); + }, + }); + const response = await request(server, '/aufgaben', identityHeaders); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ path: '/aufgaben', user: 'max' }); + }); + + it('blockiert fachliche Routen ohne Identität (identityRequired)', async () => { + const server = createModuleServer({ + manifest, + routes: (_req: unknown, res: import('node:http').ServerResponse) => { + res.writeHead(200); + res.end('ok'); + }, + }); + const response = await request(server, '/aufgaben'); + expect(response.status).toBe(401); + }); + + it('antwortet 404 ohne routes-Handler', async () => { + const server = createModuleServer({ manifest }); + const response = await request(server, '/andere', identityHeaders); + expect(response.status).toBe(404); + }); + }); }); \ No newline at end of file diff --git a/docs/PHASES.md b/docs/PHASES.md index 7431585..aa3314a 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -10,8 +10,8 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S | 4 | Gateway & dynamisches Routing (`/slug`) | ✅ Abgeschlossen | | 5 | Berechtigungssystem (User ↔ Module) | ✅ Abgeschlossen | | 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ✅ Abgeschlossen | -| 7 | Referenzmodul Kalender | ⏳ Geplant | -| 8 | Modul-SDK (`platform-module-sdk`) | ⏳ Geplant | +| 7 | Referenzmodul Kalender | ⏳ Übersprungen (bestehende Projekte werden stattdessen eingebunden) | +| 8 | Modul-SDK (`platform-module-sdk`) | ✅ Abgeschlossen | | 9 | Administration (Übersichten, Audit-UI, Einstellungen) | ⏳ Geplant | | 10 | Security Hardening & Produktivbetrieb | ⏳ Geplant | @@ -105,9 +105,28 @@ Definition of Done: `GET /health`, `GET /api/manifest`, `GET /api/me` plus zentr - [x] Backend-Tests: 98 bestanden (inkl. 8 SDK-Tests) - [x] E2E verifiziert: `/demo/health` → vertragskonform, `/demo/api/manifest` → Manifest, `/demo/api/me` → Identität vom Gateway (admin/ADMIN, Permissions); direkter Aufruf ohne Identität → 401; ohne Login → 401 vom Gateway -## Nächste Schritte (Phase 7 – Referenzmodul Kalender) +## Phase 7 – Referenzmodul Kalender (übersprungen) -- Vollständiges Kalender-Modul (Dashboard, Kalender, Termine, Teilnehmer, Einstellungen) -- Eigenes PostgreSQL-Schema (`calendar.*`) mit Migrationen -- Frontend mit eigener Oberfläche, integriert in das Plattform-Design -- Dient als Referenzimplementierung für zukünftige Module \ No newline at end of file +Entscheidung: Das Kalender-Modul wird zugunsten der Einbindung bestehender Projekte übersprungen. Das Demo-Modul dient als Referenzimplementierung des Modul-Vertrags. + +## Phase 8 – Modul-SDK (abgeschlossen) + +Definition of Done: `platform-module-sdk` mit Authentication, Current User, Permissions, API Client, Module Config, Logging, Healthcheck. + +- [x] SDK-Paket `packages/platform-module-sdk` (CommonJS, vendor-fähig als Einzeldatei) +- [x] `createModuleServer`: verdrahtet den vollständigen Vertrag (health, manifest, me) + fachliche Routen mit Identitäts-Context +- [x] `extractIdentity`: Gateway-Header (case-insensitive, Rollen-Validierung, 401-Verhalten) +- [x] `loadManifest`: Manifest-Validierung (Pflichtfelder, Runtime, apiVersion) +- [x] `loadModuleConfig`: PORT, NODE_ENV, PLATFORM_INTERNAL_URL, MODULE_SERVICE_TOKEN +- [x] `createLogger`: strukturierte JSON-Logs mit automatischem Schwärzen sensibler Schlüssel +- [x] `createPlatformClient`: interner HTTP-Client zur Plattform (Service-Token vorbereitet) +- [x] Demo-Modul vollständig auf das SDK umgestellt (Vendor-Pattern) +- [x] Backend-Tests: 116 bestanden (inkl. 26 SDK-Tests: Vertrag, Manifest, Config, Logger-Schwärzung, Server-Verhalten) +- [x] E2E verifiziert: SDK-Vertrag über Gateway (health/manifest/me), fachliche Route, 404, 401 ohne Identität + +## Nächste Schritte (Phase 9 – Administration) + +- Audit-Log-UI (durchsuchen, filtern) +- Systemeinstellungen (Backend + UI) +- Systemstatus-Dashboard erweitern (Modul-Healths) +- Danach: Einbindung bestehender Projekte als Module (ersetzt Phase 7) \ No newline at end of file diff --git a/modules/demo/backend/platform-module-sdk.js b/modules/demo/backend/platform-module-sdk.js index b55e7a0..c77df8c 100644 --- a/modules/demo/backend/platform-module-sdk.js +++ b/modules/demo/backend/platform-module-sdk.js @@ -1,39 +1,70 @@ +'use strict'; + /** - * MPM Modul-SDK – Authentifizierungs-Helper (Phase 6). - * CommonJS-Variante (Module laufen als Node-CommonJS-Prozesse). + * 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. * - * Jedes Modul läuft hinter dem Modul-Gateway der Management-Plattform. - * Der Gateway authentifiziert den Benutzer zentral und übergibt die - * Identität über interne Header. Module dürfen diese Header NIE - * direkt von außen akzeptieren – deshalb stellt dieses SDK sicher, - * dass die Identität nur aus dem Gateway-Flow stammt. + * 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 vom Gateway gesetzt, nicht vom Client + * - 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 = { +const GATEWAY_HEADERS = Object.freeze({ userId: 'x-user-id', username: 'x-user-username', displayName: 'x-user-display-name', role: 'x-user-role', -}; +}); -/** Vom Gateway übergebene Benutzer-Identität. */ -// interface GatewayIdentity { -// userId: string; -// username: string; -// displayName: string; -// role: 'ADMIN' | 'USER'; -// } +/** 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 Identität oder null, wenn der Request nicht über den - * Gateway kam (z. B. direkter Aufruf ohne Plattform). + * @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); @@ -44,40 +75,30 @@ function extractIdentity(headers) { if (!userId || !username || !role) { return null; } - if (role !== 'ADMIN' && role !== 'USER') { + if (!PLATFORM_ROLES.includes(role)) { return null; } return { userId, username, displayName: displayName ?? username, role }; } -/** 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) { - if (Array.isArray(value)) { - return value[0] ?? null; - } - return value ?? null; - } - } - return null; -} - -/** - * Standard-Antworten des Modul-API-Vertrags (Phase 6): - * Jedes Modul muss diese drei Endpunkte bereitstellen: - * GET /health → Liveness/Readiness - * GET /api/manifest → Manifest zur Laufzeit - * GET /api/me → Aktueller Benutzer (vom Gateway übergeben) - */ - /** 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 { @@ -92,9 +113,235 @@ function meResponse(identity, 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, }; \ No newline at end of file diff --git a/modules/demo/backend/server.js b/modules/demo/backend/server.js index f5067d9..11cd24a 100644 --- a/modules/demo/backend/server.js +++ b/modules/demo/backend/server.js @@ -1,70 +1,40 @@ /** - * Demo-Modul: Referenzimplementierung des Modul-API-Vertrags (Phase 6). + * Demo-Modul: Referenzimplementierung auf Basis des Modul-SDKs (Phase 8). * Läuft als eigener Node-Prozess im Management-Container (Port via ENV PORT). * - * Vertrag (verbindlich für alle Module): + * Der vollständige Modul-API-Vertrag wird vom SDK verdrahtet: * GET /health → Liveness/Readiness * GET /api/manifest → Manifest zur Laufzeit * GET /api/me → Aktueller Benutzer (vom Gateway übergeben) - * - * Die Identität stammt ausschließlich aus den Gateway-Headern - * (siehe platform-module-sdk.js) – niemals aus der URL oder dem Body. + * GET / → fachliche Route (routes-Handler) */ -const http = require('node:http'); +const path = require('node:path'); const { - extractIdentity, - healthResponse, - meResponse, + createModuleServer, + loadManifest, + createLogger, } = require('./platform-module-sdk'); -const PORT = Number(process.env.PORT ?? 41001); +const manifest = loadManifest(path.join(__dirname, '..', 'module.json')); +const logger = createLogger({ moduleId: manifest.id }); +const port = Number(process.env.PORT ?? manifest.port); -/** Manifest des Moduls (identisch zu module.json). */ -const MANIFEST = { - moduleId: 'demo', - version: '1.0.0', - status: 'healthy', -}; - -const server = http.createServer((request, response) => { - const url = new URL(request.url ?? '/', `http://127.0.0.1:${PORT}`); - - if (url.pathname === '/health') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify(healthResponse(MANIFEST.moduleId, MANIFEST.version))); - return; - } - - if (url.pathname === '/api/manifest') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify(MANIFEST)); - return; - } - - if (url.pathname === '/api/me') { - const identity = extractIdentity(request.headers); - if (!identity) { - // Request kam nicht über den Modul-Gateway → keine Identität. - response.writeHead(401, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ statusCode: 401, message: 'Keine Identität übergeben' })); +const server = createModuleServer({ + manifest, + logger, + permissions: ['demo.read'], + routes: (request, response, context) => { + if (request.method === 'GET' && context.path === '/') { + response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Demo-Modul läuft'); return; } - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify(meResponse(identity, MANIFEST.moduleId, ['demo.read']))); - return; - } - - if (url.pathname === '/') { - response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); - response.end('Demo-Modul läuft'); - return; - } - - response.writeHead(404, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ statusCode: 404, message: 'Nicht gefunden' })); + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ statusCode: 404, message: 'Nicht gefunden' })); + }, }); -server.listen(PORT, '127.0.0.1', () => { - console.log(`Demo-Modul läuft auf Port ${PORT}`); +server.listen(port, '127.0.0.1', () => { + logger.info('Demo-Modul gestartet', { port }); }); \ No newline at end of file diff --git a/modules/demo/demo-module.zip b/modules/demo/demo-module.zip index ee2ec8863f0675cf552acc641cb1593896dfaac8..4bd8500645b3e691450e8cc36a35762aafd8c8ef 100644 GIT binary patch delta 4911 zcmV+~6VU9=6_Y6&P)h>@6aWAK2mkiu)7WriNiT(&YW}RJ3k{QPo+q+B$#B*^Upu~{IjhmPd@)lJQ0T{ zhvG2y^RP48doA3Pxrd2=-?aGMPV*nX8)aVPdx<{|#$ga=b9pJI_a74C*I|CLD+WQN zc#9y-gv65!W->~%``;oT-xB6@6#KGslEk+%O+^qzGMRhfSoks(hhF5(7?!J;W9(G}Gzl(#Pro}J9$pNO4+iTSIdl4Zhx{{bpP$(;`QLChDB+6&XOD?PPEd@ zW2PT7@)-R+<+H9IJfLix1h zMkCQr67QxPr2OgfVlDAPt$Mb7E?&F<3WXu?j)%&NO${<3cZFpX!Lfa51KqifVRT`MA zP;ZX;rMD37ZCAYPLbgO+l6h4?Q!%ADN3E`gqr&2nwl&GL7&nDIY=bL2U`$$fj~b$M z)nw%cC3&RW<)IAN=PVW$vOJKBMPW04BD8rR2b!u*mU7BRS`EMcDjpSEq6eO6T(asx z+O3$p3RvABnuIwLJ(tNpMsGRhowh~0P-?r1y0)U=psddJ$em46(UgnT zFhb27%5({hN_WB0o`lE-!+d7Fq(}V*yLr1tTn-xgx`KH8kCfD$ zO4rpR3&jNb(6=ocEvvUFyDf5mHSn`^3Nm_4}v&CfW+M#Knw#(imOx{j#iZhz63M-#@*`_Ee?%=VkO=h2_#?&Ld@he ziBU|cDv_Bh%50v**B~4uc)vo73dB^-kvIY2w(@9p{~^q%_TVtLo_lxf-}Q2j1IZE+J#D)h*~@@)EzqkwpYlq7?DA`gL-1@N)# zB9hmFrFY-eG|dqZGNdRHdgAe&y}(MikMEdWcY*2vb@QtAu4UsV$!;j>8!+S8#Sf$7 zqb}@+!+~x6SMpNXe@PyHW3-+!cM`oSX;n}ird09|b;{7^gXmBaE*zCb3rE8ZeI;xi@a1`VeiKPzh6w)-u zE*xwI-~Lu?KUh0?bcKxGbVJ(grWu=6^ZYpuBA2$)vJ|A3HL#g~IalXQVbQn?M;T~ip+iO;mAQMV%^#(MG4+YkgKPkD25#pEJ@1YiZEAC zYp$)KxmL%CZns-FHrG}S@J6Adxp%=eM5@p(Rk%h4xo%Z;RC6j(^`$sD9-clg3$p#A z)4}kle{k_;c)-VhAYKwnFI-DL8F3i?RDZ>l(G~o2nZy&Uf*Rqf8h#pTRX{oXz9)|I z1+rpd&?Rvw`PpunI1c*_qN|>uw@1f&gNwn@TbkiuB|o2(1y>niUZU=JlZ1+D&E(-ND7_@$12nDMnNrvMoiG zsxC#jd>bQ$D~ng^exYjZaF;>2Nc(em%N5R2NYQ^RX9y9u4JDqBe1BxhLD%;(PivXn znjZPQbG?N0HoI9$lx;lD{;i^tQ_omwLU}?AjL}+>N{4ENrWSpH`~oG}-Rcu-My9S5 zjMcil%&pgd3dvTbc2v^QYQQ+*oLR;Hxnqh$A;VPiDynvTWR&y87e+bi#ZHrU)|aUp zKvk>N)ix6IoInp9CW22#8G$Ua0z*`?^z`Kv?#kGNfzW`3M!Tlhe{(zy+{ z#O5-bVh*=W_T)GRxoU&1_sVoLbM4@(R-{_lRq#N6QSNS4sXL3TZAU{$+AW6Y@a551 zUV2fKIh8OM1&e%fAg^#lO2UwzL1mWf`XoAh(BUr*9MK7)Y3$hLB2%$sf@k%_)9tpP zX+7~Jz5$eRvcJMNj_Ez|Y@1U__Gh!%>mC zpYyeU&WG&^Mrb-FHzc!}nxT@Q7(__nws0W!N5>=1PTf|KS}Xa?Q&A8U16pqLjPT+X zm{8;fQ0E=*thWJYR(#&gs;lR=+#xua984w8!Su%E-I(K1=t&(mR=PtdWet)|?DI#= zQ6F@9vP+9>I&5ZU7|*Kn8VYgsMmvRDvno=5C}ni*TXnPR?JGapOw8`MfmV6mW?I(5 z23qZ_n~9ZP8>!a{rL{X1bCW`yb#((qRZe*arEge}T^u2MXj#cc(d`|nz}ex*DXxRW zr-DtqSiC1(C56ST+nbUZ%5SqmIB2M*mR=v0JXD! z%@{RN7ehPixr~h?W0g%|u)uWwpTb!_?wXj)DB^ z3#2X@ySx-NoUn2R?n1(0vU%*c&9kcQ#<71>9JfL#SwbEAgj&Qvp8gm~+HNuGwkii- zi-EbwCr{Ob>#{?ZoVp(I0AQ6nFqpN>oiO{{8oVFk11rUQIRH zOo>^5J^g8zca42o~2 zj9w#=_7Z*M-I8(9+#jK;ux_Bsc&6{c1-uK9(xVQZv=|u#PvBm3hai7{A)TgeK5_DW z#^jZDq-iEizN7h7tl<~EgUP&KpK{x{R@(HB&;+a;2+fK_R!0ZK^hD(|lOs>K8X#ca(U zCEK-ESb$VsueMSd`9Bay8^|`-O|?v=Ky=P|=`zqY^BxwL4XPJ^jIglV6eBC$y7C$g zXF8Pne%ByoyWm#KsB%soDGa_D9+=tPTqTgs77+E_S}V%xB~p*94077kz+QbV3qD=* zsP(DBvROz)R8?P5FrwZ^Nl&91Pwm31!4h9?qg1Y6AX@isYxilbGB7t4yq_qejo}ZG zbn{i>|6VLvuUX4~(*O(AujvhUb6sCerVL>vlWN!`kdC}v`TZ{a&PWaEx(|N;nxe0b zcV&cf>>RG92P=)*sN2SSNZS`I)cJ_MZI}LZg^{|}kOdaf0Eur+%KX2ovU!8-QY@4u zN2TyF0eKxu+I$OYbWG0ID_pQmhrex5+!ABMwU;P@u5PG*IN#9?V7+(x76ej{KEaQQQ#di(~no&^hUI|O3gJOJ8L$SbeHQD5o%+zv1E2o~P)h>@6aWAK z2mniiM_rZqO3&>A004polTHN}e{ED-ZWA#S{jbzL+^>34Wtv;OXZQKQCjhX z5Hj(diDAYbY)?v4<(CDphAxA@?u3@9(rE z+O>dZlQ-#mwBCvwu9cMpDuGnN0k zTdgc6Oj8Vl@R4;+H<(+hftf{;5*@`$wB%hkd@hWTo9T9UxRJ^8e~4JM$$76TgwKpE z(=rmJPJWF8ZHC(VINW1Sf+GIKD4l%zB$d^o#4a$-8C@GH{>2&1Iy==xlt4snMaeBR z@HcX>9gHgpt6gCgdTa>ex+Ke!%9EBs2xJ<;jCbyiex|^=Pt7rwN@aotZr=atcdW+KX72S?s~cPM1GWO9KQH z000080AhJYlO+k;4giBkT~rRyrG*aw0LLwp4hlXXOM^#UmHA4~?E(M*f&~Bo5dZ)H h0000000000006-clWYn-0^Jgms|p@6aWAK2mm^QM_pi80>b(P001crkr*U@l~&De8#fTX8}K`f zFOg*Iu96;Fz=n}3wi-LNT*FEo6oyey+F6O!E>|JBvZYu+Pkn&)0eZ4F|AJnorzkX!_==;<#64w)xwTF zjIExP$Rq_-mY=*{|A#oeqfjhKbVcpZ2AU$*O5y}#abTBysM@3mA@Yb3y!z(5;3fUP zg0`SNH~&O`RG^M244{-qpCcwad5}8Ygo=nGZSsX7b(p7ux}MG`$|7~Gq~6~zbKj~O zBQDF_9ZJ)rMOqdG6QV?~vLCt`V)F)Nmad~CG9x{gz! zG`#J-?|~?tSxr)#qW$E7k=|u(A{@&K`0#kJ<8~ z0m&Er3MSLIW;egg`ueaa=(?usIIqfCr7=^4<}Gu8#O?Ws*xb)0CNq8!U4BuPeBO@Z^aGJSq#1Ox3@{Q~fR2-}T8V z)Tu#Armf}X<*i|{Y=-$;7hmeYE!4Vff3uy9>tv{z2$>B1p3rxx)MoS+F9*MOdFEde z=aNlS^-ei>^ukCoH&4kN(m;d02OkCEXhR_s}I4V?8bE{rbnB zB!vZC$Vzpuh+uBM)$wf3y9pCKGJ3K2LGf!vgZ7*z?TWT~@6aWAK2mn-pM_nz+`-rmx002!1000pH zlQ9$%f4x>sZrer_-W%{8UV#E61!*V+Mq4OQ11E7)x3(hKZZ<^$kHtrEYI4ZV4CP9O zfUbIg_5j`F7}@D2IZ5Blh!UMR0qjMzBA?#x_rCdtai_Dt2OW5cMcF$olR6*58K$UE zE=5u0SfDflRV`sy&oqRSe~fE{ZzAS0ovh#1sR5pAKrus!3dUuE zy@@K9s5OWJPPybWm$~<%l!goI6i!NIU@kZujo-lJ_1T3_=#p-VK{!WsC1jF|X$I-~ zouWE9`mQhZf8a#SsU5B3GvtY|Pi=o*iMZfWq^OPeKW*NY zf3<=WUZ+bG=A#g7mpHsOHBv_ko};YI(#|z63wXs1-tYyi-(8`a;T6hgJKIZvaGY3} z&HAkYZFo@-ezn%qEEl-`&kalOv;f&;>itMFppq^_s0C&SRnARXs-jmoIC?s{X20tQ z-v9M4NP&f2iBF^8m$MTn?L&eEJTH@lf5ST7-`n4tmXc=9jHzG*3je5u!Z48JJ+#Ik zdfNEhQVigqw3(^dOt9!$o5%hPbwz&YOa-=HSMAI!zNc|lrKw}dKoR4*AoRjYl~dXs zLpf*epJ&hDV9@Umd`R5s>^8ThEYVcB4hdA+C-_PPPbnkj2KS{-568zp9G%;8e{MSO zcbp6%Af(E`%|ay9qLgGCus-X1Bkj%9nrwdfg|)6)LugbKM|mxXY^snJdG2gsXtAJn zy9A2ID1y=Vz5yRxtDGVw-q^)8?#xf zkRn*cO(r?PK_ z*diIg$`-qm1!WTcbpCqGv{6FNM7jv?vLkFDXARcf?H1Su=_uOeHMNrNW%b#y+Q<1z z4%f8)$c}N>tKYoXoGV)Y2PbVygUm^RZVKA;Uo~OdYyfdg%I+fInir%9f78r(L2|<4 zmAw%vD`wY7BH5BA)?~T|#e@Ffp(uVH$J@<;dSP`3T>64eJ>46W_2&;dYw4;j(5|or z-@*T7IIz>Yu-QY4(>t2~>NJIs6ePiKkKp*Dcx{MAR&j-V3e$`$jb>DvwD;Yoh6{Gz z29UeUJ@|B|_fq5FVcm+%Y~*cLMx43((gtsNkPNA zqFd7Ske9RY@}u$T5>3H<{Q00000000000000005%1aF%&%l7zvYb6deZi2><{90M?s}t^fc4 diff --git a/packages/platform-module-sdk/README.md b/packages/platform-module-sdk/README.md new file mode 100644 index 0000000..ccdb985 --- /dev/null +++ b/packages/platform-module-sdk/README.md @@ -0,0 +1,111 @@ +# platform-module-sdk + +Offizielles SDK für MPM-Module (Phase 8). Es verdrahtet den verbindlichen +Modul-API-Vertrag und stellt Authentifizierung, Konfiguration, Logging und +einen Plattform-API-Client bereit. + +## Installation (Vendor-Pattern) + +Module sind selbstständige ZIP-Pakete. Das SDK wird als **eine Datei** +(`index.js`) in das Modul-Paket kopiert (z. B. `backend/platform-module-sdk.js`) +und per `require` geladen. Dadurch bleiben Module ohne npm-Registry lauffähig. + +```text +mein-modul/ +├── module.json +├── backend/ +│ ├── server.js # Einstiegspunkt (Manifest: entrypoint) +│ └── platform-module-sdk.js # vendored SDK (Kopie von index.js) +└── README.md +``` + +## Schnellstart + +```js +'use strict'; + +const path = require('node:path'); +const { + createModuleServer, + loadManifest, + createLogger, +} = require('./platform-module-sdk'); + +const manifest = loadManifest(path.join(__dirname, '..', 'module.json')); +const logger = createLogger({ moduleId: manifest.id }); +const port = Number(process.env.PORT ?? manifest.port); + +const server = createModuleServer({ + manifest, + logger, + permissions: ['meinmodul.read'], + routes: (request, response, context) => { + // context.identity → { userId, username, displayName, role } + // context.path → URL-Pfad + if (request.method === 'GET' && context.path === '/aufgaben') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ aufgaben: [] })); + return; + } + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ statusCode: 404, message: 'Nicht gefunden' })); + }, +}); + +server.listen(port, '127.0.0.1', () => { + logger.info(`Modul gestartet`, { port }); +}); +``` + +Der Server verdrahtet automatisch den Vertrag: + +| Endpunkt | Verhalten | +|---|---| +| `GET /health` | 200 `{ moduleId, version, status: "healthy" }` | +| `GET /api/manifest` | 200 Manifest + Status | +| `GET /api/me` | 200 Identität vom Gateway (401 ohne Identität) | +| fachliche Routen | `options.routes(...)`; ohne Identität 401 (`identityRequired`) | + +## API + +### `extractIdentity(headers)` +Liest die Benutzer-Identität aus den Gateway-Headern (`x-user-id`, +`x-user-username`, `x-user-display-name`, `x-user-role`). Case-insensitive; +`null`, wenn der Request nicht über den Gateway kam. + +### `loadManifest(manifestPath)` +Lädt und validiert `module.json` (Pflichtfelder, Runtime `node`, apiVersion `v1`). + +### `loadModuleConfig(env?)` +Laufzeit-Konfiguration: `port` (ENV `PORT`), `nodeEnv`, `platformBaseUrl` +(ENV `PLATFORM_INTERNAL_URL`, Default `http://127.0.0.1:3000`), `serviceToken`. + +### `createLogger({ moduleId, level })` +Strukturierte JSON-Logs (`debug/info/warn/error`). Sensible Schlüssel +(Passwort, Token, Secret, Cookie, …) werden automatisch geschwärzt. + +### `createPlatformClient({ baseUrl, serviceToken, timeoutMs }))` +HTTP-Client für interne Aufrufe zur Plattform (`get/post/patch/delete`). +Wirft bei Nicht-2xx einen Fehler mit Statuscode. + +### `createModuleServer(options)` +Siehe Schnellstart. Optionen: `manifest`, `routes`, `permissions`, +`logger`, `identityRequired` (Default `true` – sicherer Default). + +## Tests + +Die SDK-Tests laufen im Backend-Test-Suite +(`apps/platform-backend/test/platform-module-sdk.spec.ts`): + +```bash +cd apps/platform-backend && npm test +``` + +## Sicherheitsregeln für Modul-Autoren + +- Nur auf `127.0.0.1` lauschen (der Gateway ist der einzige Zugang). +- Identität ausschließlich über `extractIdentity` beziehen – niemals aus + URL, Body oder eigenen Headern vertrauen. +- Keine Plattform-Secrets erwarten: Der Prozess-Manager übergibt nur + `PATH`, `NODE_ENV` und `PORT`. +- Logs niemals Passwörter/Tokens/personenbezogene Daten schreiben lassen. \ No newline at end of file diff --git a/packages/platform-module-sdk/index.js b/packages/platform-module-sdk/index.js new file mode 100644 index 0000000..c77df8c --- /dev/null +++ b/packages/platform-module-sdk/index.js @@ -0,0 +1,347 @@ +'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, +}; \ No newline at end of file diff --git a/packages/platform-module-sdk/package.json b/packages/platform-module-sdk/package.json new file mode 100644 index 0000000..f3e4d0b --- /dev/null +++ b/packages/platform-module-sdk/package.json @@ -0,0 +1,20 @@ +{ + "name": "platform-module-sdk", + "version": "1.0.0", + "description": "MPM Modul-SDK: Authentifizierung, Vertrag-Endpunkte, Config, Logging und API-Client für MPM-Module", + "main": "index.js", + "files": [ + "index.js", + "README.md" + ], + "keywords": [ + "mpm", + "module", + "sdk" + ], + "license": "UNLICENSED", + "private": true, + "engines": { + "node": ">=20" + } +} \ No newline at end of file