Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)

* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
This commit was merged in pull request #18.
This commit is contained in:
Masterluke77
2026-07-09 17:09:00 +02:00
committed by GitHub
parent f4923437ca
commit 6f48827f4d
556 changed files with 77265 additions and 210 deletions
+38
View File
@@ -0,0 +1,38 @@
{
"schemaVersion": 2,
"architecture": "modular-monolith",
"rules": {
"modulePrefix": "vendoo.",
"denyImplicitRoutes": true,
"denyCrossModuleDatabaseAccess": true,
"denyCrossModuleInternalImports": true,
"thirdPartyExtensionsInMainProcess": false,
"themeCustomCss": false,
"themeTokensOnly": true,
"denyUnknownSettings": true,
"sessionOwnershipRequired": true,
"lastActiveAdminProtected": true,
"catalogPayloadAllowlist": true,
"mediaReferenceProtection": true,
"inventoryBulkLimit": 500
},
"nativeModules": [
"vendoo.auth",
"vendoo.users",
"vendoo.sessions",
"vendoo.settings",
"vendoo.security",
"vendoo.themes",
"vendoo.media",
"vendoo.listings",
"vendoo.inventory"
],
"migration": {
"mode": "strangler",
"legacyBridgeStatus": "legacy-bridge",
"frameworkMigration": false,
"databaseReplacement": false,
"identityCompatibilityFacade": "lib/auth.mjs",
"catalogCompatibilityLayer": "lib/db.mjs"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://vendoo.local/contracts/module.schema.json",
"title": "Vendoo Module Manifest",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "id", "name", "version", "type", "status", "requires", "permissions", "provides", "consumes", "owns"],
"properties": {
"schemaVersion": { "const": 1 },
"id": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" },
"type": { "enum": ["core", "feature", "adapter", "extension-host"] },
"status": { "enum": ["native", "legacy-bridge", "disabled"] },
"description": { "type": "string", "maxLength": 500 },
"requires": { "$ref": "#/$defs/moduleIds" },
"permissions": { "$ref": "#/$defs/capabilities" },
"provides": { "$ref": "#/$defs/capabilities" },
"consumes": { "$ref": "#/$defs/capabilities" },
"owns": { "$ref": "#/$defs/capabilities" }
},
"$defs": {
"moduleIds": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" }
},
"capabilities": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" }
}
}
}
+386
View File
@@ -0,0 +1,386 @@
import { db } from '../../../lib/db.mjs';
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
// --- Schema ---
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT,
role TEXT NOT NULL DEFAULT 'editor',
active INTEGER NOT NULL DEFAULT 1,
avatar_color TEXT,
last_active_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
invited_by INTEGER,
FOREIGN KEY (invited_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
ip TEXT,
user_agent TEXT,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
type TEXT NOT NULL DEFAULT 'login',
role TEXT DEFAULT 'editor',
used INTEGER NOT NULL DEFAULT 0,
expires_at TEXT NOT NULL,
created_by INTEGER,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
user_email TEXT,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
details TEXT,
ip TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS login_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ip TEXT,
user_agent TEXT,
success INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
// Migration: add password_hash column if missing
try { db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN locked_until TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN password_changed_at TEXT"); } catch {}
try { db.exec("ALTER TABLE sessions ADD COLUMN last_seen_at TEXT"); } catch {}
try { db.exec("ALTER TABLE sessions ADD COLUMN revoked_at TEXT"); } catch {}
try { db.exec("UPDATE sessions SET last_seen_at = COALESCE(last_seen_at, created_at)"); } catch {}
const AVATAR_COLORS = [
'#4a7c59', '#2d6a9f', '#8b5a3c', '#6b4c8a', '#c4713b',
'#3a8c7a', '#7c4d6e', '#5a7c3a', '#8c5a5a', '#3a6b8c',
];
const SESSION_DURATION_MS = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000;
const SESSION_IDLE_MS = Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)) * 60 * 1000;
const SESSION_TOUCH_INTERVAL_MS = 60 * 1000;
const LOGIN_TOKEN_EXPIRY_MIN = 15;
const INVITE_TOKEN_EXPIRY_HOURS = 48;
function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
}
function generateToken() {
return randomBytes(32).toString('hex');
}
function randomAvatarColor() {
return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
}
// --- Password Hashing (scrypt) ---
export function hashPassword(password) {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password, storedHash) {
if (!storedHash) return false;
const [salt, hash] = storedHash.split(':');
if (!salt || !hash) return false;
const hashBuf = Buffer.from(hash, 'hex');
const supplied = scryptSync(password, salt, 64);
return timingSafeEqual(hashBuf, supplied);
}
// --- Users ---
export function createUser(email, name, role = 'editor', invitedBy = null, password = null) {
role = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor';
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) throw new Error('User existiert bereits');
const color = randomAvatarColor();
const pwHash = password ? hashPassword(password) : null;
const r = db.prepare('INSERT INTO users (email, name, role, avatar_color, invited_by, password_hash) VALUES (?, ?, ?, ?, ?, ?)').run(email, name, role, color, invitedBy, pwHash);
return getUser(r.lastInsertRowid);
}
export function setPassword(userId, password) {
const pwHash = hashPassword(password);
db.prepare("UPDATE users SET password_hash = ?, password_changed_at = datetime('now'), failed_login_count = 0, locked_until = NULL WHERE id = ?").run(pwHash, userId);
destroyUserSessions(userId);
}
export function validatePasswordStrength(password) {
const value = String(password || '');
const problems = [];
if (value.length < 10) problems.push('mindestens 10 Zeichen');
if (!/[a-z]/.test(value)) problems.push('einen Kleinbuchstaben');
if (!/[A-Z]/.test(value)) problems.push('einen Großbuchstaben');
if (!/\d/.test(value)) problems.push('eine Zahl');
if (!/[^A-Za-z0-9]/.test(value)) problems.push('ein Sonderzeichen');
return { valid: problems.length === 0, problems };
}
export function registerFailedLogin(userId, maxAttempts = 8, lockMinutes = 15) {
if (!userId) return null;
const user = getUser(userId);
if (!user) return null;
const count = Number(user.failed_login_count || 0) + 1;
if (count >= maxAttempts) {
const lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000).toISOString();
db.prepare('UPDATE users SET failed_login_count = 0, locked_until = ? WHERE id = ?').run(lockedUntil, userId);
destroyUserSessions(userId);
return { locked: true, lockedUntil };
}
db.prepare('UPDATE users SET failed_login_count = ? WHERE id = ?').run(count, userId);
return { locked: false, remaining: Math.max(0, maxAttempts - count) };
}
export function clearFailedLogins(userId) {
if (!userId) return;
db.prepare('UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = ?').run(userId);
}
export function isUserLocked(user) {
if (!user?.locked_until) return false;
const until = Date.parse(user.locked_until);
if (!Number.isFinite(until) || until <= Date.now()) {
clearFailedLogins(user.id);
return false;
}
return true;
}
export function getUser(id) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
}
export function getUserByEmail(email) {
return db.prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE').get(email);
}
export function getUsers() {
return db.prepare("SELECT id, email, name, role, active, avatar_color, last_active_at, created_at, locked_until, failed_login_count, CASE WHEN password_hash IS NOT NULL AND password_hash != '' THEN 1 ELSE 0 END as has_password FROM users ORDER BY created_at").all();
}
export function updateUser(id, data) {
const allowed = ['name', 'role', 'active', 'avatar_color'];
const fields = [], values = [];
for (const [k, rawValue] of Object.entries(data)) {
if (!allowed.includes(k) || rawValue === undefined) continue;
const v = k === 'role' ? (['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(rawValue) ? rawValue : 'viewer') : rawValue;
fields.push(`${k} = ?`); values.push(v);
}
if (!fields.length) return getUsers().find(user => Number(user.id) === Number(id)) || null;
values.push(id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return getUsers().find(user => Number(user.id) === Number(id)) || null;
}
export function deleteUser(id) {
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
export function touchUserActivity(userId) {
db.prepare("UPDATE users SET last_active_at = datetime('now') WHERE id = ?").run(userId);
}
export function getUserCount() {
return db.prepare('SELECT COUNT(*) as count FROM users').get().count;
}
// --- Sessions ---
export function createSession(userId, ip, userAgent) {
const token = generateToken();
const hash = hashToken(token);
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS).toISOString();
const lastSeenAt = now.toISOString();
db.prepare('INSERT INTO sessions (user_id, token_hash, ip, user_agent, expires_at, last_seen_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)').run(userId, hash, ip, userAgent || '', expiresAt, lastSeenAt);
return { token, expiresAt };
}
export function validateSession(token) {
if (!token) return null;
const hash = hashToken(token);
const session = db.prepare("SELECT s.*, u.email, u.name, u.role, u.active, u.avatar_color, u.locked_until FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > datetime('now')").get(hash);
if (!session || !session.active || isUserLocked(session)) return null;
const lastSeen = Date.parse(session.last_seen_at || session.created_at || 0);
if (Number.isFinite(lastSeen) && Date.now() - lastSeen > SESSION_IDLE_MS) {
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(session.id);
return null;
}
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen >= SESSION_TOUCH_INTERVAL_MS) {
const now = new Date().toISOString();
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run(now, session.id);
session.last_seen_at = now;
touchUserActivity(session.user_id);
}
return session;
}
export function destroySession(token) {
const hash = hashToken(token);
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE token_hash = ?").run(hash);
}
export function destroySessionById(id) {
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(id);
}
export function getSessionById(id) {
return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ?").get(id);
}
export function destroyOwnedSessionById(userId, id) {
const result = db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL").run(id, userId);
return Number(result.changes || 0);
}
export function destroyUserSessions(userId) {
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL").run(userId);
}
export function getUserSessions(userId) {
return db.prepare("SELECT id, ip, user_agent, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > datetime('now') ORDER BY created_at DESC").all(userId);
}
export function getAllActiveSessions() {
return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.last_seen_at, s.expires_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.revoked_at IS NULL AND s.expires_at > datetime('now') ORDER BY s.created_at DESC").all();
}
// clean expired sessions periodically
export function cleanExpiredSessions() {
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now') OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', '-7 days'))").run();
}
// --- Auth Tokens (Magic Links) ---
export function createAuthToken(email, type = 'login', role = 'editor', createdBy = null) {
const token = generateToken();
const hash = hashToken(token);
const minutes = type === 'invite' ? INVITE_TOKEN_EXPIRY_HOURS * 60 : LOGIN_TOKEN_EXPIRY_MIN;
const expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
// invalidate previous unused tokens of same type for same email
db.prepare("UPDATE auth_tokens SET used = 1 WHERE email = ? COLLATE NOCASE AND type = ? AND used = 0").run(email, type);
db.prepare('INSERT INTO auth_tokens (email, token_hash, type, role, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(email, hash, type, role, expiresAt, createdBy);
return { token, expiresAt };
}
export function validateAuthToken(token) {
const hash = hashToken(token);
const row = db.prepare("SELECT * FROM auth_tokens WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')").get(hash);
if (!row) return null;
db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ?').run(row.id);
return row;
}
// --- Rate Limiting ---
const rateLimits = new Map();
export function checkRateLimit(key, maxAttempts = 5, windowMs = 15 * 60 * 1000) {
const now = Date.now();
const entry = rateLimits.get(key);
if (!entry) {
rateLimits.set(key, { count: 1, firstAttempt: now });
return { allowed: true, remaining: maxAttempts - 1 };
}
if (now - entry.firstAttempt > windowMs) {
rateLimits.set(key, { count: 1, firstAttempt: now });
return { allowed: true, remaining: maxAttempts - 1 };
}
entry.count++;
if (entry.count > maxAttempts) {
const retryAfter = Math.ceil((entry.firstAttempt + windowMs - now) / 1000);
return { allowed: false, remaining: 0, retryAfter };
}
return { allowed: true, remaining: maxAttempts - entry.count };
}
// --- CSRF ---
export function generateCsrfToken() {
return randomBytes(24).toString('hex');
}
// --- Audit Log ---
export function auditLog(userId, userEmail, action, targetType = null, targetId = null, details = null, ip = null) {
db.prepare('INSERT INTO audit_log (user_id, user_email, action, target_type, target_id, details, ip) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, userEmail, action, targetType, targetId, details, ip);
}
export function getAuditLog(limit = 100, offset = 0) {
return db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
}
export function searchAuditLog({ limit = 100, offset = 0, userId = null, action = '', from = '', to = '', query = '' } = {}) {
const clauses = [];
const values = [];
if (userId) { clauses.push('user_id = ?'); values.push(Number(userId)); }
if (action) { clauses.push('action LIKE ?'); values.push(`%${String(action).slice(0, 80)}%`); }
if (from) { clauses.push('created_at >= ?'); values.push(String(from).slice(0, 30)); }
if (to) { clauses.push('created_at <= ?'); values.push(String(to).slice(0, 30)); }
if (query) {
const q = `%${String(query).slice(0, 120)}%`;
clauses.push('(user_email LIKE ? OR action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR details LIKE ? OR ip LIKE ?)');
values.push(q, q, q, q, q, q);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 100));
const safeOffset = Math.max(0, Number(offset) || 0);
const rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...values, safeLimit, safeOffset);
const total = db.prepare(`SELECT COUNT(*) AS count FROM audit_log ${where}`).get(...values)?.count || 0;
return { rows, total, limit: safeLimit, offset: safeOffset };
}
export function getAuditLogForUser(userId, limit = 50) {
return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
}
// --- Login History ---
export function recordLogin(userId, ip, userAgent, success = true) {
db.prepare('INSERT INTO login_history (user_id, ip, user_agent, success) VALUES (?, ?, ?, ?)').run(userId, ip, userAgent || '', success ? 1 : 0);
}
export function getLoginHistory(userId, limit = 20) {
return db.prepare('SELECT * FROM login_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
}
export function getRecentFailedLogins(email, windowMinutes = 15) {
const user = getUserByEmail(email);
if (!user) return 0;
const r = db.prepare("SELECT COUNT(*) as count FROM login_history WHERE user_id = ? AND success = 0 AND created_at > datetime('now', ?)").get(user.id, `-${windowMinutes} minutes`);
return r.count;
}
// --- Setup: ensure at least one admin exists ---
export function ensureAdminExists() {
const count = getUserCount();
return count === 0;
}
export function isSetupMode() {
return getUserCount() === 0;
}
// Clean expired sessions every 10 minutes
setInterval(cleanExpiredSessions, 10 * 60 * 1000);
@@ -0,0 +1,169 @@
import { createHash, createPublicKey, verify as verifySignature } from 'crypto';
import { posix as pathPosix } from 'path';
import { PlatformError } from '../../kernel/errors.mjs';
import { validateModuleManifest } from '../../kernel/module-contract.mjs';
export const MODULE_PACKAGE_FORMAT = 'vendoo.module.package';
export const MODULE_PACKAGE_SCHEMA = 1;
export const MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
export const MAX_FILE_COUNT = 200;
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === 'object') {
return Object.keys(value).sort().reduce((result, key) => {
result[key] = stable(value[key]);
return result;
}, {});
}
return value;
}
export function canonicalJson(value) {
return JSON.stringify(stable(value));
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
export function packageIntegrityPayload(modulePackage) {
return {
format: MODULE_PACKAGE_FORMAT,
schemaVersion: MODULE_PACKAGE_SCHEMA,
manifest: modulePackage.manifest,
runtime: modulePackage.runtime,
files: modulePackage.files.map(file => ({ path: file.path, sha256: file.sha256, size: file.size })),
};
}
export function calculatePackageDigest(modulePackage) {
return sha256(canonicalJson(packageIntegrityPayload(modulePackage)));
}
function safeRelativePath(value) {
const raw = String(value || '').replace(/\\/g, '/').trim();
const normalized = pathPosix.normalize(raw);
if (!raw || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || normalized.startsWith('/') || /^[A-Za-z]:/.test(raw) || raw.includes('\0')) {
throw new PlatformError(`Unsicherer Moduldateipfad: ${raw || '(leer)'}`, {
code: 'MODULE_PACKAGE_PATH_INVALID', status: 400, expose: true,
});
}
return normalized;
}
function validateRuntime(runtime = {}) {
const mode = String(runtime.mode || 'declarative').trim();
if (!['builtin-reference', 'declarative', 'isolated'].includes(mode)) {
throw new PlatformError(`Nicht unterstützter Modul-Runtime-Modus: ${mode}`, { code: 'MODULE_RUNTIME_INVALID', status: 400, expose: true });
}
const entry = runtime.entry === undefined || runtime.entry === null || runtime.entry === '' ? null : safeRelativePath(runtime.entry);
if (mode === 'declarative' && entry) {
throw new PlatformError('Deklarative Module dürfen keinen ausführbaren Einstiegspunkt besitzen.', { code: 'MODULE_RUNTIME_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
}
if (mode === 'isolated' && !entry) {
throw new PlatformError('Isolierte Module benötigen einen Einstiegspunkt.', { code: 'MODULE_RUNTIME_ENTRY_REQUIRED', status: 400, expose: true });
}
return Object.freeze({ mode, entry });
}
function validateFiles(files, runtime) {
if (!Array.isArray(files)) throw new PlatformError('Moduldateien fehlen.', { code: 'MODULE_PACKAGE_FILES_INVALID', status: 400, expose: true });
if (files.length > MAX_FILE_COUNT) throw new PlatformError('Das Modulpaket enthält zu viele Dateien.', { code: 'MODULE_PACKAGE_TOO_MANY_FILES', status: 400, expose: true });
const seen = new Set();
let total = 0;
const normalized = files.map(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new PlatformError('Ungültiger Moduldateieintrag.', { code: 'MODULE_PACKAGE_FILE_INVALID', status: 400, expose: true });
const path = safeRelativePath(item.path);
if (seen.has(path)) throw new PlatformError(`Doppelte Moduldatei: ${path}`, { code: 'MODULE_PACKAGE_FILE_DUPLICATE', status: 400, expose: true });
seen.add(path);
const content = String(item.content || '');
let buffer;
try { buffer = Buffer.from(content, 'base64'); } catch { buffer = null; }
if (!buffer || buffer.length > MAX_FILE_BYTES) throw new PlatformError(`Moduldatei ist ungültig oder zu groß: ${path}`, { code: 'MODULE_PACKAGE_FILE_TOO_LARGE', status: 400, expose: true });
const digest = sha256(buffer);
if (!/^[a-f0-9]{64}$/.test(String(item.sha256 || '')) || digest !== String(item.sha256)) {
throw new PlatformError(`Prüfsumme stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_HASH_MISMATCH', status: 400, expose: true });
}
if (Number(item.size) !== buffer.length) throw new PlatformError(`Dateigröße stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_SIZE_MISMATCH', status: 400, expose: true });
total += buffer.length;
if (runtime.mode === 'declarative' && /\.(?:mjs|cjs|js|node|exe|dll|so|dylib|ps1|bat|cmd|sh)$/i.test(path)) {
throw new PlatformError(`Deklaratives Modul enthält ausführbaren Code: ${path}`, { code: 'MODULE_PACKAGE_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
}
return Object.freeze({ path, sha256: digest, size: buffer.length, content });
});
if (total > MAX_PACKAGE_BYTES) throw new PlatformError('Das Modulpaket ist zu groß.', { code: 'MODULE_PACKAGE_TOO_LARGE', status: 400, expose: true });
if (runtime.entry && !seen.has(runtime.entry)) throw new PlatformError('Der Runtime-Einstiegspunkt fehlt im Paket.', { code: 'MODULE_RUNTIME_ENTRY_MISSING', status: 400, expose: true });
return Object.freeze(normalized);
}
function verifyOptionalSignature(modulePackage, trustKeys = {}) {
const signature = modulePackage.signature;
if (!signature) return { signed: false, trusted: false, keyId: null };
const algorithm = String(signature.algorithm || '').toLowerCase();
const keyId = String(signature.keyId || '').trim();
const value = String(signature.value || '').trim();
if (algorithm !== 'ed25519' || !keyId || !value) {
throw new PlatformError('Ungültige Modulsignatur.', { code: 'MODULE_SIGNATURE_INVALID', status: 400, expose: true });
}
const publicKey = trustKeys[keyId];
if (!publicKey) return { signed: true, trusted: false, keyId };
try {
const verified = verifySignature(null, Buffer.from(modulePackage.integrity.digest, 'hex'), createPublicKey(publicKey), Buffer.from(value, 'base64'));
if (!verified) throw new Error('Signaturprüfung fehlgeschlagen');
return { signed: true, trusted: true, keyId };
} catch (error) {
throw new PlatformError('Modulsignatur konnte nicht bestätigt werden.', { code: 'MODULE_SIGNATURE_REJECTED', status: 400, expose: true, cause: error });
}
}
export function validateModulePackage(input, { trustKeys = {}, allowBuiltinReference = false } = {}) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
if (!source) throw new PlatformError('Ungültiges Modulpaket.', { code: 'MODULE_PACKAGE_INVALID', status: 400, expose: true });
if (source.format !== MODULE_PACKAGE_FORMAT || Number(source.schemaVersion) !== MODULE_PACKAGE_SCHEMA) {
throw new PlatformError('Nicht unterstütztes Modulpaketformat.', { code: 'MODULE_PACKAGE_VERSION_UNSUPPORTED', status: 400, expose: true });
}
const manifest = validateModuleManifest(source.manifest);
const runtime = validateRuntime(source.runtime);
if (runtime.mode === 'builtin-reference' && !allowBuiltinReference) {
throw new PlatformError('Referenzexporte eingebauter Module können nicht installiert werden.', { code: 'MODULE_BUILTIN_REFERENCE_NOT_INSTALLABLE', status: 400, expose: true });
}
if (manifest.type !== 'extension-host' && runtime.mode !== 'builtin-reference') {
throw new PlatformError('Installierbare Fremdmodule müssen den Typ extension-host verwenden.', { code: 'MODULE_EXTERNAL_TYPE_REQUIRED', status: 400, expose: true });
}
const files = validateFiles(source.files || [], runtime);
const normalized = {
format: MODULE_PACKAGE_FORMAT,
schemaVersion: MODULE_PACKAGE_SCHEMA,
manifest,
runtime,
files,
createdAt: String(source.createdAt || new Date().toISOString()),
integrity: {
algorithm: String(source.integrity?.algorithm || '').toLowerCase(),
digest: String(source.integrity?.digest || '').toLowerCase(),
},
signature: source.signature || null,
};
if (normalized.integrity.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(normalized.integrity.digest)) {
throw new PlatformError('Paketintegrität fehlt oder ist ungültig.', { code: 'MODULE_PACKAGE_INTEGRITY_INVALID', status: 400, expose: true });
}
const expected = calculatePackageDigest(normalized);
if (expected !== normalized.integrity.digest) {
throw new PlatformError('Paketintegrität stimmt nicht.', { code: 'MODULE_PACKAGE_INTEGRITY_MISMATCH', status: 400, expose: true });
}
const trust = verifyOptionalSignature(normalized, trustKeys);
return Object.freeze({ ...normalized, trust: Object.freeze(trust) });
}
export function buildModulePackage({ manifest, runtime = { mode: 'builtin-reference', entry: null }, files = [], createdAt = new Date().toISOString() }) {
const normalizedFiles = files.map(file => {
const path = safeRelativePath(file.path);
const buffer = Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content || ''), 'utf8');
return { path, sha256: sha256(buffer), size: buffer.length, content: buffer.toString('base64') };
});
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
draft.integrity.digest = calculatePackageDigest(draft);
return validateModulePackage(draft, { allowBuiltinReference: true });
}
+112
View File
@@ -0,0 +1,112 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'fs';
import { dirname, join, resolve, sep } from 'path';
import { PlatformError } from '../../kernel/errors.mjs';
import { validateModulePackage } from './module-package-contract.mjs';
function ensureInside(root, pathname) {
const base = resolve(root);
const target = resolve(pathname);
if (target !== base && !target.startsWith(`${base}${sep}`)) {
throw new PlatformError('Moduldatei liegt außerhalb des Modulspeichers.', { code: 'MODULE_STORAGE_ESCAPE', status: 400, expose: true });
}
return target;
}
export class ModulePackageStore {
#root;
#trustKeys;
constructor(root, { trustKeys = {} } = {}) {
this.#root = resolve(root);
this.#trustKeys = { ...trustKeys };
mkdirSync(this.#root, { recursive: true });
}
#moduleRoot(id) {
return ensureInside(this.#root, join(this.#root, id));
}
#packagePath(id) {
return join(this.#moduleRoot(id), 'package.vmod');
}
list() {
if (!existsSync(this.#root)) return [];
const result = [];
for (const entry of readdirSync(this.#root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
try {
const packagePath = this.#packagePath(entry.name);
if (!existsSync(packagePath)) continue;
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
const pkg = validateModulePackage(parsed, { trustKeys: this.#trustKeys });
result.push({
id: pkg.manifest.id,
manifest: pkg.manifest,
runtime: pkg.runtime,
trust: pkg.trust,
installedAt: String(parsed.installedAt || parsed.createdAt || ''),
activatable: pkg.runtime.mode === 'declarative' || (pkg.runtime.mode === 'isolated' && pkg.trust.trusted),
packagePath,
});
} catch (error) {
result.push({ id: entry.name, invalid: true, error: error?.message || String(error), activatable: false });
}
}
return result.sort((a, b) => String(a.id).localeCompare(String(b.id)));
}
get(id) {
const packagePath = this.#packagePath(id);
if (!existsSync(packagePath)) return null;
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
return validateModulePackage(parsed, { trustKeys: this.#trustKeys });
}
install(input) {
const pkg = validateModulePackage(input, { trustKeys: this.#trustKeys });
const root = this.#moduleRoot(pkg.manifest.id);
if (existsSync(root)) throw new PlatformError('Dieses Fremdmodul ist bereits installiert.', { code: 'MODULE_ALREADY_INSTALLED', status: 409, expose: true });
const temporary = `${root}.${process.pid}.tmp`;
mkdirSync(temporary, { recursive: true });
try {
for (const file of pkg.files) {
const destination = ensureInside(temporary, join(temporary, file.path));
mkdirSync(dirname(destination), { recursive: true });
writeFileSync(destination, Buffer.from(file.content, 'base64'), { mode: 0o600 });
}
const persisted = { ...pkg, trust: undefined, installedAt: new Date().toISOString() };
writeFileSync(join(temporary, 'package.vmod'), `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 });
renameSync(temporary, root);
} catch (error) {
rmSync(temporary, { recursive: true, force: true });
throw error;
}
return this.describe(pkg.manifest.id);
}
remove(id) {
const root = this.#moduleRoot(id);
if (!existsSync(root)) return false;
rmSync(root, { recursive: true, force: true });
return true;
}
export(id) {
const packagePath = this.#packagePath(id);
if (!existsSync(packagePath)) throw new PlatformError('Installiertes Modulpaket nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
return readFileSync(packagePath);
}
describe(id) {
const pkg = this.get(id);
if (!pkg) return null;
return {
id: pkg.manifest.id,
manifest: pkg.manifest,
runtime: pkg.runtime,
trust: pkg.trust,
activatable: pkg.runtime.mode === 'declarative' || (pkg.runtime.mode === 'isolated' && pkg.trust.trusted),
};
}
}
+87
View File
@@ -0,0 +1,87 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'fs';
import { dirname } from 'path';
import { PlatformError } from '../../kernel/errors.mjs';
const SCHEMA_VERSION = 1;
function normalizeState(input = {}) {
const modules = {};
const source = input && typeof input === 'object' && !Array.isArray(input) ? input.modules : null;
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (const [id, record] of Object.entries(source)) {
if (!/^vendoo\.[a-z][a-z0-9-]*$/.test(id)) continue;
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
modules[id] = {
enabled: record.enabled !== false,
source: String(record.source || 'admin'),
updatedAt: String(record.updatedAt || new Date(0).toISOString()),
};
}
}
return { schemaVersion: SCHEMA_VERSION, modules };
}
export class ModuleStateStore {
#path;
#state;
constructor(pathname) {
this.#path = pathname;
this.#state = this.#read();
}
#read() {
try {
if (!existsSync(this.#path)) return normalizeState();
const parsed = JSON.parse(readFileSync(this.#path, 'utf8'));
if (Number(parsed?.schemaVersion || 0) !== SCHEMA_VERSION) {
throw new Error(`Nicht unterstützte Modulstatus-Version: ${parsed?.schemaVersion}`);
}
return normalizeState(parsed);
} catch (error) {
throw new PlatformError('Der persistente Modulstatus konnte nicht gelesen werden.', {
code: 'MODULE_STATE_READ_FAILED',
cause: error,
details: { path: this.#path },
});
}
}
#persist() {
mkdirSync(dirname(this.#path), { recursive: true });
const temporary = `${this.#path}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(this.#state, null, 2)}\n`, { mode: 0o600 });
renameSync(temporary, this.#path);
}
has(id) { return Boolean(this.#state.modules[id]); }
isEnabled(id, fallback = true) {
const record = this.#state.modules[id];
return record ? record.enabled !== false : Boolean(fallback);
}
setEnabled(id, enabled, { source = 'admin' } = {}) {
if (!/^vendoo\.[a-z][a-z0-9-]*$/.test(String(id || ''))) {
throw new PlatformError('Ungültige Modul-ID für den Modulstatus.', { code: 'MODULE_ID_INVALID', status: 400, expose: true });
}
this.#state.modules[id] = {
enabled: Boolean(enabled),
source: String(source || 'admin').slice(0, 80),
updatedAt: new Date().toISOString(),
};
this.#persist();
return { id, ...this.#state.modules[id] };
}
remove(id) {
if (!this.#state.modules[id]) return false;
delete this.#state.modules[id];
this.#persist();
return true;
}
snapshot() {
return JSON.parse(JSON.stringify(this.#state));
}
}
+51
View File
@@ -0,0 +1,51 @@
export const SECRET_DEFINITIONS = Object.freeze({
ANTHROPIC_API_KEY: { label: 'Anthropic API-Key', category: 'AI', editable: true },
OPENAI_API_KEY: { label: 'OpenAI API-Key', category: 'AI', editable: true },
OPENROUTER_API_KEY: { label: 'OpenRouter API-Key', category: 'AI', editable: true },
ETSY_API_KEY: { label: 'Etsy API-Key', category: 'Marktplatz', editable: true },
ETSY_ACCESS_TOKEN: { label: 'Etsy Access Token', category: 'Marktplatz', editable: false },
ETSY_REFRESH_TOKEN: { label: 'Etsy Refresh Token', category: 'Marktplatz', editable: false },
EBAY_CLIENT_SECRET: { label: 'eBay Client Secret', category: 'Marktplatz', editable: true },
EBAY_ACCESS_TOKEN: { label: 'eBay Access Token', category: 'Marktplatz', editable: false },
EBAY_REFRESH_TOKEN: { label: 'eBay Refresh Token', category: 'Marktplatz', editable: false },
LOCAL_IMAGE_TOKEN: { label: 'FLUX Dienst-Token', category: 'FLUX', editable: true },
SMTP_PASS: { label: 'SMTP-Passwort', category: 'E-Mail', editable: true },
VENDOO_SETUP_TOKEN: { label: 'Ersteinrichtungs-Token', category: 'System', editable: false },
COOLIFY_API_TOKEN: { label: 'Coolify API-Token', category: 'Deployment', editable: true },
COOLIFY_DEPLOY_WEBHOOK_URL: { label: 'Coolify Deploy-Webhook', category: 'Deployment', editable: true },
});
export const SECRET_NAMES = Object.freeze(Object.keys(SECRET_DEFINITIONS));
const SECRET_NAME = /^[A-Z][A-Z0-9_]{2,80}$/;
export function validateSecretName(name) {
const normalized = String(name || '').trim();
if (!SECRET_NAME.test(normalized) || !SECRET_DEFINITIONS[normalized]) {
const error = new Error(`Unbekanntes Secret: ${normalized || '(leer)'}`);
error.code = 'SECRET_NAME_INVALID';
throw error;
}
return normalized;
}
export function validateSecretValue(value) {
const normalized = String(value ?? '');
if (/\r|\n|\0/.test(normalized)) {
const error = new Error('Secrets dürfen keine Zeilenumbrüche oder Nullbytes enthalten.');
error.code = 'SECRET_VALUE_INVALID';
throw error;
}
if (Buffer.byteLength(normalized, 'utf8') > 16_384) {
const error = new Error('Secret ist zu groß.');
error.code = 'SECRET_VALUE_TOO_LARGE';
throw error;
}
return normalized;
}
export function maskSecret(value) {
const text = String(value || '');
if (!text) return '';
if (text.length <= 4) return '••••';
return `••••••••${text.slice(-4)}`;
}
+169
View File
@@ -0,0 +1,169 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { APP_ROOT } from '../../../lib/runtime-paths.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
const source = JSON.parse(readFileSync(join(APP_ROOT, 'public', 'design-system', 'tokens', 'source.json'), 'utf8'));
const BUILTIN_THEME_IDS = new Set([...source.themes, 'system', 'inherit']);
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const RGB_COLOR = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i;
const DIMENSION = /^-?\d+(?:\.\d+)?px$/i;
const THEME_ID = /^[a-z][a-z0-9-]{2,39}$/;
function expandHex(value) {
const hex = value.replace('#', '');
if (hex.length === 3 || hex.length === 4) return hex.split('').map(char => char + char).join('');
return hex;
}
function parseColor(value) {
const raw = String(value || '').trim();
if (HEX_COLOR.test(raw)) {
const hex = expandHex(raw);
return [0, 2, 4].map(index => Number.parseInt(hex.slice(index, index + 2), 16));
}
const match = raw.match(RGB_COLOR);
if (!match) return null;
const channels = match.slice(1, 4).map(Number);
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels : null;
}
function validColor(value) {
if (!parseColor(value)) return false;
const match = String(value).trim().match(RGB_COLOR);
if (!match) return true;
const alpha = match[4] === undefined ? 1 : Number(match[4]);
return Number.isFinite(alpha) && alpha >= 0 && alpha <= 1;
}
function validateValue(token, value) {
const raw = String(value ?? '').trim();
if (token.type === 'color') return validColor(raw);
if (token.type === 'dimension') {
if (!DIMENSION.test(raw)) return false;
const numeric = Number.parseFloat(raw);
if (token.min !== undefined && numeric < Number(token.min)) return false;
if (token.max !== undefined && numeric > Number(token.max)) return false;
return true;
}
return false;
}
function relativeLuminance(value) {
const rgb = parseColor(value);
if (!rgb) return null;
const linear = rgb.map(channel => {
const normalized = channel / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
export function contrastRatio(foreground, background) {
const first = relativeLuminance(foreground);
const second = relativeLuminance(background);
if (first === null || second === null) return null;
const lighter = Math.max(first, second);
const darker = Math.min(first, second);
return (lighter + 0.05) / (darker + 0.05);
}
export function getBaseThemeValues(themeId = 'light') {
const base = source.themes.includes(themeId) ? themeId : 'light';
return Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, token[base]]));
}
export function resolveThemeValues({ baseTheme = 'light', values = {} } = {}) {
return Object.freeze({ ...getBaseThemeValues(baseTheme), ...values });
}
export function evaluateThemeAccessibility({ baseTheme = 'light', values = {} } = {}) {
const resolved = resolveThemeValues({ baseTheme, values });
const checks = [
['primary-on-canvas', 'color.ink', 'color.canvas', 4.5],
['primary-on-surface', 'color.ink', 'color.surface', 4.5],
['secondary-on-surface', 'color.inkSecondary', 'color.surface', 4.5],
['muted-on-surface', 'color.inkMuted', 'color.surface', 3],
['brand-on-surface', 'color.brand', 'color.surface', 3],
['danger-on-surface', 'color.danger', 'color.surface', 3],
['line-on-surface', 'color.lineStrong', 'color.surface', 3],
].map(([id, foregroundToken, backgroundToken, minimum]) => {
const ratio = contrastRatio(resolved[foregroundToken], resolved[backgroundToken]);
return {
id,
foregroundToken,
backgroundToken,
ratio: ratio === null ? null : Number(ratio.toFixed(2)),
minimum,
ok: ratio !== null && ratio >= minimum,
};
});
return Object.freeze({
ok: checks.every(check => check.ok),
checks: Object.freeze(checks),
failures: Object.freeze(checks.filter(check => !check.ok)),
});
}
export function getThemeContract() {
return {
schemaVersion: source.schemaVersion,
contractVersion: source.contractVersion,
themes: [...source.themes],
tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, {
css: token.css,
type: token.type,
customizable: Boolean(token.customizable),
label: token.label || name,
group: token.group || name.split('.')[0],
...(token.min !== undefined ? { min: token.min } : {}),
...(token.max !== undefined ? { max: token.max } : {}),
}])),
};
}
export function getBuiltinThemes() {
return source.themes.map(id => ({
id,
name: ({ light: 'Hell', dark: 'Dunkel', contrast: 'Hoher Kontrast' })[id] || id,
base_theme: id,
builtin: true,
values: {},
resolved_values: getBaseThemeValues(id),
accessibility: evaluateThemeAccessibility({ baseTheme: id }),
}));
}
export function validateThemeDefinition(theme, { allowExistingId = false } = {}) {
if (!theme || typeof theme !== 'object' || Array.isArray(theme)) {
throw new PlatformError('Theme-Definition ist ungültig.', { code: 'THEME_INVALID', status: 400, expose: true });
}
const id = String(theme.id || '').trim().toLowerCase();
const name = String(theme.name || '').trim();
const baseTheme = String(theme.base_theme || theme.baseTheme || 'light').trim().toLowerCase();
if (!THEME_ID.test(id) || BUILTIN_THEME_IDS.has(id)) throw new PlatformError('Theme-ID ist ungültig oder reserviert.', { code: 'THEME_ID_INVALID', status: 400, expose: true });
if (!allowExistingId && !id.startsWith('custom-')) throw new PlatformError('Eigene Theme-IDs müssen mit custom- beginnen.', { code: 'THEME_ID_PREFIX', status: 400, expose: true });
if (!name || name.length > 80) throw new PlatformError('Theme-Name ist ungültig.', { code: 'THEME_NAME_INVALID', status: 400, expose: true });
if (!source.themes.includes(baseTheme)) throw new PlatformError('Basis-Theme ist ungültig.', { code: 'THEME_BASE_INVALID', status: 400, expose: true });
const values = {};
for (const [tokenName, value] of Object.entries(theme.values || {})) {
const token = source.tokens[tokenName];
if (!token || !token.customizable) throw new PlatformError(`Token ist nicht anpassbar: ${tokenName}`, { code: 'THEME_TOKEN_FORBIDDEN', status: 400, expose: true });
if (!validateValue(token, value)) throw new PlatformError(`Ungültiger Wert für ${tokenName}.`, { code: 'THEME_VALUE_INVALID', status: 400, expose: true });
values[tokenName] = String(value).trim();
}
const accessibility = evaluateThemeAccessibility({ baseTheme, values });
return Object.freeze({ schemaVersion: 1, id, name, base_theme: baseTheme, values: Object.freeze(values), accessibility });
}
export function validateThemePreference(input, { customThemeExists = () => false } = {}) {
const themeId = String(input?.theme_id || input?.themeId || 'inherit').trim().toLowerCase();
const density = String(input?.density || 'comfortable').trim().toLowerCase();
const reducedMotion = String(input?.reduced_motion || input?.reducedMotion || 'system').trim().toLowerCase();
if (![...BUILTIN_THEME_IDS].includes(themeId) && !customThemeExists(themeId)) {
throw new PlatformError('Gewähltes Theme existiert nicht.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
}
if (!['compact', 'comfortable', 'spacious'].includes(density)) throw new PlatformError('UI-Dichte ist ungültig.', { code: 'THEME_DENSITY_INVALID', status: 400, expose: true });
if (!['system', 'reduce', 'allow'].includes(reducedMotion)) throw new PlatformError('Bewegungseinstellung ist ungültig.', { code: 'THEME_MOTION_INVALID', status: 400, expose: true });
return Object.freeze({ theme_id: themeId, density, reduced_motion: reducedMotion });
}
+31
View File
@@ -0,0 +1,31 @@
export class PlatformError extends Error {
constructor(message, { code = 'PLATFORM_ERROR', status = 500, details = null, expose = false, cause = undefined } = {}) {
super(String(message || 'Unbekannter Plattformfehler'), { cause });
this.name = 'PlatformError';
this.code = String(code || 'PLATFORM_ERROR');
this.status = Number.isInteger(status) ? status : 500;
this.details = details;
this.expose = Boolean(expose || this.status < 500);
}
}
export function normalizePlatformError(error) {
if (error instanceof PlatformError) return error;
return new PlatformError(error?.message || 'Interner Serverfehler', {
code: error?.code || 'INTERNAL_ERROR',
status: Number.isInteger(error?.status) ? error.status : 500,
details: error?.details || null,
expose: Boolean(error?.expose),
cause: error,
});
}
export function platformErrorPayload(error, requestId = null) {
const normalized = normalizePlatformError(error);
return {
error: normalized.expose ? normalized.message : 'Interner Serverfehler',
code: normalized.code,
request_id: requestId || null,
...(normalized.expose && normalized.details ? { details: normalized.details } : {}),
};
}
+77
View File
@@ -0,0 +1,77 @@
import { PlatformError } from './errors.mjs';
const EVENT_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
export class EventBus {
#listeners = new Map();
#maxListeners;
constructor({ maxListenersPerEvent = 50 } = {}) {
this.#maxListeners = Math.max(1, Number(maxListenersPerEvent) || 50);
}
on(eventName, handler, { owner = 'core', once = false } = {}) {
if (!EVENT_PATTERN.test(String(eventName))) {
throw new PlatformError(`Ungültiger Eventname: ${eventName}`, { code: 'EVENT_NAME_INVALID', status: 500 });
}
if (typeof handler !== 'function') {
throw new PlatformError('Event-Handler muss eine Funktion sein.', { code: 'EVENT_HANDLER_INVALID', status: 500 });
}
const listeners = this.#listeners.get(eventName) || [];
if (listeners.length >= this.#maxListeners) {
throw new PlatformError(`Zu viele Listener für ${eventName}.`, { code: 'EVENT_LISTENER_LIMIT', status: 500 });
}
const entry = Object.freeze({ handler, owner: String(owner || 'core'), once: Boolean(once) });
listeners.push(entry);
this.#listeners.set(eventName, listeners);
return () => this.off(eventName, handler);
}
once(eventName, handler, options = {}) {
return this.on(eventName, handler, { ...options, once: true });
}
off(eventName, handler) {
const listeners = this.#listeners.get(eventName) || [];
const remaining = listeners.filter(entry => entry.handler !== handler);
if (remaining.length) this.#listeners.set(eventName, remaining);
else this.#listeners.delete(eventName);
return remaining.length !== listeners.length;
}
removeOwner(owner) {
let removed = 0;
for (const [eventName, listeners] of this.#listeners) {
const remaining = listeners.filter(entry => {
const match = entry.owner === owner;
if (match) removed += 1;
return !match;
});
if (remaining.length) this.#listeners.set(eventName, remaining);
else this.#listeners.delete(eventName);
}
return removed;
}
async emit(eventName, payload = null, context = {}) {
const listeners = [...(this.#listeners.get(eventName) || [])];
const results = [];
for (const entry of listeners) {
try {
results.push({ owner: entry.owner, ok: true, value: await entry.handler(payload, Object.freeze({ ...context, eventName })) });
} catch (error) {
results.push({ owner: entry.owner, ok: false, error });
} finally {
if (entry.once) this.off(eventName, entry.handler);
}
}
return results;
}
snapshot() {
return [...this.#listeners.entries()].map(([event, listeners]) => ({
event,
listeners: listeners.map(entry => ({ owner: entry.owner, once: entry.once })),
}));
}
}
+59
View File
@@ -0,0 +1,59 @@
import { PlatformError } from './errors.mjs';
const FLAG_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
function envKey(flag) {
return `VENDOO_FEATURE_${flag.replace(/[^a-z0-9]/gi, '_').toUpperCase()}`;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
if (/^(1|true|yes|on)$/i.test(String(value))) return true;
if (/^(0|false|no|off)$/i.test(String(value))) return false;
throw new PlatformError(`Ungültiger Feature-Flag-Wert: ${value}`, { code: 'FEATURE_FLAG_VALUE_INVALID', status: 500 });
}
export class FeatureFlagRegistry {
#flags = new Map();
define(name, { defaultValue = false, owner = 'vendoo.system', description = '', mutable = false } = {}) {
const normalized = String(name || '');
if (!FLAG_PATTERN.test(normalized)) {
throw new PlatformError(`Ungültiger Feature-Flag: ${normalized}`, { code: 'FEATURE_FLAG_NAME_INVALID', status: 500 });
}
if (this.#flags.has(normalized)) {
throw new PlatformError(`Feature-Flag bereits definiert: ${normalized}`, { code: 'FEATURE_FLAG_DUPLICATE', status: 500 });
}
const configured = parseBoolean(process.env[envKey(normalized)], Boolean(defaultValue));
this.#flags.set(normalized, {
name: normalized,
value: configured,
defaultValue: Boolean(defaultValue),
owner: String(owner),
description: String(description),
mutable: Boolean(mutable),
source: process.env[envKey(normalized)] === undefined ? 'default' : 'environment',
});
return configured;
}
isEnabled(name) {
if (!this.#flags.has(name)) {
throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 500 });
}
return this.#flags.get(name).value;
}
set(name, value) {
const flag = this.#flags.get(name);
if (!flag) throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 404, expose: true });
if (!flag.mutable) throw new PlatformError(`Feature-Flag ist nicht zur Laufzeit änderbar: ${name}`, { code: 'FEATURE_FLAG_IMMUTABLE', status: 409, expose: true });
flag.value = Boolean(value);
flag.source = 'runtime';
return flag.value;
}
list() {
return [...this.#flags.values()].map(flag => ({ ...flag }));
}
}
+43
View File
@@ -0,0 +1,43 @@
import { PlatformError } from './errors.mjs';
function fail(message, field) {
throw new PlatformError(message, { code: 'INPUT_VALIDATION_FAILED', status: 400, expose: true, details: { field } });
}
export function objectSchema(fields, { allowUnknown = false } = {}) {
const contract = Object.freeze({ ...fields });
return input => {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const output = {};
if (!allowUnknown) for (const key of Object.keys(source)) if (!contract[key]) fail(`Unbekanntes Eingabefeld: ${key}`, key);
for (const [name, rule] of Object.entries(contract)) {
let value = source[name];
if (value === undefined || value === null) {
if (rule.required) fail(`${name} ist erforderlich.`, name);
continue;
}
if (rule.type === 'string') {
value = String(value);
if (rule.trim !== false) value = value.trim();
if (rule.minLength && value.length < rule.minLength) fail(`${name} ist zu kurz.`, name);
if (rule.maxLength && value.length > rule.maxLength) fail(`${name} ist zu lang.`, name);
if (rule.pattern && !rule.pattern.test(value)) fail(`${name} besitzt ein ungültiges Format.`, name);
} else if (rule.type === 'boolean') {
if (typeof value !== 'boolean') fail(`${name} muss true oder false sein.`, name);
} else if (rule.type === 'number' || rule.type === 'integer') {
value = Number(value);
if (!Number.isFinite(value)) fail(`${name} muss eine Zahl sein.`, name);
if (rule.type === 'integer' && !Number.isInteger(value)) fail(`${name} muss eine ganze Zahl sein.`, name);
if (rule.min !== undefined && value < rule.min) fail(`${name} ist zu klein.`, name);
if (rule.max !== undefined && value > rule.max) fail(`${name} ist zu groß.`, name);
}
if (rule.enum && !rule.enum.includes(value)) fail(`${name} enthält einen nicht erlaubten Wert.`, name);
if (typeof rule.validate === 'function') {
const result = rule.validate(value);
if (result !== true) fail(typeof result === 'string' ? result : `${name} ist ungültig.`, name);
}
output[name] = value;
}
return Object.freeze(output);
};
}
+49
View File
@@ -0,0 +1,49 @@
import { PlatformError } from './errors.mjs';
export class LifecycleManager {
#state = 'created';
#hooks = [];
register({ id, order = 100, start = async () => {}, stop = async () => {} }) {
if (!id || this.#hooks.some(hook => hook.id === id)) {
throw new PlatformError(`Ungültiger oder doppelter Lifecycle-Hook: ${id}`, { code: 'LIFECYCLE_HOOK_INVALID' });
}
this.#hooks.push({ id: String(id), order: Number(order) || 100, start, stop, state: 'registered' });
}
async start(context) {
if (!['created', 'stopped'].includes(this.#state)) throw new PlatformError(`Kernel kann aus Zustand ${this.#state} nicht starten.`, { code: 'LIFECYCLE_STATE_INVALID' });
this.#state = 'starting';
const started = [];
try {
for (const hook of [...this.#hooks].sort((a, b) => a.order - b.order)) {
hook.state = 'starting';
await hook.start(context);
hook.state = 'running';
started.push(hook);
}
this.#state = 'running';
} catch (error) {
for (const hook of started.reverse()) {
try { await hook.stop({ ...context, rollback: true }); } catch {}
hook.state = 'stopped';
}
this.#state = 'failed';
throw error;
}
}
async stop(context) {
if (!['running', 'starting'].includes(this.#state)) return;
this.#state = 'stopping';
for (const hook of [...this.#hooks].sort((a, b) => b.order - a.order)) {
if (hook.state !== 'running') continue;
try { await hook.stop(context); } finally { hook.state = 'stopped'; }
}
this.#state = 'stopped';
}
snapshot() {
return { state: this.#state, hooks: this.#hooks.map(({ start: _s, stop: _t, ...hook }) => ({ ...hook })) };
}
}
+47
View File
@@ -0,0 +1,47 @@
import { PlatformError } from './errors.mjs';
const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/;
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const CAPABILITY = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
const TYPES = new Set(['core', 'feature', 'adapter', 'extension-host']);
const STATUSES = new Set(['native', 'legacy-bridge', 'disabled']);
function uniqueStrings(values, label, pattern = CAPABILITY) {
if (!Array.isArray(values)) throw new PlatformError(`${label} muss ein Array sein.`, { code: 'MODULE_MANIFEST_INVALID' });
const normalized = values.map(value => String(value || '').trim());
if (normalized.some(value => !pattern.test(value))) {
throw new PlatformError(`${label} enthält einen ungültigen Eintrag.`, { code: 'MODULE_MANIFEST_INVALID' });
}
if (new Set(normalized).size !== normalized.length) {
throw new PlatformError(`${label} enthält Duplikate.`, { code: 'MODULE_MANIFEST_INVALID' });
}
return normalized;
}
export function validateModuleManifest(manifest) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new PlatformError('Modulmanifest fehlt oder ist ungültig.', { code: 'MODULE_MANIFEST_INVALID' });
}
const normalized = {
schemaVersion: Number(manifest.schemaVersion || 1),
id: String(manifest.id || '').trim(),
name: String(manifest.name || '').trim(),
version: String(manifest.version || '').trim(),
type: String(manifest.type || '').trim(),
status: String(manifest.status || 'native').trim(),
description: String(manifest.description || '').trim(),
requires: Object.freeze(uniqueStrings(manifest.requires || [], 'requires', MODULE_ID)),
permissions: Object.freeze(uniqueStrings(manifest.permissions || [], 'permissions')),
provides: Object.freeze(uniqueStrings(manifest.provides || [], 'provides')),
consumes: Object.freeze(uniqueStrings(manifest.consumes || [], 'consumes')),
owns: Object.freeze(uniqueStrings(manifest.owns || [], 'owns')),
};
if (normalized.schemaVersion !== 1) throw new PlatformError('Nicht unterstützte Manifestversion.', { code: 'MODULE_MANIFEST_VERSION' });
if (!MODULE_ID.test(normalized.id)) throw new PlatformError(`Ungültige Modul-ID: ${normalized.id}`, { code: 'MODULE_ID_INVALID' });
if (!normalized.name) throw new PlatformError(`Modulname fehlt: ${normalized.id}`, { code: 'MODULE_NAME_REQUIRED' });
if (!SEMVER.test(normalized.version)) throw new PlatformError(`Ungültige Modulversion: ${normalized.id}`, { code: 'MODULE_VERSION_INVALID' });
if (!TYPES.has(normalized.type)) throw new PlatformError(`Ungültiger Modultyp: ${normalized.id}`, { code: 'MODULE_TYPE_INVALID' });
if (!STATUSES.has(normalized.status)) throw new PlatformError(`Ungültiger Modulstatus: ${normalized.id}`, { code: 'MODULE_STATUS_INVALID' });
if (normalized.requires.includes(normalized.id)) throw new PlatformError(`Modul darf sich nicht selbst benötigen: ${normalized.id}`, { code: 'MODULE_SELF_DEPENDENCY' });
return Object.freeze(normalized);
}
+276
View File
@@ -0,0 +1,276 @@
import { PlatformError } from './errors.mjs';
import { validateModuleManifest } from './module-contract.mjs';
export class ModuleRegistry {
#modules = new Map();
#startOrder = [];
#ownership = new Map();
#started = false;
register({ manifest, lifecycle = {}, source = 'builtin' }, { enabled = true } = {}) {
const validated = validateModuleManifest(manifest);
if (this.#modules.has(validated.id)) {
throw new PlatformError(`Modul bereits registriert: ${validated.id}`, { code: 'MODULE_DUPLICATE' });
}
for (const resource of validated.owns) {
const owner = this.#ownership.get(resource);
if (owner) throw new PlatformError(`Ressource ${resource} wird bereits von ${owner} besessen.`, { code: 'MODULE_OWNERSHIP_CONFLICT' });
}
const active = validated.status !== 'disabled' && enabled !== false;
const record = {
manifest: validated,
lifecycle: {
start: typeof lifecycle.start === 'function' ? lifecycle.start : async () => {},
stop: typeof lifecycle.stop === 'function' ? lifecycle.stop : async () => {},
health: typeof lifecycle.health === 'function' ? lifecycle.health : async () => ({ ok: true }),
},
source: String(source || 'builtin'),
enabled: active,
state: active ? 'registered' : 'disabled',
startedAt: null,
error: null,
};
this.#modules.set(validated.id, record);
for (const resource of validated.owns) this.#ownership.set(resource, validated.id);
return validated;
}
get(id) {
return this.#modules.get(id) || null;
}
has(id) {
return this.#modules.has(id);
}
isEnabled(id) {
return this.#modules.get(id)?.enabled === true;
}
dependentsOf(id, { enabledOnly = false, recursive = true } = {}) {
const found = new Set();
const visit = dependency => {
for (const [candidateId, record] of this.#modules) {
if (enabledOnly && !record.enabled) continue;
if (!record.manifest.requires.includes(dependency) || found.has(candidateId)) continue;
found.add(candidateId);
if (recursive) visit(candidateId);
}
};
visit(id);
return [...found];
}
#resolveStartOrder() {
const visiting = new Set();
const visited = new Set();
const order = [];
const visit = id => {
if (visited.has(id)) return;
if (visiting.has(id)) throw new PlatformError(`Zyklische Modulabhängigkeit bei ${id}.`, { code: 'MODULE_DEPENDENCY_CYCLE' });
const record = this.#modules.get(id);
if (!record) throw new PlatformError(`Fehlende Modulabhängigkeit: ${id}`, { code: 'MODULE_DEPENDENCY_MISSING' });
if (!record.enabled) return;
visiting.add(id);
for (const dependency of record.manifest.requires) {
const dependencyRecord = this.#modules.get(dependency);
if (!dependencyRecord) throw new PlatformError(`Fehlende Modulabhängigkeit: ${dependency}`, { code: 'MODULE_DEPENDENCY_MISSING' });
if (!dependencyRecord.enabled) {
throw new PlatformError(`Modul ${id} benötigt das deaktivierte Modul ${dependency}.`, {
code: 'MODULE_DEPENDENCY_DISABLED', details: { module: id, dependency },
});
}
visit(dependency);
}
visiting.delete(id);
visited.add(id);
order.push(id);
};
for (const [id, record] of this.#modules) if (record.enabled) visit(id);
const capabilities = new Set([...this.#modules.values()].filter(record => record.enabled).flatMap(record => record.manifest.provides));
for (const record of this.#modules.values()) {
if (!record.enabled) continue;
for (const capability of record.manifest.consumes) {
if (!capabilities.has(capability)) throw new PlatformError(`Fehlende Capability ${capability} für ${record.manifest.id}.`, { code: 'MODULE_CAPABILITY_MISSING' });
}
}
return order;
}
validateGraph() {
const order = this.#resolveStartOrder();
return Object.freeze({
order: Object.freeze([...order]),
enabledModules: Object.freeze(order.map(id => this.#modules.get(id)?.manifest.id).filter(Boolean)),
capabilities: Object.freeze([...new Set([...this.#modules.values()]
.filter(record => record.enabled)
.flatMap(record => record.manifest.provides))].sort()),
});
}
async #startRecord(id, context) {
const record = this.#modules.get(id);
if (!record || !record.enabled || record.state === 'running') return;
for (const dependency of record.manifest.requires) {
const dependencyRecord = this.#modules.get(dependency);
if (!dependencyRecord?.enabled) {
throw new PlatformError(`Modul ${id} benötigt das deaktivierte Modul ${dependency}.`, {
code: 'MODULE_DEPENDENCY_DISABLED', status: 409, expose: true,
details: { module: id, dependency },
});
}
if (dependencyRecord.state !== 'running') await this.#startRecord(dependency, context);
}
try {
record.state = 'starting';
record.error = null;
await record.lifecycle.start(Object.freeze({ ...context, module: record.manifest }));
record.state = 'running';
record.startedAt = new Date().toISOString();
if (!this.#startOrder.includes(id)) this.#startOrder.push(id);
} catch (error) {
record.state = 'failed';
record.error = error?.message || String(error);
throw new PlatformError(`Modulstart fehlgeschlagen: ${id}`, { code: 'MODULE_START_FAILED', cause: error, details: { module: id } });
}
}
async startAll(context) {
this.#startOrder = this.#resolveStartOrder();
const started = [];
for (const id of this.#startOrder) {
const record = this.#modules.get(id);
if (!record.enabled) continue;
try {
await this.#startRecord(id, context);
started.push(id);
} catch (error) {
for (const startedId of started.reverse()) {
const startedRecord = this.#modules.get(startedId);
try { await startedRecord.lifecycle.stop(Object.freeze({ ...context, module: startedRecord.manifest, rollback: true })); } catch {}
startedRecord.state = 'stopped';
}
throw error;
}
}
this.#started = true;
}
async enable(id, context) {
const record = this.#modules.get(id);
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
if (record.enabled && record.state === 'running') return this.describe(id);
record.enabled = true;
record.state = 'registered';
try {
this.#resolveStartOrder();
if (this.#started) await this.#startRecord(id, context);
return this.describe(id);
} catch (error) {
record.enabled = false;
record.state = 'disabled';
throw error;
}
}
async disable(id, context, { cascade = false } = {}) {
const record = this.#modules.get(id);
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
if (!record.enabled) return { module: this.describe(id), disabled: [] };
const dependents = this.dependentsOf(id, { enabledOnly: true, recursive: true });
if (dependents.length && !cascade) {
throw new PlatformError('Das Modul wird von aktiven Modulen benötigt.', {
code: 'MODULE_HAS_ACTIVE_DEPENDENTS', status: 409, expose: true, details: { dependents },
});
}
const orderIndex = new Map(this.#startOrder.map((moduleId, index) => [moduleId, index]));
const toDisable = [...dependents, id].sort((a, b) => (orderIndex.get(b) ?? -1) - (orderIndex.get(a) ?? -1));
for (const moduleId of toDisable) {
const target = this.#modules.get(moduleId);
if (!target?.enabled) continue;
if (target.state === 'running') {
target.state = 'stopping';
try {
await target.lifecycle.stop(Object.freeze({ ...context, module: target.manifest, disabled: true }));
target.state = 'disabled';
} catch (error) {
target.state = 'failed';
target.error = error?.message || String(error);
throw new PlatformError(`Modul konnte nicht deaktiviert werden: ${moduleId}`, { code: 'MODULE_STOP_FAILED', status: 500, cause: error });
}
} else {
target.state = 'disabled';
}
target.enabled = false;
target.startedAt = null;
}
return { module: this.describe(id), disabled: toDisable };
}
async unregister(id, context) {
const record = this.#modules.get(id);
if (!record) return false;
if (record.enabled || record.state === 'running') await this.disable(id, context, { cascade: false });
const dependents = this.dependentsOf(id, { enabledOnly: false, recursive: false });
if (dependents.length) {
throw new PlatformError('Modul kann wegen vorhandener Abhängigkeiten nicht entfernt werden.', {
code: 'MODULE_HAS_DEPENDENTS', status: 409, expose: true, details: { dependents },
});
}
for (const resource of record.manifest.owns) if (this.#ownership.get(resource) === id) this.#ownership.delete(resource);
this.#modules.delete(id);
this.#startOrder = this.#startOrder.filter(moduleId => moduleId !== id);
return true;
}
async stopAll(context) {
for (const id of [...this.#startOrder].reverse()) {
const record = this.#modules.get(id);
if (!record || record.state !== 'running') continue;
try {
record.state = 'stopping';
await record.lifecycle.stop(Object.freeze({ ...context, module: record.manifest }));
record.state = 'stopped';
} catch (error) {
record.state = 'failed';
record.error = error?.message || String(error);
}
}
this.#started = false;
}
async health() {
const checks = [];
for (const [id, record] of this.#modules) {
if (!record.enabled || record.state === 'disabled') {
checks.push({ id, ok: true, state: 'disabled' });
continue;
}
try {
const result = await record.lifecycle.health({ module: record.manifest });
checks.push({ id, state: record.state, ok: record.state === 'running' && result?.ok !== false, ...(result || {}) });
} catch (error) {
checks.push({ id, state: record.state, ok: false, error: error?.message || String(error) });
}
}
return checks;
}
describe(id) {
const record = this.#modules.get(id);
if (!record) return null;
return {
...record.manifest,
source: record.source,
enabled: record.enabled,
state: record.state,
startedAt: record.startedAt,
error: record.error,
dependents: this.dependentsOf(id, { enabledOnly: false, recursive: false }),
};
}
snapshot() {
return [...this.#modules.keys()].map(id => this.describe(id));
}
}
+155
View File
@@ -0,0 +1,155 @@
import { EventBus } from './event-bus.mjs';
import { FeatureFlagRegistry } from './feature-flags.mjs';
import { LifecycleManager } from './lifecycle.mjs';
import { ModuleRegistry } from './module-registry.mjs';
import { PolicyEngine } from './policy-engine.mjs';
import { RouteRegistry } from './route-registry.mjs';
import { loadBuiltInModules } from '../modules/catalog.mjs';
import { ModuleStateStore } from '../core/modules/module-state-store.mjs';
import { ModulePackageStore } from '../core/modules/module-package-store.mjs';
import { createExternalLifecycle, PROTECTED_MODULE_IDS } from '../modules/module-manager/service.mjs';
import { MODULES_DIR, MODULE_STATE_PATH } from '../../lib/runtime-paths.mjs';
export function createPlatformKernel({ version, hasPermission, moduleTrustKeys = {} }) {
const events = new EventBus();
const features = new FeatureFlagRegistry();
const modules = new ModuleRegistry();
const moduleStateStore = new ModuleStateStore(MODULE_STATE_PATH);
const modulePackageStore = new ModulePackageStore(MODULES_DIR, { trustKeys: moduleTrustKeys });
const policies = new PolicyEngine();
const routes = new RouteRegistry({ policyEngine: policies, moduleRegistry: modules });
const lifecycle = new LifecycleManager();
policies.register('platform.authenticated', ({ user }) => Boolean(user), {
description: 'Erfordert eine gültige Vendoo-Sitzung.',
});
policies.register('platform.security-admin', ({ user }) => Boolean(user && hasPermission(user.role, 'security.manage')), {
description: 'Erfordert die Berechtigung security.manage.',
});
policies.register('security.secrets.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'security.manage')), {
owner: 'vendoo.security', description: 'Erfordert security.manage für verschlüsselte Zugangsdaten.',
});
policies.register('auth.public', () => true, { owner: 'vendoo.auth', description: 'Öffentliche Authentifizierungsroute mit eigener Eingabe- und Rate-Limit-Prüfung.' });
policies.register('auth.authenticated', ({ user }) => Boolean(user), { owner: 'vendoo.auth', description: 'Erfordert eine gültige Vendoo-Identität.' });
policies.register('users.self', ({ user }) => Boolean(user), { owner: 'vendoo.users', description: 'Erlaubt Zugriff auf das eigene Benutzerprofil.' });
policies.register('users.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'users.manage')), { owner: 'vendoo.users', description: 'Erfordert users.manage.' });
policies.register('sessions.self', ({ user }) => Boolean(user), { owner: 'vendoo.sessions', description: 'Erlaubt Zugriff auf eigene Sitzungen.' });
policies.register('sessions.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'sessions.manage')), { owner: 'vendoo.sessions', description: 'Erfordert sessions.manage.' });
policies.register('settings.read', ({ user }) => Boolean(user), { owner: 'vendoo.settings', description: 'Erfordert eine gültige Sitzung zum Lesen der Anwendungseinstellungen.' });
policies.register('settings.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'settings.manage')), { owner: 'vendoo.settings', description: 'Erfordert settings.manage.' });
policies.register('listings.view', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.view')), { owner: 'vendoo.listings', description: 'Erfordert listings.view.' });
policies.register('listings.edit', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.edit')), { owner: 'vendoo.listings', description: 'Erfordert listings.edit.' });
policies.register('listings.delete', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.delete')), { owner: 'vendoo.listings', description: 'Erfordert listings.delete.' });
policies.register('listings.permanent-delete', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.permanent_delete')), { owner: 'vendoo.listings', description: 'Erfordert listings.permanent_delete.' });
policies.register('inventory.view', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.view')), { owner: 'vendoo.inventory', description: 'Erfordert listings.view für Lagerübersichten.' });
policies.register('inventory.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'inventory.edit')), { owner: 'vendoo.inventory', description: 'Erfordert inventory.edit.' });
policies.register('media.view', ({ user }) => Boolean(user && hasPermission(user.role, 'media.view')), { owner: 'vendoo.media', description: 'Erfordert media.view.' });
policies.register('media.edit', ({ user }) => Boolean(user && hasPermission(user.role, 'media.edit')), { owner: 'vendoo.media', description: 'Erfordert media.edit.' });
policies.register('media.delete', ({ user }) => Boolean(user && hasPermission(user.role, 'media.delete')), { owner: 'vendoo.media', description: 'Erfordert media.delete.' });
policies.register('themes.use', ({ user }) => Boolean(user), {
owner: 'vendoo.themes', description: 'Erfordert eine gültige Sitzung für persönliche Darstellungseinstellungen.',
});
policies.register('themes.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'settings.manage')), {
owner: 'vendoo.themes', description: 'Erfordert settings.manage für systemweite und eigene Theme-Profile.',
});
policies.register('modules.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'modules.manage')), {
owner: 'vendoo.module-manager', description: 'Erfordert modules.manage für Installation, Aktivierung, Export und Entfernung von Modulen.',
});
policies.register('publishing.view', ({ user }) => Boolean(user && hasPermission(user.role, 'publishing.view')), { owner: 'vendoo.publishing', description: 'Erfordert publishing.view.' });
policies.register('publishing.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'publishing.execute')), { owner: 'vendoo.publishing', description: 'Erfordert publishing.execute.' });
policies.register('publishing.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'publishing.manage')), { owner: 'vendoo.publishing', description: 'Erfordert publishing.manage.' });
policies.register('ai.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'generation.execute')), { owner: 'vendoo.ai', description: 'Erfordert generation.execute.' });
policies.register('flux.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'generation.execute')), { owner: 'vendoo.flux-studio', description: 'Erfordert generation.execute.' });
policies.register('quality.view', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.view')), { owner: 'vendoo.quality', description: 'Erfordert listings.view.' });
policies.register('quality.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'quality.execute')), { owner: 'vendoo.quality', description: 'Erfordert quality.execute.' });
policies.register('updates.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'updates.manage')), { owner: 'vendoo.deployments', description: 'Erfordert updates.manage für Coolify-Deployment und Web-Updates.' });
features.define('platform.module-kernel', { defaultValue: true, description: 'Aktiviert den modularen Plattformkernel.' });
features.define('platform.route-contracts', { defaultValue: true, description: 'Aktiviert registrierte Routenverträge.' });
features.define('security.encrypted-secret-store', { defaultValue: true, owner: 'vendoo.security', description: 'Aktiviert AES-256-GCM-verschlüsselte Zugangsdaten außerhalb der Runtime-Konfiguration.' });
features.define('security.request-telemetry', { defaultValue: true, owner: 'vendoo.security', description: 'Aktiviert Request-/Correlation-IDs und strukturierte Telemetrie.' });
features.define('security.strict-script-csp', { defaultValue: true, owner: 'vendoo.security', description: 'Blockiert Inline-Skripte und Inline-Eventhandler per CSP.' });
features.define('identity.native-auth', { defaultValue: true, owner: 'vendoo.auth', description: 'Aktiviert native Setup-, Login- und Einladungsabläufe.' });
features.define('identity.native-users', { defaultValue: true, owner: 'vendoo.users', description: 'Aktiviert native Benutzer- und Rollenverwaltung.' });
features.define('identity.native-sessions', { defaultValue: true, owner: 'vendoo.sessions', description: 'Aktiviert eigentümergebundene Sitzungsverwaltung und Revocation.' });
features.define('settings.native-module', { defaultValue: true, owner: 'vendoo.settings', description: 'Aktiviert allowlist-basierte Anwendungs- und SMTP-Einstellungen.' });
features.define('catalog.native-listings', { defaultValue: true, owner: 'vendoo.listings', description: 'Aktiviert native Artikel-, Duplikat- und Papierkorbverträge.' });
features.define('catalog.native-inventory', { defaultValue: true, owner: 'vendoo.inventory', description: 'Aktiviert native Lagerort- und Bulk-Bestandsverträge.' });
features.define('catalog.native-media', { defaultValue: true, owner: 'vendoo.media', description: 'Aktiviert native Medienbibliothek und Referenzschutz.' });
features.define('themes.token-runtime', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den versionierten Design-Token-Vertrag.' });
features.define('themes.manager', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den sicheren Theme Manager mit Benutzerpräferenzen.' });
features.define('themes.accessibility-gate', { defaultValue: true, owner: 'vendoo.themes', description: 'Prüft Theme-Kontraste und sichere Tokenwerte.' });
features.define('extensions.isolated-host', { defaultValue: false, owner: 'vendoo.security', description: 'Reserviert für einen späteren isolierten Extension-Host.' });
features.define('modules.manager', { defaultValue: true, owner: 'vendoo.module-manager', description: 'Aktiviert den sicheren Modulmanager und den persistenten Modulstatus.' });
features.define('modules.package-format', { defaultValue: true, owner: 'vendoo.module-manager', description: 'Aktiviert validierte .vmod-Pakete mit SHA-256-Integrität und optionaler Ed25519-Signatur.' });
features.define('publishing.native-module', { defaultValue: true, owner: 'vendoo.publishing', description: 'Aktiviert native Publishing-Verträge.' });
features.define('ai.native-module', { defaultValue: true, owner: 'vendoo.ai', description: 'Aktiviert native AI-Verträge.' });
features.define('flux.native-module', { defaultValue: true, owner: 'vendoo.flux-studio', description: 'Aktiviert native FLUX-Verträge.' });
features.define('quality.native-module', { defaultValue: true, owner: 'vendoo.quality', description: 'Aktiviert native Quality-Verträge.' });
features.define('deployments.coolify', { defaultValue: true, owner: 'vendoo.deployments', description: 'Aktiviert kontrollierte Coolify-Deployments ohne Docker-Socket.' });
features.define('updates.web-trigger', { defaultValue: true, owner: 'vendoo.deployments', description: 'Erlaubt Administratoren, einen geprüften Coolify-Deployment-Auftrag aus dem Update Center anzufordern.' });
const initialDisabledModules = new Set(String(process.env.VENDOO_INITIAL_DISABLED_MODULES || '')
.split(',').map(value => value.trim()).filter(Boolean));
for (const module of loadBuiltInModules()) {
const forcedEnabled = PROTECTED_MODULE_IDS.includes(module.manifest.id);
const defaultEnabled = module.manifest.status !== 'disabled' && !initialDisabledModules.has(module.manifest.id);
const enabled = forcedEnabled || moduleStateStore.isEnabled(module.manifest.id, defaultEnabled);
modules.register(module, { enabled });
}
for (const installed of modulePackageStore.list()) {
if (installed.invalid || modules.has(installed.id)) continue;
try {
const pkg = modulePackageStore.get(installed.id);
const enabled = installed.activatable && moduleStateStore.isEnabled(installed.id, false);
modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled });
} catch {
// Ungültige oder konfliktbehaftete Fremdmodule bleiben quarantänisiert und erscheinen im Modulmanager.
}
}
lifecycle.register({
id: 'modules',
order: 20,
start: context => modules.startAll(context),
stop: context => modules.stopAll(context),
});
const context = Object.freeze({ version, events, features, policies, modules, moduleStateStore, modulePackageStore });
return Object.freeze({
version,
events,
features,
lifecycle,
modules,
policies,
routes,
async start() {
await lifecycle.start(context);
await events.emit('platform.started', { version }, { owner: 'vendoo.system' });
},
async stop() {
await events.emit('platform.stopping', { version }, { owner: 'vendoo.system' });
await lifecycle.stop(context);
},
async health() {
const moduleChecks = await modules.health();
return {
ok: lifecycle.snapshot().state === 'running' && moduleChecks.every(check => check.ok),
lifecycle: lifecycle.snapshot(),
modules: moduleChecks,
};
},
snapshot() {
return {
version,
architecture: 'modular-monolith',
lifecycle: lifecycle.snapshot(),
modules: modules.snapshot(),
features: features.list(),
policies: policies.list(),
routes: routes.list(),
moduleState: moduleStateStore.snapshot(),
};
},
});
}
+30
View File
@@ -0,0 +1,30 @@
import { PlatformError } from './errors.mjs';
const POLICY_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
export class PolicyEngine {
#policies = new Map();
register(id, evaluator, { owner = 'vendoo.security', description = '' } = {}) {
const normalized = String(id || '');
if (!POLICY_ID.test(normalized)) throw new PlatformError(`Ungültige Policy-ID: ${normalized}`, { code: 'POLICY_ID_INVALID' });
if (this.#policies.has(normalized)) throw new PlatformError(`Policy bereits registriert: ${normalized}`, { code: 'POLICY_DUPLICATE' });
if (typeof evaluator !== 'function') throw new PlatformError(`Policy benötigt eine Prüffunktion: ${normalized}`, { code: 'POLICY_EVALUATOR_INVALID' });
this.#policies.set(normalized, { id: normalized, evaluator, owner, description });
}
async evaluate(id, context) {
const policy = this.#policies.get(id);
if (!policy) {
return { allowed: false, reason: 'policy_not_registered', policy: id };
}
const result = await policy.evaluator(Object.freeze({ ...context }));
if (result === true) return { allowed: true, policy: id };
if (result === false || result == null) return { allowed: false, reason: 'denied', policy: id };
return { allowed: Boolean(result.allowed), reason: result.reason || null, policy: id, details: result.details || null };
}
list() {
return [...this.#policies.values()].map(({ evaluator: _evaluator, ...policy }) => ({ ...policy }));
}
}
+72
View File
@@ -0,0 +1,72 @@
import { PlatformError, platformErrorPayload } from './errors.mjs';
const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']);
const ROUTE_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
export class RouteRegistry {
#routes = new Map();
#policyEngine;
#moduleRegistry;
constructor({ policyEngine, moduleRegistry = null }) {
this.#policyEngine = policyEngine;
this.#moduleRegistry = moduleRegistry;
}
register(app, definition) {
const method = String(definition.method || '').toLowerCase();
const id = String(definition.id || '');
const path = String(definition.path || '');
if (!ROUTE_ID.test(id)) throw new PlatformError(`Ungültige Route-ID: ${id}`, { code: 'ROUTE_ID_INVALID' });
if (!METHODS.has(method)) throw new PlatformError(`Ungültige HTTP-Methode für ${id}.`, { code: 'ROUTE_METHOD_INVALID' });
if (!path.startsWith('/')) throw new PlatformError(`Ungültiger Routenpfad für ${id}.`, { code: 'ROUTE_PATH_INVALID' });
if (!definition.policy) throw new PlatformError(`Route ${id} benötigt eine explizite Policy.`, { code: 'ROUTE_POLICY_REQUIRED' });
if (!definition.owner) throw new PlatformError(`Route ${id} benötigt einen Modulbesitzer.`, { code: 'ROUTE_OWNER_REQUIRED' });
if (typeof definition.handler !== 'function') throw new PlatformError(`Route ${id} benötigt einen Handler.`, { code: 'ROUTE_HANDLER_REQUIRED' });
const key = `${method.toUpperCase()} ${path}`;
if (this.#routes.has(key)) throw new PlatformError(`Route bereits registriert: ${key}`, { code: 'ROUTE_DUPLICATE' });
const contract = Object.freeze({
id,
method: method.toUpperCase(),
path,
owner: String(definition.owner),
policy: String(definition.policy),
audit: String(definition.audit || ''),
input: definition.input ? 'validated' : 'none',
rateLimit: String(definition.rateLimit || 'default'),
csrf: definition.csrf !== false,
stability: String(definition.stability || 'internal'),
});
this.#routes.set(key, contract);
app[method](path, ...(definition.middleware || []), async (req, res, next) => {
try {
if (this.#moduleRegistry && !this.#moduleRegistry.isEnabled(contract.owner)) {
throw new PlatformError('Dieses Modul ist deaktiviert.', {
code: 'MODULE_DISABLED', status: 503, expose: true,
details: { module: contract.owner },
});
}
const decision = await this.#policyEngine.evaluate(contract.policy, { req, user: req.user, route: contract });
if (!decision.allowed) {
throw new PlatformError('Keine Berechtigung für diese Aktion.', {
code: 'POLICY_DENIED', status: 403, expose: true,
details: { policy: contract.policy, reason: decision.reason },
});
}
if (typeof definition.input === 'function') req.validatedBody = definition.input(req.body);
await definition.handler(req, res, { route: contract, decision });
} catch (error) {
if (res.headersSent) return next(error);
const status = Number.isInteger(error?.status) ? error.status : 500;
res.status(status).json(platformErrorPayload(error, req.requestId));
}
});
return contract;
}
list() {
return [...this.#routes.values()].map(route => ({ ...route }));
}
}
+34
View File
@@ -0,0 +1,34 @@
import manifest from './module.json' with { type: 'json' };
let runtime = null;
let started = false;
export function configureAiModule(deps = {}) {
runtime = {
startModelJobs: deps.startModelJobs || (() => {}),
stopModelJobs: deps.stopModelJobs || (() => {}),
startBatchJobs: deps.startBatchJobs || (() => {}),
stopBatchJobs: deps.stopBatchJobs || (() => {}),
};
}
export function createAiModule() {
return {
manifest,
source: 'builtin',
lifecycle: {
async start() {
if (!runtime) throw new Error('AI-Modul ist nicht konfiguriert.');
runtime.startModelJobs();
runtime.startBatchJobs();
started = true;
},
async stop() {
runtime?.stopModelJobs();
runtime?.stopBatchJobs();
started = false;
},
async health() { return { ok: Boolean(runtime && started), native: true, workers: ['model-photos', 'image-batch'] }; },
},
};
}
+28
View File
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "vendoo.ai",
"name": "AI Services",
"version": "1.0.0",
"type": "feature",
"status": "native",
"description": "Native AI-Services für Text-, Modellfoto- und Batch-Jobs mit kontrolliertem Worker-Lifecycle.",
"requires": [
"vendoo.security",
"vendoo.media"
],
"permissions": [
"generation.execute"
],
"provides": [
"ai.text",
"ai.images"
],
"consumes": [
"media.assets",
"security.policies"
],
"owns": [
"ai.providers",
"ai.jobs"
]
}
+32
View File
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PlatformError } from '../../kernel/errors.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
let service = null;
let setupTokenMatcher = () => false;
export function configureAuthModule({ setupTokenMatches }) {
if (typeof setupTokenMatches === 'function') setupTokenMatcher = setupTokenMatches;
}
export function getAuthService() {
if (!service) throw new PlatformError('Auth-Modul ist noch nicht gestartet.', { code: 'AUTH_MODULE_NOT_READY', status: 503, expose: true });
return service;
}
export function createAuthModule() {
return {
manifest,
lifecycle: {
async start() {
const { createAuthService } = await import('./service.mjs');
service = createAuthService({ setupTokenMatches: setupTokenMatcher });
},
async stop() { service = null; },
async health() { return getAuthService().health(); },
},
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "vendoo.auth",
"name": "Authentication",
"version": "2.0.0",
"type": "core",
"status": "native",
"description": "Native Authentifizierung mit Setup, Login, Einladungen, Passwortregeln und sicheren Sitzungsübergaben.",
"requires": ["vendoo.security"],
"permissions": ["sessions.manage"],
"provides": ["auth.identity", "auth.tokens"],
"consumes": ["security.policies"],
"owns": ["auth.tokens", "auth.login-history"]
}
+67
View File
@@ -0,0 +1,67 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { getAuthService } from './index.mjs';
const emailRule = { type: 'string', required: true, minLength: 3, maxLength: 254, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ };
export function registerPublicAuthRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'auth.setup-status.read', method: 'GET', path: '/auth/setup-check', owner: 'vendoo.auth', policy: 'auth.public',
csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getAuthService().setupStatus({ mailConfigured: deps.isMailerConfigured(), setupTokenRequired: deps.setupTokenRequired })),
});
routes.register(app, {
id: 'auth.setup.create', method: 'POST', path: '/auth/setup', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, password: { type: 'string', required: true, maxLength: 512, trim: false }, setup_token: { type: 'string', maxLength: 512, trim: false } }),
handler: async (req, res) => {
const result = getAuthService().setup({ ...req.validatedBody, setupToken: req.get('x-vendoo-setup-token') || req.validatedBody.setup_token, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
deps.setSessionCookies(req, res, result.session, result.csrf);
res.json({ ok: true, redirect: '/' });
},
});
routes.register(app, {
id: 'auth.login.create', method: 'POST', path: '/auth/login', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
input: objectSchema({ email: emailRule, password: { type: 'string', required: true, maxLength: 512, trim: false } }),
handler: async (req, res) => {
const result = getAuthService().login({ ...req.validatedBody, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
deps.setSessionCookies(req, res, result.session, result.csrf);
res.json({ ok: true, redirect: '/' });
},
});
routes.register(app, {
id: 'auth.invite.create', method: 'POST', path: '/auth/invite', owner: 'vendoo.auth', policy: 'auth.authenticated',
middleware: [deps.requireAuth, deps.csrfProtect], csrf: true, stability: 'stable',
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: ['admin','manager','editor','publisher','viewer'] } }),
handler: async (req, res) => {
const { token } = getAuthService().createInvite({ email: req.validatedBody.email, role: req.validatedBody.role || 'editor', createdBy: req.user.id });
const result = await deps.sendMagicLink(req.validatedBody.email, token, `${req.protocol}://${req.get('host')}`, 'invite');
deps.auditLog(req.user.id, req.user.email, 'user.invited', 'user', req.validatedBody.email, `Rolle: ${req.validatedBody.role || 'editor'}`, deps.getClientIp(req));
res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false });
},
});
routes.register(app, {
id: 'auth.magic.redirect', method: 'GET', path: '/auth/magic', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
handler: async (req, res) => {
const token = String(req.query?.token || '');
if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben'));
res.redirect(`/login.html?invite=${encodeURIComponent(token)}`);
},
});
routes.register(app, {
id: 'auth.invite.accept', method: 'POST', path: '/auth/accept-invite', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
input: objectSchema({ token: { type: 'string', required: true, minLength: 16, maxLength: 512, trim: false }, password: { type: 'string', required: true, maxLength: 512, trim: false }, name: { type: 'string', maxLength: 120 } }),
handler: async (req, res) => {
const result = getAuthService().acceptInvite({ ...req.validatedBody, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
deps.setSessionCookies(req, res, result.session, result.csrf);
res.json({ ok: true, redirect: '/' });
},
});
routes.register(app, {
id: 'auth.logout.create', method: 'POST', path: '/auth/logout', owner: 'vendoo.auth', policy: 'auth.authenticated',
middleware: [deps.requireAuth, deps.csrfProtect], csrf: true, stability: 'stable',
handler: async (req, res) => {
getAuthService().logout({ token: req.cookies?.auth_token, ip: deps.getClientIp(req) });
deps.clearSessionCookies(res);
res.json({ ok: true });
},
});
}
+100
View File
@@ -0,0 +1,100 @@
import {
auditLog, checkRateLimit, clearFailedLogins, createAuthToken, createSession, createUser,
generateCsrfToken, getUserByEmail, isSetupMode, isUserLocked, recordLogin,
registerFailedLogin, setPassword, updateUser, validateAuthToken, validatePasswordStrength, verifyPassword,
validateSession, destroySession,
} from '../../core/identity/identity-store.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
function fail(message, status = 400, code = 'AUTH_FAILED', details = null) {
throw new PlatformError(message, { status, code, expose: true, details });
}
export function createAuthService({ setupTokenMatches }) {
return Object.freeze({
setupStatus({ mailConfigured, setupTokenRequired }) {
return { setupMode: isSetupMode(), mailConfigured: Boolean(mailConfigured), setupTokenRequired: Boolean(setupTokenRequired) };
},
setup({ email, name, password, setupToken, ip, userAgent }) {
if (!isSetupMode()) fail('Setup bereits abgeschlossen', 400, 'SETUP_COMPLETED');
if (!setupTokenMatches(setupToken)) fail('Ungültiger Einrichtungs-Code.', 403, 'SETUP_TOKEN_INVALID');
const passwordCheck = validatePasswordStrength(password);
if (!passwordCheck.valid) fail(`Passwort benötigt ${passwordCheck.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
try {
const user = createUser(email, name || email.split('@')[0], 'admin', null, password);
auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip);
const session = createSession(user.id, ip, userAgent);
recordLogin(user.id, ip, userAgent, true);
auditLog(user.id, email, 'login.success', null, null, 'via setup', ip);
return { user, session, csrf: generateCsrfToken() };
} catch (error) {
fail(error?.message || 'Setup fehlgeschlagen.', 500, 'SETUP_FAILED');
}
},
login({ email, password, ip, userAgent }) {
const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000);
if (!rl.allowed) fail(`Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.`, 429, 'LOGIN_RATE_LIMIT', { retry_after: rl.retryAfter });
const user = getUserByEmail(email);
if (user && isUserLocked(user)) {
auditLog(user.id, email, 'login.blocked_locked', null, null, user.locked_until, ip);
fail('Account nach mehreren Fehlversuchen vorübergehend gesperrt.', 423, 'ACCOUNT_LOCKED', { locked_until: user.locked_until });
}
if (!user || !verifyPassword(password, user.password_hash)) {
if (user) {
recordLogin(user.id, ip, userAgent, false);
const lockResult = registerFailedLogin(user.id);
if (lockResult?.locked) {
auditLog(user.id, email, 'login.account_locked', 'user', String(user.id), lockResult.lockedUntil, ip);
fail('Zu viele Fehlversuche. Account für 15 Minuten gesperrt.', 423, 'ACCOUNT_LOCKED', { locked_until: lockResult.lockedUntil });
}
}
auditLog(user?.id || null, email, 'login.failed', null, null, null, ip);
fail('E-Mail oder Passwort falsch', 401, 'LOGIN_INVALID');
}
if (!user.active) fail('Account deaktiviert', 403, 'ACCOUNT_DISABLED');
clearFailedLogins(user.id);
const session = createSession(user.id, ip, userAgent);
recordLogin(user.id, ip, userAgent, true);
auditLog(user.id, email, 'login.success', null, null, 'via password', ip);
return { user, session, csrf: generateCsrfToken() };
},
createInvite({ email, role, createdBy }) {
return createAuthToken(email, 'invite', role || 'editor', createdBy);
},
acceptInvite({ token, password, name, ip, userAgent }) {
const passwordCheck = validatePasswordStrength(password);
if (!passwordCheck.valid) fail(`Passwort benötigt ${passwordCheck.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
const authToken = validateAuthToken(token);
if (!authToken) fail('Link ungültig oder abgelaufen', 400, 'INVITE_INVALID');
let user = getUserByEmail(authToken.email);
if (!user) {
user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password);
auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip);
} else {
setPassword(user.id, password);
if (name) user = updateUser(user.id, { name });
}
if (!user.active) fail('Account deaktiviert', 403, 'ACCOUNT_DISABLED');
const session = createSession(user.id, ip, userAgent);
recordLogin(user.id, ip, userAgent, true);
auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip);
return { user, session, csrf: generateCsrfToken() };
},
logout({ token, ip }) {
if (!token) return { ok: true };
const session = validateSession(token);
if (session) auditLog(session.user_id, session.email, 'logout', null, null, null, ip);
destroySession(token);
return { ok: true };
},
health() {
return { ok: true, setup_mode: isSetupMode() };
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { createAuthModule } from './auth/index.mjs';
import { createSecurityModule } from './security/index.mjs';
import { createSessionsModule } from './sessions/index.mjs';
import { createSettingsModule } from './settings/index.mjs';
import { createListingsModule } from './listings/index.mjs';
import { createInventoryModule } from './inventory/index.mjs';
import { createMediaModule } from './media/index.mjs';
import { createThemesModule } from './themes/index.mjs';
import { createUsersModule } from './users/index.mjs';
import { createModuleManagerModule } from './module-manager/index.mjs';
import { createAiModule } from './ai/index.mjs';
import { createFluxStudioModule } from './flux-studio/index.mjs';
import { createQualityModule } from './quality/index.mjs';
import { createPublishingModule } from './publishing/index.mjs';
import { createDeploymentsModule } from './deployments/index.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const legacyModuleNames = ['system', 'operations'];
function legacyModule(name) {
return {
manifest: JSON.parse(readFileSync(join(here, name, 'module.json'), 'utf8')),
lifecycle: {
async start() {}, async stop() {}, async health() { return { ok: true, bridge: 'legacy' }; },
},
};
}
export function loadBuiltInModules() {
return [
...legacyModuleNames.map(legacyModule),
createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(),
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
];
}
+28
View File
@@ -0,0 +1,28 @@
import manifest from './module.json' with { type: 'json' };
import { PlatformError } from '../../kernel/errors.mjs';
import { createDeploymentService } from './service.mjs';
let service = null;
let adapters = {};
export function configureDeploymentsModule(nextAdapters = {}) {
adapters = { ...adapters, ...nextAdapters };
}
export function getDeploymentService() {
if (!service) throw new PlatformError('Deployment-Modul ist noch nicht gestartet.', {
code: 'DEPLOYMENT_MODULE_NOT_READY', status: 503, expose: true,
});
return service;
}
export function createDeploymentsModule() {
return {
manifest,
lifecycle: {
async start() { service = createDeploymentService(adapters); },
async stop() { service = null; },
async health() { return getDeploymentService().health(); },
},
};
}
+34
View File
@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"id": "vendoo.deployments",
"name": "Deployments & Updates",
"version": "1.0.0",
"type": "core",
"status": "native",
"description": "Kontrollierte Coolify-Deployments, verschlüsselte Deployment-Zugangsdaten und sichere Web-Updates ohne Docker-Socket.",
"requires": [
"vendoo.security",
"vendoo.settings",
"vendoo.operations",
"vendoo.module-manager"
],
"permissions": [
"updates.manage"
],
"provides": [
"deployments.control",
"deployments.coolify",
"updates.web"
],
"consumes": [
"security.secrets",
"settings.configuration",
"operations.backup",
"modules.management"
],
"owns": [
"deployments.configuration",
"deployments.history",
"updates.coolify"
]
}
+62
View File
@@ -0,0 +1,62 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { getDeploymentService } from './index.mjs';
const settingsSchema = objectSchema({
provider: { type: 'string', enum: ['none', 'coolify'] },
coolify_url: { type: 'string', maxLength: 1000 },
resource_uuid: { type: 'string', maxLength: 120 },
trigger_mode: { type: 'string', enum: ['webhook', 'api'] },
deploy_method: { type: 'string', enum: ['GET', 'POST'] },
deploy_endpoint: { type: 'string', maxLength: 500 },
github_repository: { type: 'string', maxLength: 200 },
github_branch: { type: 'string', maxLength: 120 },
ghcr_image: { type: 'string', maxLength: 300 },
update_channel: { type: 'string', enum: ['stable', 'beta'] },
backup_before_deploy: { type: 'boolean' },
backup_uploads: { type: 'boolean' },
api_token: { type: 'string', maxLength: 16384, trim: false },
deploy_webhook_url: { type: 'string', maxLength: 2000, trim: false },
});
const triggerSchema = objectSchema({
force: { type: 'boolean' }, confirmation: { type: 'string', required: true, maxLength: 20 }, reason: { type: 'string', maxLength: 120 },
});
export function registerDeploymentRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'deployments.status.read', method: 'GET', path: '/api/admin/deployment', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.status.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getDeploymentService().status()),
});
routes.register(app, {
id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.settings.updated', rateLimit: 'administration', csrf: true, stability: 'stable', input: settingsSchema,
handler: async (req, res) => {
const result = getDeploymentService().update(req.validatedBody || {});
deps.auditLog(req.user.id, req.user.email, 'deployment.settings.updated', 'deployment', 'coolify', null, deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'deployments.connection.test', method: 'POST', path: '/api/admin/deployment/test', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.connection.tested', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (req, res) => {
const result = await getDeploymentService().testConnection();
deps.auditLog(req.user.id, req.user.email, 'deployment.connection.tested', 'deployment', 'coolify', JSON.stringify({ ok: result.ok }), deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'deployments.updates.check', method: 'POST', path: '/api/admin/deployment/updates/check', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.updates.checked', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (_req, res) => res.json(await getDeploymentService().checkUpdates()),
});
routes.register(app, {
id: 'deployments.trigger', method: 'POST', path: '/api/admin/deployment/trigger', owner: 'vendoo.deployments',
policy: 'updates.manage', audit: 'deployments.triggered', rateLimit: 'administration', csrf: true, stability: 'stable', input: triggerSchema,
handler: async (req, res) => {
const result = await getDeploymentService().trigger(req.validatedBody || {});
deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', 'coolify', JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch }), deps.getClientIp(req));
res.status(202).json(result);
},
});
}
+244
View File
@@ -0,0 +1,244 @@
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json');
const PROVIDERS = new Set(['none', 'coolify']);
const MODES = new Set(['webhook', 'api']);
const CHANNELS = new Set(['stable', 'beta']);
const METHODS = new Set(['GET', 'POST']);
function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) {
throw new PlatformError(message, { code, status, expose: true, details });
}
function clean(value, max = 1000) {
const text = String(value ?? '').trim();
if (text.length > max) fail('Deployment-Einstellung ist zu lang.');
if (/\r|\n|\0/.test(text)) fail('Deployment-Einstellung enthält unzulässige Steuerzeichen.');
return text;
}
function cleanUuid(value) {
const text = clean(value, 120);
if (text && !/^[a-zA-Z0-9_-]{6,120}$/.test(text)) fail('Coolify Resource UUID besitzt ein ungültiges Format.');
return text;
}
function normalizeBaseUrl(value) {
const text = clean(value, 1000).replace(/\/$/, '');
if (!text) return '';
let url;
try { url = new URL(text); } catch { fail('Coolify URL ist ungültig.'); }
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify URL muss HTTP oder HTTPS verwenden.');
if (url.username || url.password || url.search || url.hash) fail('Coolify URL darf keine Zugangsdaten, Query oder Fragment enthalten.');
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
fail('In Produktion muss die Coolify URL HTTPS verwenden.');
}
return url.toString().replace(/\/$/, '');
}
function normalizeWebhookUrl(value) {
const text = clean(value, 2000);
if (!text) return '';
let url;
try { url = new URL(text); } catch { fail('Coolify Deploy-Webhook ist ungültig.'); }
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify Deploy-Webhook muss HTTP oder HTTPS verwenden.');
if (url.username || url.password || url.hash) fail('Coolify Deploy-Webhook darf keine URL-Zugangsdaten oder Fragmente enthalten.');
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
fail('In Produktion muss der Coolify Deploy-Webhook HTTPS verwenden.');
}
return url.toString();
}
function readLastDeployment() {
try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; }
catch { return null; }
}
function writeLastDeployment(value) {
mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true });
const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(temporary, LAST_DEPLOYMENT_PATH);
}
function currentSettings() {
const provider = PROVIDERS.has(process.env.VENDOO_DEPLOY_PROVIDER) ? process.env.VENDOO_DEPLOY_PROVIDER : 'none';
const mode = MODES.has(process.env.COOLIFY_TRIGGER_MODE) ? process.env.COOLIFY_TRIGGER_MODE : 'webhook';
const channel = CHANNELS.has(process.env.VENDOO_UPDATE_CHANNEL) ? process.env.VENDOO_UPDATE_CHANNEL : 'stable';
const method = METHODS.has(process.env.COOLIFY_DEPLOY_METHOD) ? process.env.COOLIFY_DEPLOY_METHOD : 'GET';
return {
provider,
coolify_url: String(process.env.COOLIFY_API_URL || '').replace(/\/$/, ''),
resource_uuid: String(process.env.COOLIFY_RESOURCE_UUID || ''),
trigger_mode: mode,
deploy_method: method,
deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'),
github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'),
github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'),
ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'),
update_channel: channel,
backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')),
backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')),
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
};
}
function publicSettings() {
const settings = currentSettings();
const configured = settings.provider === 'coolify' && (
(settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) ||
(settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured)
);
return {
...settings,
configured,
api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider },
deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider },
current_version: APP_VERSION,
last_deployment: readLastDeployment(),
docker_socket_access: false,
};
}
function buildApiDeployUrl(settings, force) {
const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}';
if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.');
if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.');
const path = endpoint
.replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid))
.replaceAll('{force}', force ? 'true' : 'false');
return `${settings.coolify_url}${path}`;
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); }
catch (error) {
if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504);
fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502);
} finally { clearTimeout(timeout); }
}
async function readSafeResponse(response) {
const text = (await response.text()).slice(0, 4000);
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
}
export function createDeploymentService({ createBackup, checkForUpdates } = {}) {
if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
async function testConnection() {
const settings = currentSettings();
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
if (!settings.coolify_url) fail('Coolify URL fehlt.');
const response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000);
const body = await readSafeResponse(response);
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body });
return { ok: true, status: response.status, response: body, url: settings.coolify_url };
}
async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) {
if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
const settings = currentSettings();
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
let backup = null;
if (settings.backup_before_deploy) {
backup = await createBackup({
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
label: `pre-deploy-${APP_VERSION}`,
});
}
let url;
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
let method = settings.deploy_method;
if (settings.trigger_mode === 'webhook') {
url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL'));
if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.');
} else {
const token = getSecret('COOLIFY_API_TOKEN');
if (!settings.coolify_url || !settings.resource_uuid || !token) fail('Coolify API URL, Resource UUID oder API Token fehlt.');
url = buildApiDeployUrl(settings, Boolean(force));
headers.Authorization = `Bearer ${token}`;
}
const requestedAt = new Date().toISOString();
const response = await fetchWithTimeout(url, { method, headers }, 20000);
const body = await readSafeResponse(response);
const record = {
requested_at: requestedAt,
provider: 'coolify', trigger_mode: settings.trigger_mode, method,
resource_uuid: settings.resource_uuid || null,
github_repository: settings.github_repository, github_branch: settings.github_branch,
version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120),
accepted: response.ok, http_status: response.status,
backup_id: backup?.id || null, backup_file: backup?.filename || null,
response: body,
};
writeLastDeployment(record);
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null });
return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' };
}
function update(input = {}) {
const provider = clean(input.provider || 'none', 20);
const mode = clean(input.trigger_mode || 'webhook', 20);
const channel = clean(input.update_channel || 'stable', 20);
const method = clean(input.deploy_method || 'GET', 10).toUpperCase();
if (!PROVIDERS.has(provider)) fail('Unbekannter Deployment-Provider.');
if (!MODES.has(mode)) fail('Unbekannter Coolify Trigger-Modus.');
if (!CHANNELS.has(channel)) fail('Unbekannter Update-Kanal.');
if (!METHODS.has(method)) fail('Coolify Deploy-Methode muss GET oder POST sein.');
const values = {
VENDOO_DEPLOY_PROVIDER: provider,
COOLIFY_API_URL: normalizeBaseUrl(input.coolify_url || ''),
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
COOLIFY_TRIGGER_MODE: mode,
COOLIFY_DEPLOY_METHOD: method,
COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500),
VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200),
VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120),
VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300),
VENDOO_UPDATE_CHANNEL: channel,
VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true',
VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false',
};
let content = readRuntimeConfig();
for (const [key, value] of Object.entries(values)) {
content = setConfigValue(content, key, value);
process.env[key] = value;
}
writeRuntimeConfig(content);
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') {
setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' });
}
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') {
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
}
return publicSettings();
}
return Object.freeze({
status: publicSettings,
update,
testConnection,
trigger,
async checkUpdates() {
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
return checkForUpdates();
},
health() {
const status = publicSettings();
return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false };
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import manifest from './module.json' with { type: 'json' };
let runtime = null;
let started = false;
export function configureFluxStudioModule(deps = {}) {
runtime = { start: deps.start || (() => {}), stop: deps.stop || (() => {}) };
}
export function createFluxStudioModule() {
return {
manifest,
source: 'builtin',
lifecycle: {
async start() { if (!runtime) throw new Error('FLUX-Modul ist nicht konfiguriert.'); runtime.start(); started = true; },
async stop() { runtime?.stop(); started = false; },
async health() { return { ok: Boolean(runtime && started), native: true, worker: 'flux-prompt' }; },
},
};
}
+25
View File
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"id": "vendoo.flux-studio",
"name": "FLUX Studio",
"version": "1.0.0",
"type": "feature",
"status": "native",
"description": "Native FLUX-Studio-Verträge und kontrollierter Prompt-Job-Worker.",
"requires": [
"vendoo.ai"
],
"permissions": [
"generation.execute"
],
"provides": [
"flux.prompt-jobs"
],
"consumes": [
"ai.images"
],
"owns": [
"flux.generations",
"flux.history"
]
}
+6
View File
@@ -0,0 +1,6 @@
import manifest from './module.json' with { type:'json' };
import { InventoryService } from './service.mjs';
let service = null;
export function configureInventoryModule({ repository }) { service = new InventoryService(repository); }
export function getInventoryService(){ if (!service) throw new Error('Inventory-Modul ist nicht konfiguriert.'); return service; }
export function createInventoryModule(){ return { manifest, lifecycle:{ async start(){}, async stop(){}, async health(){ return {ok:Boolean(service),native:true,contracts:['locations','bulk-update']}; } } }; }
+27
View File
@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"id": "vendoo.inventory",
"name": "Inventory",
"version": "1.0.0",
"type": "feature",
"status": "native",
"description": "Native Lagerort- und Bestandsverwaltung mit begrenzten Bulk-Aktionen.",
"requires": [
"vendoo.auth",
"vendoo.listings"
],
"permissions": [
"listings.view",
"inventory.edit"
],
"provides": [
"inventory.stock"
],
"consumes": [
"auth.identity",
"listings.catalog"
],
"owns": [
"inventory.locations"
]
}
+2
View File
@@ -0,0 +1,2 @@
import { getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation } from '../../../lib/db.mjs';
export const inventoryRepository = Object.freeze({ locations:getStorageLocations, byLocation:getListingsByLocation, unassigned:getUnassignedListings, bulkUpdateLocation });
+7
View File
@@ -0,0 +1,7 @@
import { getInventoryService } from './index.mjs';
export function registerInventoryRoutes({app,routes,deps}){ const service=getInventoryService();
routes.register(app,{id:'inventory.locations.list',method:'GET',path:'/api/inventory/locations',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(_q,r)=>r.json(service.locations())});
routes.register(app,{id:'inventory.unassigned.list',method:'GET',path:'/api/inventory/unassigned',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(_q,r)=>r.json(service.unassigned())});
routes.register(app,{id:'inventory.location.read',method:'GET',path:'/api/inventory/location/:loc',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(q,r)=>r.json(service.byLocation(q.params.loc))});
routes.register(app,{id:'inventory.location.bulk-update',method:'PUT',path:'/api/inventory/bulk-location',owner:'vendoo.inventory',policy:'inventory.manage',stability:'stable',input:body=>service.validateBulk(body),handler:async(q,r)=>{const result=service.bulk(q.validatedBody);deps.auditLog(q.user.id,q.user.email,'inventory.location_bulk_updated','listing',null,JSON.stringify({count:result.updated,location:q.validatedBody.location}),deps.getClientIp(q));r.json(result);}});
}
+10
View File
@@ -0,0 +1,10 @@
import { PlatformError } from '../../kernel/errors.mjs';
function cleanLocation(value){ const text=String(value ?? '').trim(); if(text.length>160 || /[\u0000\r\n]/.test(text)) throw new PlatformError('Lagerort ist ungültig.',{code:'INVENTORY_LOCATION_INVALID',status:400,expose:true}); return text; }
export class InventoryService {
constructor(repository) { this.repository = repository; }
locations(){ return this.repository.locations(); }
unassigned(){ return this.repository.unassigned(); }
byLocation(location){ return this.repository.byLocation(cleanLocation(location)); }
validateBulk(input={}){ for(const key of Object.keys(input||{})) if(!['ids','location'].includes(key)) throw new PlatformError(`Unbekanntes Lagerfeld: ${key}`,{code:'INVENTORY_INPUT_INVALID',status:400,expose:true}); const ids=Array.isArray(input.ids)?[...new Set(input.ids.map(Number).filter(Number.isInteger).filter(id=>id>0))]:[]; if(!ids.length) throw new PlatformError('Mindestens eine Artikel-ID ist erforderlich.',{code:'INVENTORY_IDS_REQUIRED',status:400,expose:true}); if(ids.length>500) throw new PlatformError('Maximal 500 Artikel pro Vorgang.',{code:'INVENTORY_BATCH_TOO_LARGE',status:400,expose:true}); return Object.freeze({ ids, location:cleanLocation(input.location) }); }
bulk(input){ const valid=this.validateBulk(input); return { ok:true, updated:this.repository.bulkUpdateLocation(valid.ids, valid.location) }; }
}
+6
View File
@@ -0,0 +1,6 @@
import manifest from './module.json' with { type: 'json' };
import { ListingsService } from './service.mjs';
let service = null;
export function configureListingsModule({ repository }) { service = new ListingsService(repository); }
export function getListingsService() { if (!service) throw new Error('Listings-Modul ist nicht konfiguriert.'); return service; }
export function createListingsModule() { return { manifest, lifecycle: { async start(){}, async stop(){}, async health(){ return { ok:Boolean(service), native:true, contracts:['catalog','trash'] }; } } }; }
+30
View File
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"id": "vendoo.listings",
"name": "Listings",
"version": "2.0.0",
"type": "feature",
"status": "native",
"description": "Native Artikelverwaltung mit validierten Payloads, Papierkorb, Duplikaten und Audit-Verträgen.",
"requires": [
"vendoo.auth",
"vendoo.media"
],
"permissions": [
"listings.view",
"listings.edit",
"listings.delete",
"listings.permanent-delete"
],
"provides": [
"listings.catalog"
],
"consumes": [
"media.assets",
"auth.identity"
],
"owns": [
"listings.records",
"listings.trash"
]
}
+18
View File
@@ -0,0 +1,18 @@
import {
createListing, getListing, getListings, updateListing, softDeleteListing,
restoreListing, getDeletedListings, permanentlyDeleteListing, getTrashCount,
duplicateListing,
} from '../../../lib/db.mjs';
export const listingsRepository = Object.freeze({
create: createListing,
get: getListing,
list: getListings,
update: updateListing,
softDelete: softDeleteListing,
restore: restoreListing,
listDeleted: getDeletedListings,
permanentDelete: permanentlyDeleteListing,
trashCount: getTrashCount,
duplicate: duplicateListing,
});
+19
View File
@@ -0,0 +1,19 @@
import { getListingsService } from './index.mjs';
export function registerListingsRoutes({ app, routes, deps }) {
const service = getListingsService();
routes.register(app, { id:'listings.catalog.create', method:'POST', path:'/api/listings', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', input: body => service.validateCreate(body),
handler: async (req,res) => { const item = service.create(req.validatedBody, req.user.id); deps.auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
routes.register(app, { id:'listings.catalog.list', method:'GET', path:'/api/listings', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable',
handler: async (req,res) => res.json(service.list(req.query || {})) });
routes.register(app, { id:'listings.catalog.read', method:'GET', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler: async (req,res)=>res.json(service.get(req.params.id)) });
routes.register(app, { id:'listings.catalog.update', method:'PUT', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', input: body => service.validateUpdate(Object.fromEntries(Object.entries(body || {}).filter(([k]) => k !== '_edit_lock'))),
handler: async (req,res) => { const lock = deps.validateResourceLock({ resourceType:'listing', resourceId:req.params.id, userId:req.user.id, token:req.get('x-edit-lock') || req.body?._edit_lock }); if (!lock.valid) return res.status(409).json({ error:`${lock.lock?.user_name || lock.lock?.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Artikel gerade.`, code:'EDIT_LOCKED', lock:lock.lock }); const item=service.update(req.params.id, req.validatedBody, req.user.id); deps.auditLog(req.user.id, req.user.email, 'listing.updated', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
routes.register(app, { id:'listings.catalog.delete', method:'DELETE', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.delete', stability:'stable', handler: async (req,res)=>{ const result=service.softDelete(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
routes.register(app, { id:'listings.catalog.duplicate', method:'POST', path:'/api/listings/:id/duplicate', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', handler: async (req,res)=>{ const item=service.duplicate(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
routes.register(app, { id:'listings.trash.list', method:'GET', path:'/api/trash', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler:async(_req,res)=>res.json(service.trash()) });
routes.register(app, { id:'listings.trash.count', method:'GET', path:'/api/trash/count', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler:async(_req,res)=>res.json(service.trashCount()) });
routes.register(app, { id:'listings.trash.restore', method:'POST', path:'/api/trash/:id/restore', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', handler:async(req,res)=>{ const result=service.restore(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
routes.register(app, { id:'listings.trash.permanent-delete', method:'DELETE', path:'/api/trash/:id', owner:'vendoo.listings', policy:'listings.permanent-delete', stability:'stable', handler:async(req,res)=>{ const result=service.permanentDelete(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
routes.register(app, { id:'listings.trash.empty', method:'DELETE', path:'/api/trash', owner:'vendoo.listings', policy:'listings.permanent-delete', stability:'stable', handler:async(req,res)=>{ const result=service.emptyTrash(); deps.auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${result.deleted} Einträge`, deps.getClientIp(req)); res.json(result); } });
}
+68
View File
@@ -0,0 +1,68 @@
import { PlatformError } from '../../kernel/errors.mjs';
import { sanitizeRichHtml } from '../../../lib/html-templates.mjs';
const ALLOWED_FIELDS = new Set([
'platform','ai_provider','title','description','description_html','tags','photos','seller_notes',
'brand','category','size','color','condition','price','status','language','sku','storage_location',
]);
const ARRAY_LIMITS = { tags: 50, photos: 100 };
function fail(message, field, status = 400, code = 'LISTING_INPUT_INVALID') {
throw new PlatformError(message, { code, status, expose: true, details: { field } });
}
function htmlToPlainText(html) {
return String(html || '').replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n')
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/\n{3,}/g, '\n\n').trim();
}
function cleanText(value, max, field, { required = false } = {}) {
const text = String(value ?? '').trim();
if (required && !text) fail(`${field} ist erforderlich.`, field);
if (text.length > max) fail(`${field} ist zu lang.`, field);
if (/\u0000/.test(text)) fail(`${field} enthält ungültige Steuerzeichen.`, field);
return text;
}
function normalizePayload(input = {}, { partial = false } = {}) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
for (const key of Object.keys(source)) if (!ALLOWED_FIELDS.has(key)) fail(`Unbekanntes Artikelfeld: ${key}`, key);
const out = {};
const textRules = {
platform:64, ai_provider:64, title:240, description:12000, seller_notes:6000, brand:160,
category:240, size:120, color:120, condition:120, status:64, language:16, sku:80, storage_location:160,
};
for (const [field,max] of Object.entries(textRules)) if (source[field] !== undefined) out[field] = cleanText(source[field], max, field, { required: field === 'title' && !partial });
if (!partial && source.title === undefined) fail('title ist erforderlich.', 'title');
if (source.description_html !== undefined) {
const html = String(source.description_html || '');
if (html.length > 50000) fail('description_html ist zu lang.', 'description_html');
out.description_html = sanitizeRichHtml(html);
if (source.description === undefined) out.description = htmlToPlainText(out.description_html).slice(0, 12000);
}
for (const field of ['tags','photos']) if (source[field] !== undefined) {
if (!Array.isArray(source[field])) fail(`${field} muss eine Liste sein.`, field);
out[field] = source[field].map(v => cleanText(v, field === 'photos' ? 500 : 120, field)).filter(Boolean).slice(0, ARRAY_LIMITS[field]);
}
if (source.price !== undefined && source.price !== null && source.price !== '') {
const price = Number(source.price);
if (!Number.isFinite(price) || price < 0 || price > 100000000) fail('price ist ungültig.', 'price');
out.price = price;
} else if (source.price !== undefined) out.price = null;
return Object.freeze(out);
}
export class ListingsService {
constructor(repository) { this.repository = repository; }
validateCreate(input) { return normalizePayload(input, { partial: false }); }
validateUpdate(input) { return normalizePayload(input, { partial: true }); }
create(input, actorId) { return this.repository.create({ ...this.validateCreate(input), created_by: actorId || null, updated_by: actorId || null }); }
list(filters = {}) { return this.repository.list(cleanText(filters.q || '', 240, 'q'), cleanText(filters.platform || '', 64, 'platform'), cleanText(filters.status || '', 64, 'status')); }
get(id) { const item = this.repository.get(Number(id)); if (!item) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return item; }
update(id, input, actorId) { this.get(id); return this.repository.update(Number(id), { ...this.validateUpdate(input), updated_by: actorId || null }); }
softDelete(id) { this.get(id); this.repository.softDelete(Number(id)); return { ok:true }; }
duplicate(id) { const copy = this.repository.duplicate(Number(id)); if (!copy) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return copy; }
trash() { return this.repository.listDeleted(); }
trashCount() { return { count: this.repository.trashCount() }; }
restore(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.restore(Number(id)); return { ok:true }; }
permanentDelete(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.permanentDelete(Number(id)); return { ok:true }; }
emptyTrash() { const items = this.repository.listDeleted(); for (const item of items) this.repository.permanentDelete(item.id); return { ok:true, deleted:items.length }; }
}
+1
View File
@@ -0,0 +1 @@
import manifest from './module.json' with {type:'json'}; import { MediaService } from './service.mjs'; let service=null; export function configureMediaModule(deps){service=new MediaService(deps);} export function getMediaService(){if(!service)throw new Error('Media-Modul ist nicht konfiguriert.');return service;} export function createMediaModule(){return {manifest,lifecycle:{async start(){},async stop(){},async health(){return {ok:Boolean(service),native:true,contracts:['library','reference-protection']};}}};}
+28
View File
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "vendoo.media",
"name": "Media Library",
"version": "2.0.0",
"type": "feature",
"status": "native",
"description": "Native Medienbibliothek mit sicherer Pfadbehandlung, Referenzschutz, Favoriten und Metadaten.",
"requires": [
"vendoo.auth"
],
"permissions": [
"media.view",
"media.edit",
"media.delete"
],
"provides": [
"media.assets",
"media.derivatives"
],
"consumes": [
"auth.identity"
],
"owns": [
"media.library",
"media.uploads"
]
}
+2
View File
@@ -0,0 +1,2 @@
import { listMediaLibraryItems, setMediaLibraryFavorite, getListingReferencedMediaPaths, removeMediaLibraryRecords } from '../../../lib/db.mjs';
export const mediaRepository=Object.freeze({list:listMediaLibraryItems,setFavorite:setMediaLibraryFavorite,referenced:getListingReferencedMediaPaths,removeRecords:removeMediaLibraryRecords});
+2
View File
@@ -0,0 +1,2 @@
import { getMediaService } from './index.mjs';
export function registerMediaRoutes({app,routes,deps}){const service=getMediaService();routes.register(app,{id:'media.library.list',method:'GET',path:'/api/media-library',owner:'vendoo.media',policy:'media.view',csrf:false,stability:'stable',handler:async(q,r)=>r.json(await service.list(q.query||{}))});routes.register(app,{id:'media.library.favorite',method:'PATCH',path:'/api/media-library/favorite',owner:'vendoo.media',policy:'media.edit',stability:'stable',input:body=>service.validateFavorite(body),handler:async(q,r)=>{const result=service.favorite(q.validatedBody);if(!result.ok)return r.status(400).json({error:'Nur FLUX-Bilder können als Favorit markiert werden.'});deps.auditLog(q.user.id,q.user.email,'media.favorite_updated','media',q.validatedBody.image_path,String(q.validatedBody.favorite),deps.getClientIp(q));r.json(result);}});routes.register(app,{id:'media.library.delete',method:'DELETE',path:'/api/media-library/items',owner:'vendoo.media',policy:'media.delete',stability:'stable',input:body=>service.validateDelete(body),handler:async(q,r)=>{const result=service.delete(q.validatedBody);deps.auditLog(q.user.id,q.user.email,'media.deleted','media',null,JSON.stringify({deleted:result.deleted,protected:result.protected.length}),deps.getClientIp(q));r.json(result);}});}
+8
View File
@@ -0,0 +1,8 @@
import { existsSync, statSync, unlinkSync } from 'node:fs'; import { resolve, sep, extname } from 'node:path'; import { PlatformError } from '../../kernel/errors.mjs'; function normalizePath(value){const p=String(value||'').replace(/\\/g,'/').replace(/^\/+/, '').trim();if(!p||p.includes('..')||/[\u0000\r\n]/.test(p)) throw new PlatformError('Ungültiger Medienpfad.',{code:'MEDIA_PATH_INVALID',status:400,expose:true});return p;}
export class MediaService { constructor({uploadRoot,sharp,repository}){this.uploadRoot=resolve(uploadRoot);this.sharp=sharp;this.repository=repository;}
async list(query={}){const source=String(query.source||'all').slice(0,32),search=String(query.q||'').slice(0,240),favoritesOnly=String(query.favorites||'0')==='1',sort=['newest','oldest','name'].includes(String(query.sort))?String(query.sort):'newest';const all=this.repository.list({source,search,favoritesOnly,sort});const pageSize=Math.max(12,Math.min(96,Number(query.page_size)||24)),total=all.length,totalPages=Math.max(1,Math.ceil(total/pageSize)),page=Math.min(Math.max(1,Number(query.page)||1),totalPages);const pageItems=all.slice((page-1)*pageSize,page*pageSize);const items=await Promise.all(pageItems.map(async item=>{const imagePath=normalizePath(item.image_path),absolute=resolve(this.uploadRoot,imagePath);let fileSize=null,width=Number(item.width)||null,height=Number(item.height)||null,format=item.format||extname(imagePath).replace('.','').toLowerCase()||null;if(absolute!==this.uploadRoot&&absolute.startsWith(this.uploadRoot+sep)&&existsSync(absolute)){try{fileSize=statSync(absolute).size;}catch{}if((!width||!height)&&this.sharp){try{const m=await this.sharp(absolute,{failOn:'none'}).metadata();width=m.width||width;height=m.height||height;format=m.format||format;}catch{}}}return {...item,image_path:imagePath,public_url:`/uploads/${imagePath}`,file_size:fileSize,width,height,format};}));const summary=this.repository.list({source:'all',search,favoritesOnly,sort});const counts=summary.reduce((acc,item)=>{for(const s of item.sources||[])acc[s]=(acc[s]||0)+1;return acc;},{});return {items,pagination:{page,page_size:pageSize,total,total_pages:totalPages},summary:{total:summary.length,sources:counts}};}
validateFavorite(input={}){for(const key of Object.keys(input||{}))if(!['image_path','favorite'].includes(key))throw new PlatformError(`Unbekanntes Medienfeld: ${key}`,{code:'MEDIA_INPUT_INVALID',status:400,expose:true});return Object.freeze({image_path:normalizePath(input.image_path),favorite:input.favorite!==false});}
favorite(input={}){const valid=this.validateFavorite(input);return this.repository.setFavorite(valid.image_path,valid.favorite);}
validateDelete(input={}){for(const key of Object.keys(input||{}))if(!['image_paths'].includes(key))throw new PlatformError(`Unbekanntes Medienfeld: ${key}`,{code:'MEDIA_INPUT_INVALID',status:400,expose:true});if(!Array.isArray(input.image_paths))throw new PlatformError('image_paths muss eine Liste sein.',{code:'MEDIA_PATHS_REQUIRED',status:400,expose:true});const paths=[...new Set(input.image_paths.map(normalizePath))].slice(0,500);if(!paths.length)throw new PlatformError('Keine Medien ausgewählt.',{code:'MEDIA_PATHS_REQUIRED',status:400,expose:true});return Object.freeze({image_paths:paths});}
delete(input={}){const requested=this.validateDelete(input).image_paths,referenced=this.repository.referenced(),protectedPaths=requested.filter(p=>referenced.has(p)),deletable=requested.filter(p=>!referenced.has(p)),deletedFiles=[];for(const p of deletable){const absolute=resolve(this.uploadRoot,p);if(absolute===this.uploadRoot||!absolute.startsWith(this.uploadRoot+sep))continue;try{if(existsSync(absolute)&&statSync(absolute).isFile()){unlinkSync(absolute);deletedFiles.push(p);}}catch{}}this.repository.removeRecords(deletable);return {ok:true,deleted:deletable.length,deleted_files:deletedFiles.length,protected:protectedPaths};}
}
+28
View File
@@ -0,0 +1,28 @@
import manifest from './module.json' with { type: 'json' };
import { ModuleManagerService } from './service.mjs';
let service = null;
export function createModuleManagerModule() {
return {
manifest,
source: 'builtin',
lifecycle: {
async start(context) {
service = new ModuleManagerService({
modules: context.modules,
stateStore: context.moduleStateStore,
packageStore: context.modulePackageStore,
context,
});
},
async stop() { service = null; },
async health() { return { ok: Boolean(service), protected: true }; },
},
};
}
export function getModuleManagerService() {
if (!service) throw new Error('Module Manager ist noch nicht gestartet.');
return service;
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "vendoo.module-manager",
"name": "Module Manager",
"version": "1.0.0",
"type": "core",
"status": "native",
"description": "Sichere Verwaltung eingebauter und installierter Vendoo-Module mit Aktivierung, Export, Quarantäne und kontrollierter Entfernung.",
"requires": ["vendoo.security", "vendoo.settings"],
"permissions": ["modules.manage"],
"provides": ["modules.management", "modules.packages"],
"consumes": ["security.policies", "settings.configuration"],
"owns": ["modules.state", "modules.packages"]
}
+62
View File
@@ -0,0 +1,62 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { getModuleManagerService } from './index.mjs';
const toggleSchema = objectSchema({
cascade: { type: 'boolean' },
});
export function registerModuleManagerRoutes({ app, routes, upload, deps }) {
routes.register(app, {
id: 'modules.catalog.read', method: 'GET', path: '/api/admin/modules', owner: 'vendoo.module-manager',
policy: 'modules.manage', audit: 'modules.catalog.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getModuleManagerService().catalog()),
});
routes.register(app, {
id: 'modules.enable', method: 'POST', path: '/api/admin/modules/:id/enable', owner: 'vendoo.module-manager',
policy: 'modules.manage', audit: 'modules.enabled', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (req, res) => {
const module = await getModuleManagerService().enable(req.params.id);
deps.auditLog(req.user.id, req.user.email, 'module.enabled', 'module', req.params.id, null, deps.getClientIp(req));
res.json({ module });
},
});
routes.register(app, {
id: 'modules.disable', method: 'POST', path: '/api/admin/modules/:id/disable', owner: 'vendoo.module-manager',
policy: 'modules.manage', audit: 'modules.disabled', rateLimit: 'administration', csrf: true, stability: 'stable', input: toggleSchema,
handler: async (req, res) => {
const result = await getModuleManagerService().disable(req.params.id, req.validatedBody || {});
deps.auditLog(req.user.id, req.user.email, 'module.disabled', 'module', req.params.id, JSON.stringify(result.disabled), deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'modules.install', method: 'POST', path: '/api/admin/modules/install', owner: 'vendoo.module-manager',
policy: 'modules.manage', audit: 'modules.installed', rateLimit: 'administration', csrf: true, stability: 'stable',
middleware: [upload.single('module')],
handler: async (req, res) => {
const module = getModuleManagerService().install(req.file?.buffer);
deps.auditLog(req.user.id, req.user.email, 'module.installed', 'module', module.id, module.version, deps.getClientIp(req));
res.status(201).json({ module });
},
});
routes.register(app, {
id: 'modules.remove', method: 'DELETE', path: '/api/admin/modules/:id', owner: 'vendoo.module-manager',
policy: 'modules.manage', audit: 'modules.removed', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (req, res) => {
const result = await getModuleManagerService().remove(req.params.id);
deps.auditLog(req.user.id, req.user.email, 'module.removed', 'module', req.params.id, null, deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'modules.export', method: 'GET', path: '/api/admin/modules/:id/export', owner: 'vendoo.module-manager',
policy: 'modules.manage', audit: 'modules.exported', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (req, res) => {
const payload = getModuleManagerService().export(req.params.id);
deps.auditLog(req.user.id, req.user.email, 'module.exported', 'module', req.params.id, null, deps.getClientIp(req));
res.setHeader('Content-Type', 'application/vnd.vendoo.module+json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${String(req.params.id).replace(/[^a-z0-9.-]/gi, '_')}.vmod"`);
res.send(payload);
},
});
}
+233
View File
@@ -0,0 +1,233 @@
import { readFileSync } from 'fs';
import { PlatformError } from '../../kernel/errors.mjs';
import { buildModulePackage } from '../../core/modules/module-package-contract.mjs';
export const PROTECTED_MODULE_IDS = Object.freeze([
'vendoo.system',
'vendoo.security',
'vendoo.auth',
'vendoo.users',
'vendoo.sessions',
'vendoo.settings',
'vendoo.themes',
'vendoo.listings',
'vendoo.inventory',
'vendoo.media',
'vendoo.operations',
'vendoo.module-manager',
'vendoo.deployments',
]);
const PROTECTED_MODULES = new Set(PROTECTED_MODULE_IDS);
function asPublicModule(record, packagesById) {
const external = record.source === 'external';
const pkg = packagesById.get(record.id);
const protectedModule = PROTECTED_MODULES.has(record.id);
const canDisable = !protectedModule && record.enabled;
const canEnable = !record.enabled && (!external || pkg?.activatable === true);
return {
...record,
protected: protectedModule,
removable: external && !record.enabled,
exportable: true,
installSource: external ? 'installed' : 'builtin',
package: pkg ? {
runtime: pkg.runtime,
trust: pkg.trust,
activatable: pkg.activatable,
invalid: Boolean(pkg.invalid),
error: pkg.error || null,
} : null,
actions: {
enable: canEnable,
disable: canDisable,
remove: external && !record.enabled,
export: true,
},
};
}
export function createExternalLifecycle(pkg) {
return {
async start() {
if (pkg.runtime.mode === 'isolated') {
throw new PlatformError('Der isolierte Extension-Host ist für ausführbare Fremdmodule noch nicht aktiviert.', {
code: 'MODULE_EXTENSION_HOST_UNAVAILABLE', status: 409, expose: true,
});
}
},
async stop() {},
async health() {
return { ok: true, runtime: pkg.runtime.mode, external: true };
},
};
}
export class ModuleManagerService {
#modules;
#stateStore;
#packageStore;
#context;
constructor({ modules, stateStore, packageStore, context }) {
this.#modules = modules;
this.#stateStore = stateStore;
this.#packageStore = packageStore;
this.#context = context;
}
registerInstalledPackages() {
const installed = this.#packageStore.list();
for (const entry of installed) {
if (entry.invalid || this.#modules.has(entry.id)) continue;
const pkg = this.#packageStore.get(entry.id);
const enabled = entry.activatable && this.#stateStore.isEnabled(entry.id, false);
this.#modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled });
}
return installed.length;
}
catalog() {
const packages = this.#packageStore.list();
const packagesById = new Map(packages.filter(item => item.id).map(item => [item.id, item]));
const modules = this.#modules.snapshot().map(record => asPublicModule(record, packagesById));
for (const pkg of packages) {
if (!pkg.invalid || modules.some(item => item.id === pkg.id)) continue;
modules.push({
id: pkg.id,
name: pkg.id,
version: null,
type: 'extension-host',
status: 'invalid',
source: 'external',
enabled: false,
state: 'invalid',
protected: false,
removable: true,
exportable: false,
installSource: 'installed',
package: { invalid: true, error: pkg.error, activatable: false },
actions: { enable: false, disable: false, remove: true, export: false },
});
}
return {
modules: modules.sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id), 'de')),
summary: {
total: modules.length,
enabled: modules.filter(item => item.enabled).length,
disabled: modules.filter(item => !item.enabled && item.state !== 'invalid').length,
native: modules.filter(item => item.status === 'native').length,
legacy: modules.filter(item => item.status === 'legacy-bridge').length,
external: modules.filter(item => item.source === 'external').length,
protected: modules.filter(item => item.protected).length,
},
safety: {
unsignedDeclarativeAllowed: true,
executableRequiresTrustedSignature: true,
isolatedHostAvailable: false,
directMainProcessExecution: false,
},
};
}
async enable(id) {
const record = this.#modules.get(id);
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
if (record.source === 'external') {
const pkg = this.#packageStore.describe(id);
if (!pkg?.activatable) {
throw new PlatformError('Dieses Fremdmodul ist nicht aktivierbar. Ausführbarer Code benötigt eine vertrauenswürdige Signatur und den isolierten Extension-Host.', {
code: 'MODULE_NOT_ACTIVATABLE', status: 409, expose: true,
});
}
if (pkg.runtime.mode === 'isolated') {
throw new PlatformError('Der isolierte Extension-Host ist noch nicht verfügbar.', { code: 'MODULE_EXTENSION_HOST_UNAVAILABLE', status: 409, expose: true });
}
}
const enabling = new Set();
const enableWithDependencies = async moduleId => {
if (enabling.has(moduleId)) throw new PlatformError('Zyklische Modulabhängigkeit erkannt.', { code: 'MODULE_DEPENDENCY_CYCLE', status: 409, expose: true });
const target = this.#modules.get(moduleId);
if (!target) throw new PlatformError(`Fehlende Modulabhängigkeit: ${moduleId}`, { code: 'MODULE_DEPENDENCY_MISSING', status: 409, expose: true });
if (target.enabled && target.state === 'running') return this.#modules.describe(moduleId);
enabling.add(moduleId);
for (const dependency of target.manifest.requires) await enableWithDependencies(dependency);
const enabled = await this.#modules.enable(moduleId, this.#context);
this.#stateStore.setEnabled(moduleId, true, { source: moduleId === id ? 'module-manager' : 'module-manager-dependency' });
enabling.delete(moduleId);
return enabled;
};
return enableWithDependencies(id);
}
async disable(id, { cascade = false } = {}) {
if (PROTECTED_MODULES.has(id)) {
throw new PlatformError('Dieses Kernmodul ist geschützt und kann nicht deaktiviert werden.', {
code: 'MODULE_PROTECTED', status: 409, expose: true,
});
}
const dependents = this.#modules.dependentsOf(id, { enabledOnly: true, recursive: true });
const protectedDependents = dependents.filter(dependent => PROTECTED_MODULES.has(dependent));
if (protectedDependents.length) {
throw new PlatformError('Das Modul wird von geschützten Kernmodulen benötigt.', {
code: 'MODULE_REQUIRED_BY_PROTECTED', status: 409, expose: true, details: { dependents: protectedDependents },
});
}
const result = await this.#modules.disable(id, this.#context, { cascade: Boolean(cascade) });
for (const disabledId of result.disabled) this.#stateStore.setEnabled(disabledId, false, { source: 'module-manager' });
return result;
}
install(buffer) {
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new PlatformError('Modulpaket fehlt.', { code: 'MODULE_PACKAGE_REQUIRED', status: 400, expose: true });
let parsed;
try { parsed = JSON.parse(buffer.toString('utf8')); }
catch (error) { throw new PlatformError('Das .vmod-Paket enthält kein gültiges JSON.', { code: 'MODULE_PACKAGE_JSON_INVALID', status: 400, expose: true, cause: error }); }
const id = String(parsed?.manifest?.id || '');
if (this.#modules.has(id)) throw new PlatformError('Eine Modul-ID mit diesem Namen ist bereits registriert.', { code: 'MODULE_DUPLICATE', status: 409, expose: true });
const installed = this.#packageStore.install(parsed);
const pkg = this.#packageStore.get(installed.id);
try {
this.#modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled: false });
this.#stateStore.setEnabled(installed.id, false, { source: 'module-install' });
} catch (error) {
this.#packageStore.remove(installed.id);
this.#stateStore.remove(installed.id);
throw error;
}
return this.catalog().modules.find(item => item.id === installed.id);
}
async remove(id) {
const record = this.#modules.get(id);
if (!record) {
const removedInvalid = this.#packageStore.remove(id);
if (removedInvalid) this.#stateStore.remove(id);
return { ok: removedInvalid, id };
}
if (record.source !== 'external') {
throw new PlatformError('Eingebaute Vendoo-Module können nicht gelöscht werden.', { code: 'MODULE_BUILTIN_NOT_REMOVABLE', status: 409, expose: true });
}
if (record.enabled) throw new PlatformError('Das Modul muss vor dem Löschen deaktiviert werden.', { code: 'MODULE_DISABLE_BEFORE_REMOVE', status: 409, expose: true });
await this.#modules.unregister(id, this.#context);
this.#packageStore.remove(id);
this.#stateStore.remove(id);
return { ok: true, id };
}
export(id) {
const record = this.#modules.get(id);
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
if (record.source === 'external') return this.#packageStore.export(id);
const pkg = buildModulePackage({
manifest: record.manifest,
runtime: { mode: 'builtin-reference', entry: null },
files: [{ path: 'module.json', content: `${JSON.stringify(record.manifest, null, 2)}\n` }],
});
return Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
}
stateSnapshot() {
return this.#stateStore.snapshot();
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"schemaVersion": 1,
"id": "vendoo.operations",
"name": "Operations Center",
"version": "1.0.0",
"type": "feature",
"status": "legacy-bridge",
"description": "Vertrag für den bestehenden Vendoo-Bereich Operations Center; schrittweise Migration ohne Funktionsbruch.",
"requires": [
"vendoo.security"
],
"permissions": [
"operations.manage"
],
"provides": [
"operations.backup",
"operations.update"
],
"consumes": [
"security.audit"
],
"owns": [
"operations.backups",
"operations.updates"
]
}
+23
View File
@@ -0,0 +1,23 @@
import manifest from './module.json' with { type: 'json' };
let runtime = null;
let started = false;
export function configurePublishingModule(deps = {}) {
runtime = {
start: deps.start || (() => {}),
stop: deps.stop || (() => {}),
};
}
export function createPublishingModule() {
return {
manifest,
source: 'builtin',
lifecycle: {
async start() { if (!runtime) throw new Error('Publishing-Modul ist nicht konfiguriert.'); await runtime.start(); started = true; },
async stop() { await runtime?.stop?.(); started = false; },
async health() { return { ok: Boolean(runtime && started), native: true, queue: 'publishing-jobs' }; },
},
};
}
+30
View File
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"id": "vendoo.publishing",
"name": "Publishing",
"version": "1.0.0",
"type": "feature",
"status": "native",
"description": "Native Publishing-Jobs, Plattformadapter und kontrollierter Queue-Lifecycle.",
"requires": [
"vendoo.listings",
"vendoo.security"
],
"permissions": [
"publishing.view",
"publishing.execute",
"publishing.manage"
],
"provides": [
"publishing.jobs",
"publishing.adapters"
],
"consumes": [
"listings.catalog",
"security.policies"
],
"owns": [
"publishing.queue",
"publishing.events"
]
}
+13
View File
@@ -0,0 +1,13 @@
import manifest from './module.json' with { type: 'json' };
export function createQualityModule() {
return {
manifest,
source: 'builtin',
lifecycle: {
async start() {},
async stop() {},
async health() { return { ok: true, native: true, contracts: ['reports', 'ai-suggestions'] }; },
},
};
}
+26
View File
@@ -0,0 +1,26 @@
{
"schemaVersion": 1,
"id": "vendoo.quality",
"name": "Quality Center",
"version": "1.0.0",
"type": "feature",
"status": "native",
"description": "Native Qualitätsanalyse, Verlauf und bestätigte AI-Vorschläge.",
"requires": [
"vendoo.listings",
"vendoo.ai"
],
"permissions": [
"quality.execute"
],
"provides": [
"quality.reports"
],
"consumes": [
"listings.catalog",
"ai.text"
],
"owns": [
"quality.history"
]
}
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { secretStoreStatus } from '../../../lib/secret-store.mjs';
import { getRequestTelemetry } from '../../../lib/structured-logger.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
export function createSecurityModule() {
return {
manifest,
lifecycle: {
async start() {},
async stop() {},
async health() {
const secrets = secretStoreStatus();
return { ok: true, provider: secrets.provider, encrypted_secrets: secrets.encrypted };
},
},
};
}
export function getSecurityFoundationStatus() {
return { secrets: secretStoreStatus(), requests: getRequestTelemetry() };
}
+31
View File
@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"id": "vendoo.security",
"name": "Security Core",
"version": "2.0.0",
"type": "core",
"status": "native",
"description": "Nativer Sicherheitskern für verschlüsselte Secrets, Request-Telemetrie, Sicherheitsheader und Security-Dashboard.",
"requires": [
"vendoo.system"
],
"permissions": [
"security.manage"
],
"provides": [
"security.policies",
"security.audit",
"security.secrets",
"security.telemetry",
"security.headers"
],
"consumes": [
"platform.events"
],
"owns": [
"security.permissions",
"security.rate-limits",
"security.secret-store",
"security.request-telemetry"
]
}
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PlatformError } from '../../kernel/errors.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
let service = null;
export function getSessionsService() {
if (!service) throw new PlatformError('Sitzungs-Modul ist noch nicht gestartet.', { code: 'SESSIONS_MODULE_NOT_READY', status: 503, expose: true });
return service;
}
export function createSessionsModule() {
return {
manifest,
lifecycle: {
async start() { service = (await import('./service.mjs')).createSessionsService(); },
async stop() { service = null; },
async health() { return getSessionsService().health(); },
},
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "vendoo.sessions",
"name": "Sessions",
"version": "1.0.0",
"type": "core",
"status": "native",
"description": "Native Sitzungsverwaltung mit Idle-Timeout, Eigentümerprüfung, Revocation und administrativer Bereinigung.",
"requires": ["vendoo.security"],
"permissions": ["sessions.manage"],
"provides": ["auth.sessions", "sessions.lifecycle"],
"consumes": ["security.policies"],
"owns": ["auth.sessions"]
}
+44
View File
@@ -0,0 +1,44 @@
import { getSessionsService } from './index.mjs';
export function registerSessionRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'sessions.admin.list', method: 'GET', path: '/api/admin/sessions', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getSessionsService().listAll()),
});
routes.register(app, {
id: 'sessions.admin.revoke', method: 'DELETE', path: '/api/admin/sessions/:id', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
const session = getSessionsService().revokeAsAdmin(Number(req.params.id));
deps.auditLog(req.user.id, req.user.email, 'session.destroyed', 'session', String(session.id), null, deps.getClientIp(req));
res.json({ ok: true });
},
});
routes.register(app, {
id: 'sessions.admin.revoke-user', method: 'DELETE', path: '/api/admin/users/:id/sessions', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
getSessionsService().revokeAllForUser(Number(req.params.id));
deps.auditLog(req.user.id, req.user.email, 'sessions.destroyed_all', 'user', String(req.params.id), null, deps.getClientIp(req));
res.json({ ok: true });
},
});
routes.register(app, {
id: 'sessions.admin.cleanup', method: 'POST', path: '/api/admin/sessions/cleanup', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
getSessionsService().cleanup();
deps.auditLog(req.user.id, req.user.email, 'sessions.cleanup', null, null, null, deps.getClientIp(req));
res.json({ ok: true, message: 'Abgelaufene Sessions bereinigt' });
},
});
routes.register(app, {
id: 'sessions.self.list', method: 'GET', path: '/api/my/sessions', owner: 'vendoo.sessions', policy: 'sessions.self', csrf: false, stability: 'stable',
handler: async (req, res) => res.json(getSessionsService().listForUser(req.user.id)),
});
routes.register(app, {
id: 'sessions.self.revoke', method: 'DELETE', path: '/api/my/sessions/:id', owner: 'vendoo.sessions', policy: 'sessions.self', csrf: true, stability: 'stable',
handler: async (req, res) => {
const session = getSessionsService().revokeOwn(req.user.id, Number(req.params.id));
deps.auditLog(req.user.id, req.user.email, 'session.destroyed_own', 'session', String(session.id), null, deps.getClientIp(req));
res.json({ ok: true });
},
});
}
+33
View File
@@ -0,0 +1,33 @@
import {
cleanExpiredSessions, destroyOwnedSessionById, destroySessionById, destroyUserSessions,
getAllActiveSessions, getSessionById, getUserSessions,
} from '../../core/identity/identity-store.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
function fail(message, status = 400, code = 'SESSION_OPERATION_FAILED') {
throw new PlatformError(message, { status, code, expose: true });
}
export function createSessionsService() {
return Object.freeze({
listAll() { return getAllActiveSessions(); },
listForUser(userId) { return getUserSessions(userId); },
revokeAsAdmin(sessionId) {
const session = getSessionById(sessionId);
if (!session) fail('Sitzung nicht gefunden.', 404, 'SESSION_NOT_FOUND');
destroySessionById(sessionId);
return session;
},
revokeAllForUser(userId) {
destroyUserSessions(userId);
return { user_id: Number(userId) };
},
revokeOwn(userId, sessionId) {
const changed = destroyOwnedSessionById(userId, sessionId);
if (!changed) fail('Sitzung nicht gefunden oder gehört nicht zu diesem Benutzer.', 404, 'SESSION_NOT_OWNED');
return { id: Number(sessionId), user_id: Number(userId) };
},
cleanup() { cleanExpiredSessions(); return { ok: true }; },
health() { return { ok: true, active_sessions: getAllActiveSessions().length }; },
});
}
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PlatformError } from '../../kernel/errors.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
let service = null;
let adapters = { etsyApi: null, ebayApi: null };
export function configureSettingsModule(nextAdapters = {}) { adapters = { ...adapters, ...nextAdapters }; }
export function getSettingsService() {
if (!service) throw new PlatformError('Einstellungs-Modul ist noch nicht gestartet.', { code: 'SETTINGS_MODULE_NOT_READY', status: 503, expose: true });
return service;
}
export function createSettingsModule() {
return {
manifest,
lifecycle: {
async start() { service = (await import('./service.mjs')).createSettingsService(adapters); },
async stop() { service = null; },
async health() { return getSettingsService().health(); },
},
};
}
+29
View File
@@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"id": "vendoo.settings",
"name": "Settings",
"version": "1.0.0",
"type": "core",
"status": "native",
"description": "Native Anwendungs- und SMTP-Einstellungen mit Allowlist, verschlüsselten Secrets und Runtime-Konfigurationsadapter.",
"requires": [
"vendoo.security",
"vendoo.users"
],
"permissions": [
"settings.manage"
],
"provides": [
"settings.configuration",
"settings.application",
"settings.smtp"
],
"consumes": [
"security.secrets",
"users.directory"
],
"owns": [
"settings.application",
"settings.smtp"
]
}
+15
View File
@@ -0,0 +1,15 @@
import { db } from '../../../lib/db.mjs';
export function readApplicationSettings() {
const rows = db.prepare('SELECT key, value FROM settings').all();
return Object.fromEntries(rows.map(row => [row.key, row.value]));
}
export function writeApplicationSettings(settings) {
const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
const transaction = db.transaction(entries => {
for (const [key, value] of entries) stmt.run(key, value == null ? '' : String(value));
});
transaction(Object.entries(settings));
return readApplicationSettings();
}
+45
View File
@@ -0,0 +1,45 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { getSettingsService } from './index.mjs';
const smtpSchema = objectSchema({
smtp_host: { type: 'string', maxLength: 255 }, smtp_port: { type: 'string', maxLength: 8 },
smtp_user: { type: 'string', maxLength: 255 }, smtp_pass: { type: 'string', maxLength: 4096, trim: false },
smtp_from: { type: 'string', maxLength: 255 },
});
export function registerSettingsRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'settings.application.read', method: 'GET', path: '/api/settings', owner: 'vendoo.settings', policy: 'settings.read', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getSettingsService().get()),
});
routes.register(app, {
id: 'settings.application.update', method: 'PUT', path: '/api/settings', owner: 'vendoo.settings', policy: 'settings.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
const result = getSettingsService().update(req.body || {});
deps.auditLog(req.user.id, req.user.email, 'settings.updated', 'settings', 'application', null, deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'settings.smtp.read', method: 'GET', path: '/api/admin/smtp', owner: 'vendoo.settings', policy: 'settings.manage', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getSettingsService().smtpStatus()),
});
routes.register(app, {
id: 'settings.smtp.update', method: 'PUT', path: '/api/admin/smtp', owner: 'vendoo.settings', policy: 'settings.manage', csrf: true, stability: 'stable', input: smtpSchema,
handler: async (req, res) => {
const result = getSettingsService().updateSmtp(req.validatedBody || {});
deps.auditLog(req.user.id, req.user.email, 'smtp.updated', 'settings', 'smtp', null, deps.getClientIp(req));
res.json(result);
},
});
routes.register(app, {
id: 'settings.smtp.test', method: 'POST', path: '/api/admin/smtp/test', owner: 'vendoo.settings', policy: 'settings.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
if (!deps.isMailerConfigured()) return res.status(400).json({ error: 'SMTP nicht konfiguriert. Bitte zuerst speichern.' });
if (!req.user?.email) return res.status(400).json({ error: 'Admin-E-Mail nicht gefunden' });
await deps.testMailer(req.user.email);
deps.auditLog(req.user.id, req.user.email, 'smtp.test_sent', 'settings', 'smtp', `an ${req.user.email}`, deps.getClientIp(req));
res.json({ ok: true, message: `Testmail an ${req.user.email} gesendet` });
},
});
}
+136
View File
@@ -0,0 +1,136 @@
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
import { getSecret, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
import { isMailerConfigured, reinitMailer } from '../../../lib/mailer.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
import { readApplicationSettings, writeApplicationSettings } from './repository.mjs';
const SECRET_FIELDS = Object.freeze({
anthropic_key: 'ANTHROPIC_API_KEY', openai_key: 'OPENAI_API_KEY', openrouter_key: 'OPENROUTER_API_KEY',
etsy_key: 'ETSY_API_KEY', ebay_client_secret: 'EBAY_CLIENT_SECRET', ai_model_photo_local_token: 'LOCAL_IMAGE_TOKEN',
});
const RUNTIME_FIELDS = Object.freeze({
ebay_client_id: 'EBAY_CLIENT_ID', ebay_ru_name: 'EBAY_RU_NAME', ebay_sandbox: 'EBAY_SANDBOX',
ollama_url: 'OLLAMA_URL', public_base_url: 'PUBLIC_BASE_URL', ai_model_photo_local_url: 'LOCAL_IMAGE_URL',
ai_model_photo_local_workflow_path: 'LOCAL_IMAGE_WORKFLOW_PATH', ai_model_photo_local_install_path: 'LOCAL_IMAGE_INSTALL_PATH',
});
const DB_FIELDS = new Set([
'default_platform','default_ai','default_language','seller_notes','ai_model_photos_enabled','ai_model_photo_provider',
'ai_model_photo_default_mode','ai_model_photo_default_variants','ai_model_photo_default_preset','ai_model_photo_openrouter_free_only',
'ai_model_photo_block_underwear','ai_model_photo_block_kids','ai_model_photo_preserve_logos','ai_model_photo_openrouter_model',
'ai_model_photo_openai_model','ai_model_photo_local_enabled','ai_model_photo_local_require_token','ai_model_photo_use_source_reference',
'ai_model_photo_reference_strength','ai_model_photo_local_resolution','flux_runtime_profile','flux_default_profile',
'flux_prompt_assistant','flux_default_style','flux_default_format','flux_default_variants','flux_seed_lock','flux_max_parallel_jobs',
'smtp_host','smtp_port','smtp_user','smtp_from',
]);
const ALL_FIELDS = new Set([...Object.keys(SECRET_FIELDS), ...Object.keys(RUNTIME_FIELDS), ...DB_FIELDS]);
function fail(message, status = 400, code = 'SETTINGS_INVALID') {
throw new PlatformError(message, { status, code, expose: true });
}
function cleanString(value, { max = 4096, multiline = false } = {}) {
const result = String(value ?? '').trim();
if (result.length > max) fail('Einstellungswert ist zu lang.');
if (!multiline && /[\r\n\0]/.test(result)) fail('Einstellungswert enthält unzulässige Steuerzeichen.');
return result;
}
export function createSettingsService({ etsyApi, ebayApi }) {
function decorate(settings) {
const result = { ...settings };
result.has_anthropic_key = hasSecret('ANTHROPIC_API_KEY');
result.has_openai_key = hasSecret('OPENAI_API_KEY');
result.has_openrouter_key = hasSecret('OPENROUTER_API_KEY');
result.has_etsy_key = hasSecret('ETSY_API_KEY');
result.etsy_connected = Boolean(etsyApi?.isConnected?.());
result.has_ebay_credentials = Boolean(ebayApi?.hasCredentials?.());
result.ebay_connected = Boolean(ebayApi?.isConnected?.());
result.ebay_sandbox = process.env.EBAY_SANDBOX === 'true';
result.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434';
result.public_base_url = process.env.PUBLIC_BASE_URL || '';
result.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || result.ai_model_photo_local_url || 'http://127.0.0.1:8188';
result.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || result.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json';
result.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || result.ai_model_photo_local_install_path || 'local-ai/comfyui';
result.ai_model_photo_local_enabled = result.ai_model_photo_local_enabled || '1';
result.ai_model_photo_local_require_token = result.ai_model_photo_local_require_token || '1';
result.ai_model_photo_use_source_reference = result.ai_model_photo_use_source_reference || '1';
result.ai_model_photo_reference_strength = result.ai_model_photo_reference_strength || '70';
result.ai_model_photo_local_resolution = result.ai_model_photo_local_resolution || '768';
result.ai_model_photo_preserve_logos = result.ai_model_photo_preserve_logos || '1';
result.flux_runtime_profile = result.flux_runtime_profile || 'auto';
result.flux_default_profile = result.flux_default_profile || 'balanced';
result.flux_prompt_assistant = result.flux_prompt_assistant || '1';
result.flux_default_style = result.flux_default_style || 'photorealistic';
result.flux_default_format = result.flux_default_format || '768x768';
result.flux_default_variants = result.flux_default_variants || '1';
result.flux_seed_lock = result.flux_seed_lock || '0';
result.flux_max_parallel_jobs = result.flux_max_parallel_jobs || '1';
result.local_image_token_set = hasSecret('LOCAL_IMAGE_TOKEN');
return result;
}
function validatePatch(input) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
for (const key of Object.keys(source)) if (!ALL_FIELDS.has(key)) fail(`Unbekanntes Einstellungsfeld: ${key}`);
return source;
}
function smtpStatus() {
const settings = readApplicationSettings();
return {
smtp_host: settings.smtp_host || process.env.SMTP_HOST || '', smtp_port: settings.smtp_port || process.env.SMTP_PORT || '587',
smtp_user: settings.smtp_user || process.env.SMTP_USER || '', smtp_pass: hasSecret('SMTP_PASS') ? '••••••••' : '',
smtp_from: settings.smtp_from || process.env.SMTP_FROM || '', configured: isMailerConfigured(),
source: hasSecret('SMTP_PASS') ? 'encrypted-secret-store' : (process.env.SMTP_HOST ? 'environment' : 'none'),
};
}
return Object.freeze({
get() { return decorate(readApplicationSettings()); },
update(input) {
const source = validatePatch(input);
let envContent = readRuntimeConfig();
const dbPatch = {};
for (const [field, secretName] of Object.entries(SECRET_FIELDS)) {
if (source[field] !== undefined && source[field] !== '') setSecret(secretName, cleanString(source[field], { max: 16384 }), { source: 'settings-ui' });
}
for (const [field, envName] of Object.entries(RUNTIME_FIELDS)) {
if (source[field] === undefined) continue;
let value = cleanString(source[field], { max: 4096 });
if (['public_base_url','ai_model_photo_local_url'].includes(field)) value = value.replace(/\/$/, '');
envContent = setConfigValue(envContent, envName, value);
process.env[envName] = value;
}
for (const field of DB_FIELDS) {
if (source[field] === undefined) continue;
dbPatch[field] = cleanString(source[field], { max: field === 'seller_notes' ? 16000 : 4096, multiline: field === 'seller_notes' });
}
if (dbPatch.ai_model_photo_local_enabled !== undefined) {
const value = String(dbPatch.ai_model_photo_local_enabled) === '0' ? 'false' : 'true';
envContent = setConfigValue(envContent, 'LOCAL_IMAGE_ENABLED', value); process.env.LOCAL_IMAGE_ENABLED = value;
}
if (dbPatch.ai_model_photo_local_require_token !== undefined) {
const value = String(dbPatch.ai_model_photo_local_require_token) === '0' ? 'false' : 'true';
envContent = setConfigValue(envContent, 'LOCAL_IMAGE_REQUIRE_TOKEN', value); process.env.LOCAL_IMAGE_REQUIRE_TOKEN = value;
}
writeRuntimeConfig(envContent);
return decorate(writeApplicationSettings(dbPatch));
},
smtpStatus,
updateSmtp(input) {
const patch = {};
for (const field of ['smtp_host','smtp_port','smtp_user','smtp_from']) if (input[field] !== undefined) patch[field] = cleanString(input[field], { max: 512 });
if (input.smtp_pass !== undefined && input.smtp_pass !== '••••••••' && input.smtp_pass !== '') setSecret('SMTP_PASS', cleanString(input.smtp_pass, { max: 4096 }), { source: 'smtp-ui' });
patch.smtp_pass = '';
const settings = writeApplicationSettings(patch);
const smtpSecret = getSecret('SMTP_PASS');
if (settings.smtp_host && settings.smtp_user && smtpSecret) {
reinitMailer({ host: settings.smtp_host, port: settings.smtp_port || '587', user: settings.smtp_user, pass: smtpSecret });
if (settings.smtp_from) process.env.SMTP_FROM = settings.smtp_from;
}
return smtpStatus();
},
health() { return { ok: true, settings: Object.keys(readApplicationSettings()).length, smtp_configured: isMailerConfigured() }; },
});
}
+23
View File
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"id": "vendoo.system",
"name": "System Kernel",
"version": "1.0.0",
"type": "core",
"status": "legacy-bridge",
"description": "Vertrag für den bestehenden Vendoo-Bereich System Kernel; schrittweise Migration ohne Funktionsbruch.",
"requires": [],
"permissions": [
"dashboard.view"
],
"provides": [
"platform.lifecycle",
"platform.events",
"platform.features"
],
"consumes": [],
"owns": [
"system.runtime",
"system.health"
]
}
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { PlatformError } from '../../kernel/errors.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
let service = null;
export function getThemeService() {
if (!service) throw new PlatformError('Theme-Modul ist noch nicht gestartet.', { code: 'THEME_MODULE_NOT_READY', status: 503, expose: true });
return service;
}
export function createThemesModule() {
return {
manifest,
lifecycle: {
async start() {
const { createThemeService } = await import('./service.mjs');
service = createThemeService();
},
async stop() { service = null; },
async health() { return getThemeService().health(); },
},
};
}
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS theme_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_theme TEXT NOT NULL DEFAULT 'light',
values_json TEXT NOT NULL DEFAULT '{}',
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS user_theme_preferences (
user_id INTEGER PRIMARY KEY,
theme_id TEXT NOT NULL DEFAULT 'inherit',
density TEXT NOT NULL DEFAULT 'comfortable',
reduced_motion TEXT NOT NULL DEFAULT 'system',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_theme_profiles_updated ON theme_profiles(updated_at DESC);
+30
View File
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"id": "vendoo.themes",
"name": "Themes & Design System",
"version": "1.1.0",
"type": "feature",
"status": "native",
"description": "Natives Theme- und Design-System-Modul mit sicherem Token-Editor, Benutzerpräferenzen und Accessibility-Prüfungen.",
"requires": [
"vendoo.system"
],
"permissions": [
"settings.manage",
"themes.use"
],
"provides": [
"themes.tokens",
"themes.runtime",
"themes.profiles",
"themes.accessibility"
],
"consumes": [
"platform.events"
],
"owns": [
"themes.definitions",
"themes.user-preferences",
"themes.system-default"
]
}
+95
View File
@@ -0,0 +1,95 @@
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { db } from '../../../lib/db.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const migrationSql = readFileSync(join(here, 'migrations', '001_theme_profiles.sql'), 'utf8');
export function ensureThemeSchema() {
db.exec(migrationSql);
}
function parseProfile(row) {
if (!row) return null;
let values = {};
try { values = JSON.parse(row.values_json || '{}'); } catch {}
return { ...row, values };
}
export function listThemeProfiles() {
ensureThemeSchema();
return db.prepare('SELECT * FROM theme_profiles ORDER BY name COLLATE NOCASE, id').all().map(parseProfile);
}
export function getThemeProfile(id) {
ensureThemeSchema();
return parseProfile(db.prepare('SELECT * FROM theme_profiles WHERE id = ?').get(id));
}
export function saveThemeProfile(profile, userId) {
ensureThemeSchema();
db.prepare(`INSERT INTO theme_profiles (id, name, base_theme, values_json, created_by)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, base_theme = excluded.base_theme,
values_json = excluded.values_json, updated_at = datetime('now')`).run(
profile.id, profile.name, profile.base_theme, JSON.stringify(profile.values || {}), userId || null,
);
return getThemeProfile(profile.id);
}
export function deleteThemeProfile(id) {
ensureThemeSchema();
const transaction = db.transaction(() => {
db.prepare("UPDATE user_theme_preferences SET theme_id = 'inherit', updated_at = datetime('now') WHERE theme_id = ?").run(id);
const system = db.prepare("SELECT value FROM settings WHERE key = 'theme_system_default'").get();
if (system?.value === id) db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('theme_system_default', 'system')").run();
return db.prepare('DELETE FROM theme_profiles WHERE id = ?').run(id).changes;
});
return transaction();
}
export function getUserThemePreference(userId) {
ensureThemeSchema();
return db.prepare('SELECT theme_id, density, reduced_motion, updated_at FROM user_theme_preferences WHERE user_id = ?').get(userId) || {
theme_id: 'inherit', density: 'comfortable', reduced_motion: 'system', updated_at: null,
};
}
export function saveUserThemePreference(userId, preference) {
ensureThemeSchema();
db.prepare(`INSERT INTO user_theme_preferences (user_id, theme_id, density, reduced_motion)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET theme_id = excluded.theme_id, density = excluded.density,
reduced_motion = excluded.reduced_motion, updated_at = datetime('now')`).run(
userId, preference.theme_id, preference.density, preference.reduced_motion,
);
return getUserThemePreference(userId);
}
function getSetting(key, fallback) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row?.value ?? fallback;
}
function setSetting(key, value) {
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, String(value));
}
export function getThemeSystemDefault() {
return {
theme_id: getSetting('theme_system_default', 'system'),
density: getSetting('theme_system_density', 'comfortable'),
reduced_motion: getSetting('theme_system_reduced_motion', 'system'),
};
}
export function saveThemeSystemDefault(preference) {
const transaction = db.transaction(() => {
setSetting('theme_system_default', preference.theme_id);
setSetting('theme_system_density', preference.density);
setSetting('theme_system_reduced_motion', preference.reduced_motion);
});
transaction();
return getThemeSystemDefault();
}
+85
View File
@@ -0,0 +1,85 @@
import { PlatformError } from '../../kernel/errors.mjs';
import {
evaluateThemeAccessibility, getBaseThemeValues, getBuiltinThemes, getThemeContract,
resolveThemeValues, validateThemeDefinition, validateThemePreference,
} from '../../core/themes/theme-contract.mjs';
import {
deleteThemeProfile, ensureThemeSchema, getThemeProfile, getThemeSystemDefault,
getUserThemePreference, listThemeProfiles, saveThemeProfile, saveThemeSystemDefault,
saveUserThemePreference,
} from './repository.mjs';
function decorate(profile) {
return {
...profile,
builtin: false,
resolved_values: resolveThemeValues({ baseTheme: profile.base_theme, values: profile.values }),
accessibility: evaluateThemeAccessibility({ baseTheme: profile.base_theme, values: profile.values }),
};
}
function customExists(id) {
return Boolean(getThemeProfile(id));
}
function effectivePreference(userPreference, systemDefault) {
return userPreference.theme_id === 'inherit' ? { ...systemDefault, inherited: true } : { ...userPreference, inherited: false };
}
export function createThemeService() {
ensureThemeSchema();
return Object.freeze({
health() {
ensureThemeSchema();
return { ok: true, profiles: listThemeProfiles().length, contract: getThemeContract().contractVersion };
},
catalog(userId) {
const custom = listThemeProfiles().map(decorate);
const preference = getUserThemePreference(userId);
const systemDefault = getThemeSystemDefault();
return {
contract: getThemeContract(),
builtin: getBuiltinThemes(),
custom,
preference,
system_default: systemDefault,
effective_preference: effectivePreference(preference, systemDefault),
};
},
validate(input) {
const theme = validateThemeDefinition(input, { allowExistingId: true });
return { theme, resolved_values: resolveThemeValues({ baseTheme: theme.base_theme, values: theme.values }), accessibility: theme.accessibility };
},
create(input, userId) {
const theme = validateThemeDefinition(input);
if (getThemeProfile(theme.id)) throw new PlatformError('Theme-ID ist bereits vorhanden.', { code: 'THEME_DUPLICATE', status: 409, expose: true });
return decorate(saveThemeProfile(theme, userId));
},
update(id, input, userId) {
if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
const theme = validateThemeDefinition({ ...input, id }, { allowExistingId: true });
return decorate(saveThemeProfile(theme, userId));
},
remove(id) {
if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
deleteThemeProfile(id);
return { ok: true, id };
},
saveUserPreference(userId, input) {
const preference = validateThemePreference(input, { customThemeExists: customExists });
return saveUserThemePreference(userId, preference);
},
saveSystemDefault(input) {
const preference = validateThemePreference(input, { customThemeExists: customExists });
if (preference.theme_id === 'inherit') throw new PlatformError('Der Systemstandard kann nicht auf „Vererben“ gesetzt werden.', { code: 'THEME_SYSTEM_INHERIT_INVALID', status: 400, expose: true });
return saveThemeSystemDefault(preference);
},
exportTheme(id) {
const profile = getThemeProfile(id);
if (!profile) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
return { schemaVersion: 1, type: 'vendoo-theme', theme: { id: profile.id, name: profile.name, base_theme: profile.base_theme, values: profile.values } };
},
});
}
export { getBaseThemeValues };
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PlatformError } from '../../kernel/errors.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
let service = null;
export function getUsersService() {
if (!service) throw new PlatformError('Benutzer-Modul ist noch nicht gestartet.', { code: 'USERS_MODULE_NOT_READY', status: 503, expose: true });
return service;
}
export function createUsersModule() {
return {
manifest,
lifecycle: {
async start() { service = (await import('./service.mjs')).createUsersService(); },
async stop() { service = null; },
async health() { return getUsersService().health(); },
},
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "vendoo.users",
"name": "Users & Roles",
"version": "2.0.0",
"type": "core",
"status": "native",
"description": "Native Benutzer-, Profil-, Rollen- und Passwortverwaltung mit Schutz des letzten Administrators.",
"requires": ["vendoo.auth", "vendoo.sessions"],
"permissions": ["users.manage"],
"provides": ["users.directory", "users.roles"],
"consumes": ["auth.identity", "sessions.lifecycle"],
"owns": ["users.accounts", "users.profiles"]
}
+85
View File
@@ -0,0 +1,85 @@
import { objectSchema } from '../../kernel/input-schema.mjs';
import { getUsersService } from './index.mjs';
const emailRule = { type: 'string', required: true, minLength: 3, maxLength: 254, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ };
const roles = ['admin','manager','editor','publisher','viewer'];
export function registerUsersRoutes({ app, routes, deps }) {
routes.register(app, {
id: 'users.me.read', method: 'GET', path: '/api/me', owner: 'vendoo.users', policy: 'users.self', csrf: false, stability: 'stable',
handler: async (req, res) => res.json(getUsersService().currentIdentity(req.user.id, req.cookies?.csrf_token)),
});
routes.register(app, {
id: 'users.me.update', method: 'PUT', path: '/api/me', owner: 'vendoo.users', policy: 'users.self', csrf: true, stability: 'stable',
input: objectSchema({ name: { type: 'string', required: true, minLength: 1, maxLength: 120 } }),
handler: async (req, res) => {
const user = getUsersService().updateProfile(req.user.id, req.validatedBody);
deps.auditLog(req.user.id, req.user.email, 'user.profile_updated', 'user', String(req.user.id), null, deps.getClientIp(req));
res.json(user);
},
});
routes.register(app, {
id: 'users.admin.list', method: 'GET', path: '/api/admin/users', owner: 'vendoo.users', policy: 'users.manage', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json(getUsersService().list()),
});
routes.register(app, {
id: 'users.admin.invite', method: 'POST', path: '/api/admin/users/invite', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: roles }, method: { type: 'string', enum: ['magic_link','auto_password'] } }),
handler: async (req, res) => {
const prepared = getUsersService().prepareInvite({ ...req.validatedBody, createdBy: req.user.id });
const baseUrl = `${req.protocol}://${req.get('host')}`;
if (prepared.mode === 'auto_password') {
const mailResult = await deps.sendCredentials(prepared.user.email, prepared.password, prepared.user.name || '', baseUrl);
deps.auditLog(req.user.id, req.user.email, 'user.invited', 'user', prepared.user.email, `Rolle: ${prepared.role}, Auto-Passwort`, deps.getClientIp(req));
return res.json({ ok: true, emailSent: mailResult.sent, consoleFallback: mailResult.consoleFallback || false, password: prepared.password });
}
const result = await deps.sendMagicLink(prepared.email, prepared.invite.token, baseUrl, 'invite');
deps.auditLog(req.user.id, req.user.email, 'user.invited', 'user', prepared.email, `Rolle: ${prepared.role}`, deps.getClientIp(req));
res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false });
},
});
routes.register(app, {
id: 'users.admin.invite-resend', method: 'POST', path: '/api/admin/users/resend-invite', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
input: objectSchema({ user_id: { type: 'integer', required: true, min: 1 } }),
handler: async (req, res) => {
const prepared = getUsersService().resendInvite({ userId: req.validatedBody.user_id, createdBy: req.user.id });
const result = await deps.sendMagicLink(prepared.user.email, prepared.invite.token, `${req.protocol}://${req.get('host')}`, 'invite');
deps.auditLog(req.user.id, req.user.email, 'user.invite_resent', 'user', String(prepared.user.id), null, deps.getClientIp(req));
res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false });
},
});
routes.register(app, {
id: 'users.admin.update', method: 'PUT', path: '/api/admin/users/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
input: objectSchema({ name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: roles }, active: { type: 'integer', min: 0, max: 1 }, avatar_color: { type: 'string', maxLength: 32 } }),
handler: async (req, res) => {
const updated = getUsersService().updateManagedUser({ actor: req.user, userId: Number(req.params.id), patch: req.validatedBody });
deps.auditLog(req.user.id, req.user.email, 'user.updated', 'user', String(req.params.id), JSON.stringify(req.validatedBody), deps.getClientIp(req));
res.json(updated);
},
});
routes.register(app, {
id: 'users.admin.delete', method: 'DELETE', path: '/api/admin/users/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
handler: async (req, res) => {
const deleted = getUsersService().deleteManagedUser({ actor: req.user, userId: Number(req.params.id) });
deps.auditLog(req.user.id, req.user.email, 'user.deleted', 'user', String(req.params.id), deleted.email, deps.getClientIp(req));
res.json({ ok: true });
},
});
routes.register(app, {
id: 'users.admin.password-reset', method: 'POST', path: '/api/admin/users/:id/reset-password', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable',
input: objectSchema({ password: { type: 'string', required: true, maxLength: 512, trim: false } }),
handler: async (req, res) => {
const user = getUsersService().resetPassword({ userId: Number(req.params.id), password: req.validatedBody.password });
deps.auditLog(req.user.id, req.user.email, 'user.password_reset', 'user', String(req.params.id), `für ${user.email}`, deps.getClientIp(req));
res.json({ ok: true, message: `Passwort für ${user.email} zurückgesetzt` });
},
});
routes.register(app, {
id: 'users.admin.login-history', method: 'GET', path: '/api/admin/users/:id/login-history', owner: 'vendoo.users', policy: 'users.manage', csrf: false, stability: 'stable',
handler: async (req, res) => res.json(getUsersService().loginHistory(Number(req.params.id), 30)),
});
routes.register(app, {
id: 'users.admin.login-history-compat', method: 'GET', path: '/api/admin/login-history/:userId', owner: 'vendoo.users', policy: 'users.manage', csrf: false, stability: 'deprecated',
handler: async (req, res) => res.json(getUsersService().loginHistory(Number(req.params.userId), 20)),
});
}
+95
View File
@@ -0,0 +1,95 @@
import { randomBytes } from 'node:crypto';
import {
auditLog, createAuthToken, createUser, deleteUser, getLoginHistory, getUser, getUserByEmail,
getUsers, setPassword, updateUser, validatePasswordStrength,
} from '../../core/identity/identity-store.mjs';
import { permissionsForRole, getRoleDefinitions } from '../../../lib/security.mjs';
import { PlatformError } from '../../kernel/errors.mjs';
const ROLES = new Set(['admin', 'manager', 'editor', 'publisher', 'viewer']);
function fail(message, status = 400, code = 'USER_OPERATION_FAILED') {
throw new PlatformError(message, { status, code, expose: true });
}
function publicUser(user) {
if (!user) return null;
const { password_hash: _passwordHash, ...safe } = user;
return safe;
}
export function createUsersService() {
return Object.freeze({
list() { return getUsers().map(publicUser); },
find(id) { return publicUser(getUser(id)); },
findByEmail(email) { return publicUser(getUserByEmail(email)); },
currentIdentity(userId, csrf) {
const user = getUser(userId);
if (!user) fail('Benutzer nicht gefunden.', 404, 'USER_NOT_FOUND');
const initials = (user.name || user.email).split(/[\s@]/).map(word => word[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join('');
return {
id: user.id, email: user.email, name: user.name, role: user.role,
avatar_color: user.avatar_color, initials, permissions: [...permissionsForRole(user.role)],
role_definition: getRoleDefinitions().find(role => role.key === user.role) || null,
csrf,
};
},
updateProfile(userId, data) {
const updated = updateUser(userId, { name: data.name });
if (!updated) fail('Benutzer nicht gefunden.', 404, 'USER_NOT_FOUND');
return publicUser(updated);
},
prepareInvite({ email, name, role, method, createdBy }) {
const normalizedRole = ROLES.has(role) ? role : 'editor';
if (getUserByEmail(email)) fail('User existiert bereits', 400, 'USER_EXISTS');
if (method === 'auto_password') {
const password = `Vd!${randomBytes(9).toString('base64url')}9a`;
const user = createUser(email, name || email.split('@')[0], normalizedRole, createdBy, password);
return { mode: 'auto_password', user: publicUser(user), password, role: normalizedRole };
}
const invite = createAuthToken(email, 'invite', normalizedRole, createdBy);
return { mode: 'magic_link', invite, email, name: name || '', role: normalizedRole };
},
resendInvite({ userId, createdBy }) {
const user = getUser(userId);
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
return { user: publicUser(user), invite: createAuthToken(user.email, 'invite', user.role, createdBy) };
},
updateManagedUser({ actor, userId, patch }) {
const existing = getUser(userId);
if (!existing) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
if (Number(actor.id) === Number(userId) && patch.role && patch.role !== 'admin') fail('Du kannst dir selbst nicht die Admin-Rolle entziehen');
const activeAdmins = getUsers().filter(user => user.role === 'admin' && user.active).length;
const removesLastAdmin = existing.role === 'admin' && existing.active && activeAdmins <= 1
&& ((patch.role && patch.role !== 'admin') || Number(patch.active) === 0);
if (removesLastAdmin) fail('Der letzte aktive Administrator darf nicht deaktiviert oder herabgestuft werden.');
const normalized = {};
if (patch.name !== undefined) normalized.name = patch.name;
if (patch.role !== undefined) normalized.role = ROLES.has(patch.role) ? patch.role : 'viewer';
if (patch.active !== undefined) normalized.active = Number(patch.active) === 0 ? 0 : 1;
if (patch.avatar_color !== undefined) normalized.avatar_color = patch.avatar_color;
return publicUser(updateUser(userId, normalized));
},
deleteManagedUser({ actor, userId }) {
if (Number(actor.id) === Number(userId)) fail('Du kannst dich nicht selbst löschen');
const user = getUser(userId);
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
if (user.role === 'admin' && user.active && getUsers().filter(entry => entry.role === 'admin' && entry.active).length <= 1) {
fail('Der letzte aktive Administrator darf nicht gelöscht werden.');
}
deleteUser(userId);
return publicUser(user);
},
resetPassword({ userId, password }) {
const check = validatePasswordStrength(password);
if (!check.valid) fail(`Passwort benötigt ${check.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
const user = getUser(userId);
if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND');
setPassword(userId, password);
return publicUser(user);
},
loginHistory(userId, limit = 30) { return getLoginHistory(userId, limit); },
health() { return { ok: true, users: getUsers().length }; },
auditLog,
});
}