dorfteich/apps/api/src/plugins/plugins.service.ts
Claude Fable 5 c8aac13dfb
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
Add Site-Admin system panel with persistent audit trail (#86)
New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 20:03:05 +02:00

323 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
import type {
PluginFallbackView,
PluginInstanceMode,
PluginView,
PondPluginSetting,
} from '@dorfteich/shared';
import { ClockService } from '../common/clock.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginPackageError, STYLES_FILE } from './plugin.constants';
const DB_MODE_TO_VIEW: Record<DbPluginMode, PluginInstanceMode> = {
DISABLED: 'disabled',
OPTIONAL: 'optional',
REQUIRED: 'required',
};
const VIEW_MODE_TO_DB: Record<PluginInstanceMode, DbPluginMode> = {
disabled: 'DISABLED',
optional: 'OPTIONAL',
required: 'REQUIRED',
};
/**
* Install registry for plugin packages (ADR 0008, issue #71): validates and
* unpacks an uploaded ZIP, records/updates its metadata, and removes it on
* uninstall. The install path is shared by the admin upload endpoint and the
* directory watcher. Instance-mode and per-pond activation (#72) also live
* here, since they read/write the same registry.
*/
@Injectable()
export class PluginsService {
constructor(
private readonly prisma: PrismaService,
private readonly packages: PluginPackageService,
private readonly storage: PluginStorageService,
private readonly clock: ClockService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PluginsService.name);
}
/**
* Validates and installs (or updates) a plugin from a ZIP buffer. An update
* (same id already installed and not removed) is accepted only when its
* version is strictly higher; the admin's chosen instance `mode` is preserved
* across updates. Files land atomically before the metadata pointer flips.
*/
/** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> {
const { manifest, files } = this.packages.parse(zip);
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
const isActiveUpdate = existing !== null && existing.removedAt === null;
if (isActiveUpdate && !isHigherVersion(manifest.version, existing.version)) {
throw new PluginPackageError(
'plugin_version_not_higher',
`Version ${manifest.version} does not exceed the installed ${existing.version}`,
);
}
// Write the new version's assets first — the metadata still points at the
// old version until the upsert below, so serving never 404s mid-update.
await this.storage.writeVersion(manifest.id, manifest.version, files);
const record = await this.prisma.plugin.upsert({
where: { id: manifest.id },
create: {
id: manifest.id,
name: manifest.name,
version: manifest.version,
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
},
update: {
name: manifest.name,
version: manifest.version,
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
// Reinstalling a previously removed plugin clears the tombstone.
removedAt: null,
},
});
// Drop the superseded version's files once the pointer has moved.
if (existing && existing.version !== manifest.version) {
await this.storage.removeVersion(manifest.id, existing.version);
}
await this.audit.record({
action: 'plugin.installed',
actorId: actor?.id,
targetType: 'plugin',
targetId: manifest.id,
details: { version: manifest.version, update: isActiveUpdate },
});
return this.toView(record);
}
/**
* Sets a plugin's instance mode (Site Admin, #72). `required` makes it active
* in every pond and blocks uninstall; `disabled` hides it everywhere;
* `optional` lets Pond Admins toggle it per pond. Per-pond activation rows are
* kept across mode changes (they are simply ignored while non-optional), so
* flipping optional→required→optional restores the previous per-pond choices.
*/
async setMode(id: string, mode: PluginInstanceMode, actor?: User): Promise<PluginView> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) {
throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`);
}
const updated = await this.prisma.plugin.update({
where: { id },
data: { mode: VIEW_MODE_TO_DB[mode] },
});
await this.audit.record({
action: 'plugin.mode_set',
actorId: actor?.id,
targetType: 'plugin',
targetId: id,
details: { mode },
});
return this.toView(updated);
}
/**
* Uninstalls a plugin: refused while `required`; otherwise the metadata is
* tombstoned (`removedAt` set, per-pond activations dropped) and every file is
* removed from disk.
*/
async uninstall(id: string, actor?: User): Promise<void> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) {
throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`);
}
if (plugin.mode === 'REQUIRED') {
throw new PluginPackageError(
'plugin_required_cannot_uninstall',
`Plugin ${id} is required and cannot be uninstalled`,
);
}
await this.prisma.$transaction([
this.prisma.pondPlugin.deleteMany({ where: { pluginId: id } }),
this.prisma.plugin.update({
where: { id },
data: { removedAt: this.clock.now() },
}),
]);
await this.storage.removePlugin(id);
await this.audit.record({
action: 'plugin.uninstalled',
actorId: actor?.id,
targetType: 'plugin',
targetId: id,
});
}
/**
* The plugins active for one pond (#72): every `required` plugin plus the
* `optional` ones this pond has switched on. This is the list the SPA loads
* per pond to decide which plugins to mount. `disabled` and un-activated
* optional plugins never appear.
*/
async listForPond(pondId: string): Promise<PluginView[]> {
const [plugins, activations] = await Promise.all([
this.prisma.plugin.findMany({
where: { removedAt: null, mode: { in: ['REQUIRED', 'OPTIONAL'] } },
orderBy: { name: 'asc' },
}),
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
.map((p) => this.toView(p));
}
/**
* The concatenated stylesheets of the pond's active `section_style` plugins
* (issue #75), for inlining into self-contained renders (PDF export HTML).
* Every stylesheet passed the install gate — scoped selectors, no external
* fetches, no `</style>` breakout — so embedding it verbatim is safe. A
* missing file (e.g. a volume restored without one version dir) degrades to
* neutral sections rather than failing the caller.
*/
async sectionStyleCssForPond(pondId: string): Promise<string> {
const parts: string[] = [];
for (const plugin of await this.listForPond(pondId)) {
if (plugin.kind !== 'section_style') continue;
const path = this.storage.assetPath(plugin.id, plugin.version, STYLES_FILE);
if (!path || !(await this.storage.assetExists(path))) continue;
parts.push(`/* ${plugin.id}@${plugin.version} */\n${await this.storage.readAsset(path)}`);
}
return parts.join('\n');
}
/**
* The optional plugins a Pond Admin may toggle for one pond (#72), each with
* this pond's current on/off state (default off — activation is opt-in).
*/
async listPondSettings(pondId: string): Promise<PondPluginSetting[]> {
const [plugins, activations] = await Promise.all([
this.prisma.plugin.findMany({
where: { removedAt: null, mode: 'OPTIONAL' },
orderBy: { name: 'asc' },
}),
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true }));
}
/**
* Toggles an `optional` plugin for one pond (Pond Admin, #72). Only optional
* plugins are per-pond choices; toggling a required/disabled/absent plugin is
* rejected so the UI cannot desync the model.
*/
async setPondActivation(
pondId: string,
pluginId: string,
enabled: boolean,
actor?: User,
): Promise<void> {
const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } });
if (!plugin || plugin.removedAt !== null) {
throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`);
}
if (plugin.mode !== 'OPTIONAL') {
throw new PluginPackageError(
'plugin_not_optional',
`Plugin ${pluginId} is not optional and cannot be toggled per pond`,
);
}
await this.prisma.pondPlugin.upsert({
where: { pondId_pluginId: { pondId, pluginId } },
create: { pondId, pluginId, enabled },
update: { enabled },
});
await this.audit.record({
action: 'plugin.pond_toggled',
actorId: actor?.id,
targetType: 'pond',
targetId: pondId,
details: { plugin: pluginId, enabled },
});
}
/**
* The stored-manifest fallback a `plugin_block` renders while its plugin is
* inactive (issue #76). Looks past `removedAt` on purpose: the manifest row
* is the uninstall tombstone (data-model.md), so blocks referencing a gone
* plugin still resolve a name and text. An image fallback is served from the
* plugin's assets and thus only resolvable while those files exist.
*/
async fallbackFor(pluginId: string): Promise<PluginFallbackView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } });
if (!plugin) return null;
const manifest = plugin.manifest as unknown as PluginManifest;
const declared = manifest.fallback ?? null;
let fallback: PluginFallbackView['fallback'] = null;
if (declared?.type === 'text') {
fallback = declared;
} else if (declared?.type === 'image' && plugin.removedAt === null) {
fallback = {
type: 'image',
url: `/api/v1/plugins/${plugin.id}/${plugin.version}/${declared.value}`,
};
}
return { pluginId: plugin.id, name: plugin.name, fallback };
}
/** All installed (non-removed) plugins, for the Site Admin list (#72). */
async list(): Promise<PluginView[]> {
const plugins = await this.prisma.plugin.findMany({
where: { removedAt: null },
orderBy: { name: 'asc' },
});
return plugins.map((plugin) => this.toView(plugin));
}
/** One installed plugin, or `null` if absent/removed. */
async get(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
return this.toView(plugin);
}
private toView(plugin: Plugin): PluginView {
const manifest = plugin.manifest as unknown as PluginManifest;
return {
id: plugin.id,
name: plugin.name,
version: plugin.version,
apiVersion: plugin.apiVersion,
kind: plugin.kind,
mode: DB_MODE_TO_VIEW[plugin.mode],
permissions: manifest.permissions ?? [],
extensionPoints: manifest.extensionPoints.map((point) => ({
type: point.type,
id: point.id,
title: point.title,
})),
assetBasePath: `/api/v1/plugins/${plugin.id}/${plugin.version}/`,
license: manifest.license,
homepage: manifest.homepage,
installedAt: plugin.installedAt.toISOString(),
updatedAt: plugin.updatedAt.toISOString(),
};
}
}