dorfteich/apps/api/src/plugins/plugins.service.ts
Claude Fable 5 e32f961047
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 3m57s
CI / Import/export fidelity gate (push) Successful in 43s
Complete section-style plugins: CSS gate, injection, picker, export (#75)
Second half of #75 on top of the section node (2e96173/784f21d):

- Install gate for section_style CSS (plugin-css.ts): every rule must be
  scoped under one of the plugin's own .dt-style-<pluginId>-<styleId>
  classes (enforced, not rewritten — grouping at-rules checked inside,
  @font-face/@keyframes exempt, statement at-rules rejected); positioning
  out of the content flow (anything but static/relative) is rejected as an
  overlay vector; "</style" is rejected as a breakout vector for inlined
  embedding. Hostile fixtures from the acceptance list are pinned in
  plugin-css.test.ts.
- Web: usePondPlugins loads the pond's active plugins once per visit;
  SectionStyleSheets links each active style plugin's immutable
  styles.css; SectionStyleMenu (toolbar) wraps/restyles/unwraps with a
  picker fed from the plugins' i18n titles. Sections show a faint dashed
  hint while editing so unstyled (plugin-disabled) sections stay findable.
- PDF export: PluginsService.sectionStyleCssForPond inlines the pond's
  active section-style CSS into the Gotenberg HTML, so styled sections
  survive the network-isolated render; covered in export.service.db.test.
- Reference plugin packages/plugins/section-styles-basic (callout, info,
  warning, colored-box; theme-neutral semi-transparent backgrounds), a
  workspace package whose tests validate it against the SDK schema and
  whose real files run through the api install gate.
- e2e section-styles.spec.ts: install → wrap → computed background in edit
  and read mode → unwrap → neutral fallback after disabling the plugin.

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

266 lines
10 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
import type { PluginInstanceMode, PluginView, PondPluginSetting } from '@dorfteich/shared';
import { ClockService } from '../common/clock.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 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.
*/
async install(zip: Buffer): 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);
}
this.logger.info(
{ plugin: manifest.id, version: manifest.version, update: isActiveUpdate },
'audit: plugin installed',
);
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): 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] },
});
this.logger.info({ plugin: id, mode }, 'audit: plugin mode set');
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): 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);
this.logger.info({ plugin: id }, 'audit: plugin uninstalled');
}
/**
* 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): 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 },
});
this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled');
}
/** 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(),
};
}
}