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(); } }