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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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-]*)+$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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 })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 })) };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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() };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
@@ -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(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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']}; } } }; }
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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);}});
|
||||
}
|
||||
@@ -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) }; }
|
||||
}
|
||||
@@ -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'] }; } } }; }
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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); } });
|
||||
}
|
||||
@@ -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(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<')
|
||||
.replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/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 }; }
|
||||
}
|
||||
@@ -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']};}}};}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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});
|
||||
@@ -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);}});}
|
||||
@@ -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};}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }; },
|
||||
});
|
||||
}
|
||||
@@ -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(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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` });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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() }; },
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user