* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
234 lines
9.6 KiB
JavaScript
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();
|
|
}
|
|
}
|