Files
vendoo/app/modules/module-manager/service.mjs
T

234 lines
9.6 KiB
JavaScript

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