From a5f8db4397850a115781770187122a6d13948a80 Mon Sep 17 00:00:00 2001 From: MPM Module Upload Date: Wed, 7 Oct 2026 21:14:56 +0200 Subject: [PATCH] Initial import of calendar MVP --- .gitignore | 7 + README.md | 21 ++ backend/platform-module-sdk.js | 381 +++++++++++++++++++++++++++++++++ backend/server.js | 34 +++ frontend/index.html | 15 ++ module.json | 13 ++ 6 files changed, 471 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/platform-module-sdk.js create mode 100644 backend/server.js create mode 100644 frontend/index.html create mode 100644 module.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c16817b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.sqlite +*.sqlite3 +*.db +*.log +node_modules/ +calendar-mvp-*.zip +calendar-mvp-*.sha256 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e0266d --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Kalender MVP + +Ein kleines, eigenständiges Kalender-Modul für MPM. Es zeigt einen Monatskalender, erlaubt Termine anzulegen und speichert sie in SQLite. + +## Voraussetzungen + +- Node.js 24 oder neuer (verwendet `node:sqlite`) +- Installation als MPM-Modul aus einem Paket mit `module.json` im ZIP-Stammverzeichnis + +MPM stellt die Modul-Identität über den Module Gateway bereit. Termine werden je MPM-Benutzer getrennt gespeichert. Die SQLite-Datei liegt derzeit im temporären Systemverzeichnis und ist daher nicht für dauerhafte Produktivdaten gedacht. + +## Marketplace-Release + +Ein installierbares Release enthält genau ein Modul-ZIP sowie eine Prüfsummendatei mit dem Namen `.sha256`. MPM prüft SHA-256 und Modulmanifest vor der Installation. Nach der Installation kann das Modul in der MPM-Modulverwaltung separat gestartet werden. + +## Projektstruktur + +- `module.json` – MPM-Modulmanifest +- `backend/server.js` – HTTP- und SQLite-Logik +- `backend/platform-module-sdk.js` – MPM-Gateway-Integration +- `frontend/index.html` – Kalenderoberfläche diff --git a/backend/platform-module-sdk.js b/backend/platform-module-sdk.js new file mode 100644 index 0000000..2f5f063 --- /dev/null +++ b/backend/platform-module-sdk.js @@ -0,0 +1,381 @@ +'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'); +const { createHmac, timingSafeEqual } = require('node:crypto'); + +/** 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 }; +} + +/** Validate the gateway's signature, bound to this module and exact HTTP request. */ +function extractVerifiedIdentity(headers, request, moduleId, identityKey) { + const identity = extractIdentity(headers); + const timestamp = readHeader(headers, 'x-mpm-identity-timestamp'); + const supplied = readHeader(headers, 'x-mpm-identity-signature'); + if (!identity || !timestamp || !supplied || !identityKey) return null; + const timestampNumber = Number(timestamp); + if (!Number.isSafeInteger(timestampNumber) || Math.abs(Date.now() - timestampNumber) > 30_000) { + return null; + } + const fields = [moduleId, request.method, request.url ?? '/', timestamp, + identity.userId, identity.username, identity.displayName, identity.role]; + const expected = createHmac('sha256', identityKey).update(JSON.stringify(fields)).digest(); + let actual; + try { + actual = Buffer.from(supplied, 'hex'); + } catch { + return null; + } + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null; + return identity; +} + +/** 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; + const identityKey = options.identityKey ?? process.env.MPM_MODULE_IDENTITY_KEY ?? null; + const requireSignedIdentity = options.requireSignedIdentity ?? process.env.NODE_ENV === 'production'; + + function requestIdentity(request) { + if (identityKey) { + return extractVerifiedIdentity(request.headers, request, manifest.id, identityKey); + } + return requireSignedIdentity ? null : extractIdentity(request.headers); + } + + 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 = requestIdentity(request); + if (!identity) { + sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' }); + return; + } + sendJson(response, 200, meResponse(identity, manifest.id, permissions)); + return; + } + + const identity = requestIdentity(request); + 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, + extractVerifiedIdentity, + healthResponse, + manifestResponse, + meResponse, + loadManifest, + loadModuleConfig, + createLogger, + createPlatformClient, + createModuleServer, +}; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..dcad45d --- /dev/null +++ b/backend/server.js @@ -0,0 +1,34 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { DatabaseSync } = require('node:sqlite'); +const { createModuleServer, loadManifest, createLogger } = require('./platform-module-sdk'); +process.umask(0o077); +const manifest = loadManifest(path.join(__dirname, '..', 'module.json')); +const logger = createLogger({ moduleId: manifest.id }); +const db = new DatabaseSync(path.join(os.tmpdir(), manifest.id + '.sqlite')); +db.exec('CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, title TEXT NOT NULL CHECK(length(title)<=120), event_date TEXT NOT NULL, notes TEXT NOT NULL DEFAULT "" CHECK(length(notes)<=1000), created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); +const listEvents = db.prepare('SELECT id,title,event_date,notes FROM events WHERE user_id=? AND event_date>=? AND event_date16384) throw new Error('too large'); } return JSON.parse(data||'{}'); } +const server = createModuleServer({manifest,logger,routes:async(req,res,{path:pathname,url,identity})=>{ + if(req.method==='GET'&&(pathname==='/'||pathname==='/index.html')){res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});res.end(html);return;} + if(pathname==='/api/events'&&req.method==='GET'){ + const month=url.searchParams.get('month')||new Date().toISOString().slice(0,7); + if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))return json(res,400,{error:'Ungültiger Monat.'}); + const start=month+'-01',next=new Date(Date.UTC(Number(month.slice(0,4)),Number(month.slice(5,7)),1)).toISOString().slice(0,10); + return json(res,200,{events:listEvents.all(identity.userId,start,next)}); + } + if(pathname==='/api/events'&&req.method==='POST'){ + try {const data=await bodyJson(req); + if(typeof data.title!=='string'||!data.title.trim()||data.title.trim().length>120||typeof data.event_date!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(data.event_date)||Number.isNaN(Date.parse(data.event_date+'T00:00:00Z'))||(data.notes!==undefined&&(typeof data.notes!=='string'||data.notes.length>1000)))return json(res,400,{error:'Titel, Datum oder Notiz ist ungültig.'}); + const result=insertEvent.run(identity.userId,data.title.trim(),data.event_date,data.notes||'');return json(res,201,{id:Number(result.lastInsertRowid)}); + } catch(error){return json(res,400,{error:error.message==='too large'?'Anfrage zu groß.':'Ungültige Anfrage.'});} + } + if(pathname.startsWith('/api/'))return json(res,404,{error:'Route nicht gefunden.'}); + res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Nicht gefunden'); +}}); +server.listen(Number(process.env.PORT||manifest.port),'127.0.0.1',()=>logger.info('Kalender MVP gestartet',{port:Number(process.env.PORT||manifest.port)})); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9e278a7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + +Kalender MVP
+

Mein Kalender

+

Termin anlegen

+

Termine im Monat

Lade Termine …
+
diff --git a/module.json b/module.json new file mode 100644 index 0000000..81683af --- /dev/null +++ b/module.json @@ -0,0 +1,13 @@ +{ + "id": "calendar-mvp", + "name": "Kalender MVP", + "version": "1.0.0", + "slug": "kalender-mvp", + "description": "Einfacher persönlicher Kalender mit SQLite", + "author": "MPM Integrationstest", + "runtime": "node", + "entrypoint": "server.js", + "port": 41002, + "healthcheck": "/health", + "apiVersion": "v1" +}