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
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
323 lines
12 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|