Add plugin storage, install API, and directory watcher (#71)
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled

Backend for installing plugin ZIPs (ADR 0008, plugin-architecture.md
§Lifecycle, security.md §Plugins). Consumes the #70 SDK for validation.

- Schema: `plugins` (id, name, version, apiVersion, kind, mode, manifest
  jsonb, removedAt soft-delete) + `pond_plugins` (per-pond activation) +
  `PluginInstanceMode` enum; migration 20260710130000_plugins.
- `PluginPackageService`: pure, stateless ZIP → validated package via
  fflate — structure check, manifest validation (SDK), apiVersion gate,
  kind/bundle/styles rules, CSS sanitation (no @import / external url() /
  expression()), zip-slip and unpacked-size guards. Each failure carries a
  stable PluginErrorCode; manifest issues travel as ApiError details.
- `PluginStorageService`: on-disk layout `<PLUGINS_DIR>/<id>/<version>/`;
  atomic writeVersion (staging dir + rename, no 404 window mid-update),
  removeVersion/removePlugin, traversal-safe asset resolution, dropzone +
  quarantine dirs.
- `PluginsService`: install/update (update only to a strictly higher
  version, preserving the admin's instance mode; files land before the
  metadata pointer flips) / uninstall (refused while required; soft-delete
  + files removed + pond activations dropped) / list / get.
- `POST/GET/DELETE /admin/plugins` (SiteAdminGuard, multer memory upload),
  error→HTTP-status mapping. Public version-pinned static serving at
  `GET /plugins/:id/:version/*rest` with immutable cache + nosniff, only for
  the installed current version.
- `PluginWatcherService`: watches `<PLUGINS_DIR>/_dropzone/`, runs the same
  validation, installs valid drops and quarantines invalid ones with the
  error logged; inert under NODE_ENV=test (tests drive processDropped).
- SDK: `compareVersions`/`isHigherVersion`. shared: `PluginView`,
  `PluginInstanceMode`, `PLUGIN_ERROR_CODES`, `PLUGINS_DIR` env, plugin
  error i18n (de+en). Compose: `plugins` volume + `PLUGINS_DIR`.
- Tests: package unit test (valid + each invalid class) and an e2e DB test
  (GUI install + immutable serving, non-admin 403, invalid-manifest details,
  dropzone install + quarantine, atomic higher-only update, required-guarded
  uninstall that removes files and tombstones metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-10 16:55:26 +02:00
parent ec6ca80c4d
commit 621aa47244
27 changed files with 1360 additions and 8 deletions

View File

@ -16,6 +16,7 @@
"search:reindex": "tsx src/search/reindex.cli.ts" "search:reindex": "tsx src/search/reindex.cli.ts"
}, },
"dependencies": { "dependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
@ -25,6 +26,7 @@
"argon2": "^0.44.0", "argon2": "^0.44.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dompurify": "^3.4.11", "dompurify": "^3.4.11",
"fflate": "^0.8.3",
"fractional-indexing": "^4.0.0", "fractional-indexing": "^4.0.0",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
@ -55,7 +57,6 @@
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"fflate": "^0.8.3",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"supertest": "^7.0.0", "supertest": "^7.0.0",

View File

@ -0,0 +1,35 @@
-- CreateEnum
CREATE TYPE "PluginInstanceMode" AS ENUM ('DISABLED', 'OPTIONAL', 'REQUIRED');
-- CreateTable
CREATE TABLE "plugins" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"version" TEXT NOT NULL,
"api_version" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"mode" "PluginInstanceMode" NOT NULL DEFAULT 'DISABLED',
"manifest" JSONB NOT NULL,
"installed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"removed_at" TIMESTAMP(3),
CONSTRAINT "plugins_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pond_plugins" (
"pond_id" TEXT NOT NULL,
"plugin_id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pond_plugins_pkey" PRIMARY KEY ("pond_id","plugin_id")
);
-- AddForeignKey
ALTER TABLE "pond_plugins" ADD CONSTRAINT "pond_plugins_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pond_plugins" ADD CONSTRAINT "pond_plugins_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -82,6 +82,7 @@ model Pond {
labels Label[] labels Label[]
grants RoleGrant[] grants RoleGrant[]
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
pondPlugins PondPlugin[]
@@index([ownerId]) @@index([ownerId])
@@map("ponds") @@map("ponds")
@ -571,3 +572,53 @@ model ConversionJob {
@@index([status, createdAt]) @@index([status, createdAt])
@@map("conversion_jobs") @@map("conversion_jobs")
} }
/// Instance-level activation a Site Admin sets per installed plugin
/// (ADR 0008 lifecycle, issue #71). `optional` plugins are then toggled per
/// pond via PondPlugin; `required` plugins cannot be uninstalled.
enum PluginInstanceMode {
DISABLED
OPTIONAL
REQUIRED
}
/// An installed plugin package (ADR 0008, issue #71). The validated manifest is
/// stored verbatim so serving and admin views never re-read disk; the unpacked
/// bundle lives under `<PLUGINS_DIR>/<id>/<version>/`. Uninstall is a soft
/// delete (`removedAt` set, files removed) so existing plugin_block nodes can
/// still resolve the manifest fallback.
model Plugin {
id String @id
name String
version String
apiVersion String @map("api_version")
kind String
mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
manifest Json
installedAt DateTime @default(now()) @map("installed_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`.
removedAt DateTime? @map("removed_at")
pondPlugins PondPlugin[]
@@map("plugins")
}
/// Per-pond activation of an `optional` plugin, toggled by a Pond Admin
/// (issue #71 model; the toggle UI/endpoint is #72). A row's presence with
/// `enabled = true` means the plugin is active in that pond.
model PondPlugin {
pondId String @map("pond_id")
pluginId String @map("plugin_id")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
@@id([pondId, pluginId])
@@map("pond_plugins")
}

View File

@ -18,6 +18,7 @@ import { MailModule } from './mail/mail.module';
import { MembersModule } from './members/members.module'; import { MembersModule } from './members/members.module';
import { PagesModule } from './pages/pages.module'; import { PagesModule } from './pages/pages.module';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
import { PluginsModule } from './plugins/plugins.module';
import { PondsModule } from './ponds/ponds.module'; import { PondsModule } from './ponds/ponds.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
@ -50,6 +51,7 @@ import { VersionsModule } from './versions/versions.module';
MembersModule, MembersModule,
PublicModule, PublicModule,
ImportExportModule, ImportExportModule,
PluginsModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({

View File

@ -0,0 +1,83 @@
import {
BadRequestException,
ConflictException,
Controller,
Delete,
Get,
HttpCode,
HttpException,
NotFoundException,
Param,
PayloadTooLargeException,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { PluginView } from '@dorfteich/shared';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { PluginsService } from './plugins.service';
import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants';
/** Maps a package/registry error to the HTTP status that fits its class. */
function toHttpException(error: PluginPackageError): HttpException {
const body = error.details
? { code: error.code, message: error.message, details: error.details }
: { code: error.code, message: error.message };
switch (error.code) {
case 'plugin_not_found':
return new NotFoundException(body);
case 'plugin_required_cannot_uninstall':
case 'plugin_version_not_higher':
return new ConflictException(body);
case 'plugin_too_large':
return new PayloadTooLargeException(body);
default:
return new BadRequestException(body);
}
}
/**
* Site Admin plugin administration (ADR 0008, issue #71). Installing is
* deliberately restricted to Site Admins (kickoff decision); the instance-mode
* and per-pond-activation writes arrive with the admin UI (#72).
*/
@Controller('admin/plugins')
@UseGuards(SiteAdminGuard)
export class PluginAdminController {
constructor(private readonly plugins: PluginsService) {}
/** Upload and install (or update) a plugin ZIP. */
@Post()
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } }))
async install(@UploadedFile() file: Express.Multer.File | undefined): Promise<PluginView> {
if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' });
try {
return await this.plugins.install(file.buffer);
} catch (error) {
if (error instanceof PluginPackageError) throw toHttpException(error);
throw error;
}
}
/** All installed plugins. */
@Get()
list(): Promise<PluginView[]> {
return this.plugins.list();
}
/** Uninstall a plugin (refused while `required`). */
@Delete(':id')
@HttpCode(204)
async uninstall(@Param('id') id: string): Promise<void> {
try {
await this.plugins.uninstall(id);
} catch (error) {
if (error instanceof PluginPackageError) throw toHttpException(error);
throw error;
}
}
}

View File

@ -0,0 +1,76 @@
import {
Controller,
Get,
NotFoundException,
Param,
Req,
Res,
StreamableFile,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../auth/auth.guard';
import { PluginStorageService } from './plugin-storage.service';
import { PluginsService } from './plugins.service';
/** Content types for the file kinds a plugin bundle ships. Unknown extensions
* are served as opaque bytes the sandbox iframe's CSP decides what may run. */
const CONTENT_TYPES: Record<string, string> = {
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
css: 'text/css; charset=utf-8',
json: 'application/json; charset=utf-8',
html: 'text/html; charset=utf-8',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
woff2: 'font/woff2',
};
function contentTypeFor(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return CONTENT_TYPES[ext] ?? 'application/octet-stream';
}
/**
* Serves the unpacked assets of an installed plugin version (ADR 0008). Public
* because the sandboxed, opaque-origin iframe (#73) loads them with no session,
* and plugin bundles are client code, not user data. Paths are version-pinned
* and content-addressed by version, so responses are immutable.
*/
@Controller('plugins')
export class PluginAssetsController {
constructor(
private readonly plugins: PluginsService,
private readonly storage: PluginStorageService,
) {}
@Get(':id/:version/*rest')
@Public()
async serve(
@Param('id') id: string,
@Param('version') version: string,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
// Only serve assets for an installed, current version — a removed plugin or
// a stale version pointer must not leak files.
const plugin = await this.plugins.get(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
const rest = (request.params as Record<string, unknown>).rest;
const relPath = Array.isArray(rest) ? rest.join('/') : String(rest ?? '');
const full = this.storage.assetPath(id, version, relPath);
if (!full || !(await this.storage.assetExists(full))) throw new NotFoundException();
response.set('X-Content-Type-Options', 'nosniff');
response.set('Cache-Control', 'public, max-age=31536000, immutable');
return new StreamableFile(this.storage.createAssetReadStream(full), {
type: contentTypeFor(relPath),
});
}
}

View File

@ -0,0 +1,57 @@
import { PluginPackageError } from './plugin.constants';
/**
* Style-plugin CSS sanitation (ADR 0008, security.md §Plugins). A style plugin
* ships CSS only no JavaScript so the risk is exfiltration/track-back via
* network-fetching CSS constructs, not code execution. We reject the constructs
* that can reach off-instance or execute:
*
* - `@import` pulls in another stylesheet (possibly external);
* - `url(...)` pointing at an absolute/external/`javascript:` target a font
* or background that beacons to a third party (relative and `data:` are ok);
* - `expression(...)` legacy IE dynamic CSS (script execution).
*
* Selector scoping under `.dt-style-<pluginId>-<styleId>` is applied by the
* section-style renderer (#75); here we only gate what may be stored.
*/
/** Strips CSS comments so their contents cannot hide a rejected construct. */
function stripComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, ' ');
}
/** Whether a `url(...)` target reaches outside the plugin's own assets. A bare
* relative path or a `data:` URI stays inside the sandbox; anything with a
* scheme (`http:`, `https:`, `javascript:`, `file:`, ) or a protocol-relative
* `//host` prefix is external. */
function isExternalUrl(target: string): boolean {
const value = target.trim().replace(/^['"]|['"]$/g, '');
if (value.startsWith('//')) return true;
if (/^data:/i.test(value)) return false;
return /^[a-z][a-z0-9+.-]*:/i.test(value);
}
/**
* Throws {@link PluginPackageError} `plugin_css_unsafe` if the stylesheet uses a
* forbidden construct; returns normally when it is safe to store and serve.
*/
export function assertSafeCss(css: string): void {
const source = stripComments(css);
if (/@import\b/i.test(source)) {
throw new PluginPackageError('plugin_css_unsafe', 'CSS may not use @import');
}
if (/\bexpression\s*\(/i.test(source)) {
throw new PluginPackageError('plugin_css_unsafe', 'CSS may not use expression()');
}
for (const match of source.matchAll(/\burl\(\s*([^)]*?)\s*\)/gi)) {
const target = match[1] ?? '';
if (isExternalUrl(target)) {
throw new PluginPackageError(
'plugin_css_unsafe',
`CSS may not reference an external URL: ${target.trim()}`,
);
}
}
}

View File

@ -0,0 +1,134 @@
import { zipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import { PluginPackageService } from './plugin-package.service';
import { PluginPackageError } from './plugin.constants';
const service = new PluginPackageService();
const enc = (text: string) => new TextEncoder().encode(text);
const codeManifest = {
id: 'toc',
name: 'Table of Contents',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
extensionPoints: [{ type: 'pageTool', id: 'toc', title: { de: 'Inhalt', en: 'Contents' } }],
permissions: ['readCurrentPage'],
license: 'MIT',
};
const styleManifest = {
id: 'callouts',
name: 'Callouts',
version: '1.0.0',
apiVersion: '1',
kind: 'section_style',
extensionPoints: [{ type: 'sectionStyle', id: 'note', title: { de: 'Hinweis', en: 'Note' } }],
license: 'MIT',
};
function zip(entries: Record<string, Uint8Array>): Buffer {
return Buffer.from(zipSync(entries));
}
function manifestZip(manifest: unknown, extra: Record<string, Uint8Array> = {}): Buffer {
return zip({ 'manifest.json': enc(JSON.stringify(manifest)), ...extra });
}
/** Asserts parsing throws a PluginPackageError with the expected code. */
function expectReject(buffer: Buffer, code: string): PluginPackageError {
try {
service.parse(buffer);
} catch (error) {
expect(error).toBeInstanceOf(PluginPackageError);
expect((error as PluginPackageError).code).toBe(code);
return error as PluginPackageError;
}
throw new Error(`expected parse to reject with ${code}`);
}
describe('PluginPackageService.parse — valid packages', () => {
it('accepts a code plugin with a bundle', () => {
const result = service.parse(
manifestZip(codeManifest, { 'plugin.js': enc('export default {}') }),
);
expect(result.manifest.id).toBe('toc');
expect(result.files.has('plugin.js')).toBe(true);
});
it('accepts a section-style plugin with a stylesheet', () => {
const css = '.note { background: #eef; } .icon { background: url("./icon.png"); }';
const result = service.parse(manifestZip(styleManifest, { 'styles.css': enc(css) }));
expect(result.manifest.kind).toBe('section_style');
expect(result.files.has('styles.css')).toBe(true);
});
});
describe('PluginPackageService.parse — each invalid class', () => {
it('rejects a non-ZIP', () => {
expectReject(Buffer.from('not a zip at all'), 'plugin_invalid_zip');
});
it('rejects a package without a manifest', () => {
expectReject(zip({ 'plugin.js': enc('x') }), 'plugin_bad_structure');
});
it('rejects a manifest that is not valid JSON', () => {
expectReject(zip({ 'manifest.json': enc('{ not json') }), 'plugin_invalid_manifest');
});
it('rejects a schema-invalid manifest with field details', () => {
const error = expectReject(
manifestZip({ ...codeManifest, id: 'Bad Id' }, { 'plugin.js': enc('x') }),
'plugin_invalid_manifest',
);
expect(error.details?.id).toBeTruthy();
});
it('rejects an incompatible apiVersion', () => {
expectReject(
manifestZip({ ...codeManifest, apiVersion: '2' }, { 'plugin.js': enc('x') }),
'plugin_api_incompatible',
);
});
it('rejects a code plugin without a bundle', () => {
expectReject(manifestZip(codeManifest), 'plugin_missing_bundle');
});
it('rejects a style plugin without a stylesheet', () => {
expectReject(manifestZip(styleManifest), 'plugin_missing_styles');
});
it('rejects a stylesheet using @import', () => {
expectReject(
manifestZip(styleManifest, { 'styles.css': enc('@import url("http://evil.test/x.css");') }),
'plugin_css_unsafe',
);
});
it('rejects a stylesheet fetching an external url', () => {
expectReject(
manifestZip(styleManifest, {
'styles.css': enc('.n { background: url(https://evil.test/a) }'),
}),
'plugin_css_unsafe',
);
});
it('rejects a zip-slip path', () => {
expectReject(
zip({ '../evil.js': enc('x'), 'manifest.json': enc(JSON.stringify(codeManifest)) }),
'plugin_bad_structure',
);
});
it('rejects a package that inflates beyond the unpacked limit', () => {
const huge = new Uint8Array(21 * 1024 * 1024); // zeros compress tiny, inflate large
expectReject(
zip({ 'manifest.json': enc(JSON.stringify(codeManifest)), 'plugin.js': huge }),
'plugin_too_large',
);
});
});

View File

@ -0,0 +1,124 @@
import { Injectable } from '@nestjs/common';
import { checkApiVersion, validateManifest, type PluginManifest } from '@dorfteich/plugin-sdk';
import { unzipSync } from 'fflate';
import { assertSafeCss } from './plugin-css';
import {
CODE_BUNDLE_FILE,
MANIFEST_FILE,
MAX_PLUGIN_UNPACKED_BYTES,
PluginPackageError,
STYLES_FILE,
} from './plugin.constants';
/** A validated, unpacked plugin package ready to be written to disk. */
export interface ParsedPluginPackage {
manifest: PluginManifest;
/** Relative posix path → file bytes (directory entries removed). */
files: Map<string, Uint8Array>;
}
/**
* Turns an uploaded ZIP buffer into a validated {@link ParsedPluginPackage} or
* throws {@link PluginPackageError} (ADR 0008, plugin-architecture.md §Package
* format). Pure and stateless no filesystem or database access so both the
* GUI upload and the directory watcher run the exact same validation.
*/
@Injectable()
export class PluginPackageService {
parse(zip: Buffer): ParsedPluginPackage {
const files = this.unzip(zip);
const manifest = this.readManifest(files);
this.checkApiCompatibility(manifest);
this.checkKindFiles(manifest, files);
return { manifest, files };
}
private unzip(zip: Buffer): Map<string, Uint8Array> {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(new Uint8Array(zip));
} catch {
throw new PluginPackageError('plugin_invalid_zip', 'The file is not a valid ZIP archive');
}
const files = new Map<string, Uint8Array>();
let unpacked = 0;
for (const [rawPath, bytes] of Object.entries(entries)) {
// Directory entries come back as zero-length keys ending in '/'.
if (rawPath.endsWith('/')) continue;
const path = this.safeRelativePath(rawPath);
unpacked += bytes.length;
if (unpacked > MAX_PLUGIN_UNPACKED_BYTES) {
throw new PluginPackageError('plugin_too_large', 'The unpacked package is too large');
}
files.set(path, bytes);
}
if (files.size === 0) {
throw new PluginPackageError('plugin_bad_structure', 'The package is empty');
}
return files;
}
/** Rejects absolute paths and `..` traversal (zip-slip) before any write. */
private safeRelativePath(rawPath: string): string {
const path = rawPath.replace(/\\/g, '/').replace(/^\/+/, '');
if (path.split('/').some((segment) => segment === '..')) {
throw new PluginPackageError('plugin_bad_structure', `Illegal path in package: ${rawPath}`);
}
return path;
}
private readManifest(files: Map<string, Uint8Array>): PluginManifest {
const raw = files.get(MANIFEST_FILE);
if (!raw) {
throw new PluginPackageError('plugin_bad_structure', `Package is missing ${MANIFEST_FILE}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(raw));
} catch {
throw new PluginPackageError('plugin_invalid_manifest', `${MANIFEST_FILE} is not valid JSON`);
}
const result = validateManifest(parsed);
if (!result.success || !result.manifest) {
const details: Record<string, string[]> = {};
for (const issue of result.issues) {
(details[issue.path] ??= []).push(issue.message);
}
throw new PluginPackageError('plugin_invalid_manifest', 'The manifest is invalid', details);
}
return result.manifest;
}
private checkApiCompatibility(manifest: PluginManifest): void {
const check = checkApiVersion(manifest.apiVersion);
if (!check.compatible) {
throw new PluginPackageError(
'plugin_api_incompatible',
check.reason ?? 'Unsupported plugin API version',
);
}
}
private checkKindFiles(manifest: PluginManifest, files: Map<string, Uint8Array>): void {
if (manifest.kind === 'code' && !files.has(CODE_BUNDLE_FILE)) {
throw new PluginPackageError(
'plugin_missing_bundle',
`A code plugin must include ${CODE_BUNDLE_FILE}`,
);
}
if (manifest.kind === 'section_style' && !files.has(STYLES_FILE)) {
throw new PluginPackageError(
'plugin_missing_styles',
`A style plugin must include ${STYLES_FILE}`,
);
}
// Whenever a stylesheet is present (required for style plugins, optional for
// code plugins), it must pass the sanitation gate.
const styles = files.get(STYLES_FILE);
if (styles) {
assertSafeCss(new TextDecoder().decode(styles));
}
}
}

View File

@ -0,0 +1,107 @@
import { createReadStream } from 'node:fs';
import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
import { Injectable } from '@nestjs/common';
import { AppConfig } from '../config/app-config.service';
/** Subfolders inside PLUGINS_DIR that are not plugin ids. */
const DROPZONE_DIR = '_dropzone';
const QUARANTINE_DIR = '_quarantine';
/**
* Filesystem layout for installed plugins (ADR 0008, issue #71):
* `<PLUGINS_DIR>/<id>/<version>/<files…>`. A Site Admin drops ZIPs into
* `<PLUGINS_DIR>/_dropzone/`; rejected drops move to `_quarantine/`. Kept behind
* this service so callers never build plugin paths themselves (and a future S3
* binding stays possible).
*/
@Injectable()
export class PluginStorageService {
constructor(private readonly config: AppConfig) {}
private get root(): string {
return resolve(this.config.env.PLUGINS_DIR);
}
versionDir(id: string, version: string): string {
return join(this.root, id, version);
}
get dropzoneDir(): string {
return join(this.root, DROPZONE_DIR);
}
get quarantineDir(): string {
return join(this.root, QUARANTINE_DIR);
}
/** Creates the dropzone/quarantine folders so the watcher can rely on them. */
async ensureServiceDirs(): Promise<void> {
await mkdir(this.dropzoneDir, { recursive: true });
await mkdir(this.quarantineDir, { recursive: true });
}
/**
* Writes a version's files atomically: fully populate a temp directory, then
* rename it into place. The version directory therefore never exists in a
* half-written state, so a client serving `/<id>/<version>/…` never sees a
* 404 for a file that is about to appear (acceptance: atomic update).
*/
async writeVersion(id: string, version: string, files: Map<string, Uint8Array>): Promise<void> {
const target = this.versionDir(id, version);
const staging = `${target}.tmp-${randomUUID()}`;
try {
for (const [relPath, bytes] of files) {
const dest = join(staging, relPath);
await mkdir(dirname(dest), { recursive: true });
await writeFile(dest, bytes);
}
// Replace any existing dir for this exact version (re-upload of same
// version) so the rename target is free.
await rm(target, { recursive: true, force: true });
await mkdir(dirname(target), { recursive: true });
await rename(staging, target);
} finally {
await rm(staging, { recursive: true, force: true });
}
}
/** Removes one obsolete version directory after an update flips the pointer. */
async removeVersion(id: string, version: string): Promise<void> {
await rm(this.versionDir(id, version), { recursive: true, force: true });
}
/** Removes a plugin's entire tree (uninstall). Idempotent. */
async removePlugin(id: string): Promise<void> {
await rm(join(this.root, id), { recursive: true, force: true });
}
/**
* Resolves an asset path inside a version directory, guarding against
* traversal outside it. Returns `null` if the request escapes the directory.
*/
assetPath(id: string, version: string, relPath: string): string | null {
const base = this.versionDir(id, version);
const full = resolve(base, relPath);
const rel = relative(base, full);
if (rel.startsWith('..') || resolve(base, rel) !== full) return null;
return full;
}
async assetExists(fullPath: string): Promise<boolean> {
try {
await access(fullPath);
return true;
} catch {
return false;
}
}
createAssetReadStream(fullPath: string): Readable {
return createReadStream(fullPath);
}
}

View File

@ -0,0 +1,106 @@
import { watch, type FSWatcher } from 'node:fs';
import { readFile, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { AppConfig } from '../config/app-config.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginPackageError } from './plugin.constants';
import { PluginsService } from './plugins.service';
/** Debounce window: an editor/copy may fire several `rename` events per file. */
const DEBOUNCE_MS = 300;
/**
* Registers plugin ZIPs a Site Admin drops into `<PLUGINS_DIR>/_dropzone/`
* (ADR 0008 lifecycle). Each dropped file runs the exact same validation as the
* GUI upload; a valid package installs and the ZIP is consumed, an invalid one
* moves to `_quarantine/` with its error logged. The watcher is inert under
* `NODE_ENV=test` tests call {@link processDropped} directly for determinism.
*/
@Injectable()
export class PluginWatcherService implements OnModuleInit, OnModuleDestroy {
private watcher: FSWatcher | undefined;
private readonly pendingTimers = new Map<string, NodeJS.Timeout>();
constructor(
private readonly plugins: PluginsService,
private readonly storage: PluginStorageService,
private readonly config: AppConfig,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PluginWatcherService.name);
}
async onModuleInit(): Promise<void> {
if (this.config.env.NODE_ENV === 'test') return;
await this.storage.ensureServiceDirs();
this.watcher = watch(this.storage.dropzoneDir, (_event, filename) => {
if (!filename || !filename.endsWith('.zip')) return;
this.schedule(filename.toString());
});
this.logger.info({ dir: this.storage.dropzoneDir }, 'watching plugin dropzone');
}
onModuleDestroy(): void {
this.watcher?.close();
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
this.pendingTimers.clear();
}
private schedule(filename: string): void {
const existing = this.pendingTimers.get(filename);
if (existing) clearTimeout(existing);
this.pendingTimers.set(
filename,
setTimeout(() => {
this.pendingTimers.delete(filename);
void this.processDropped(join(this.storage.dropzoneDir, filename));
}, DEBOUNCE_MS),
);
}
/**
* Installs one dropped ZIP. On success the file is removed from the dropzone;
* on any validation failure it is quarantined and the error logged. Returns
* the outcome so tests can assert without relying on filesystem events.
*/
async processDropped(
filePath: string,
): Promise<{ installed: true; id: string } | { installed: false; code: string }> {
let zip: Buffer;
try {
zip = await readFile(filePath);
} catch {
// The file vanished between the event and the read — nothing to do.
return { installed: false, code: 'plugin_invalid_zip' };
}
try {
const view = await this.plugins.install(zip);
// The package is now unpacked under PLUGINS_DIR; consume the dropped ZIP.
await rm(filePath, { force: true });
this.logger.info({ id: view.id, version: view.version }, 'installed plugin from dropzone');
return { installed: true, id: view.id };
} catch (error) {
const code = error instanceof PluginPackageError ? error.code : 'plugin_bad_structure';
await this.quarantine(filePath);
this.logger.warn({ code, file: filePath }, 'quarantined invalid plugin drop');
return { installed: false, code };
}
}
private async quarantine(filePath: string): Promise<void> {
const name = filePath.split('/').pop() ?? 'package.zip';
const stamp = this.clock.now().toISOString().replace(/[:.]/g, '-');
await this.storage.ensureServiceDirs();
await rename(filePath, join(this.storage.quarantineDir, `${stamp}-${name}`)).catch(
() => undefined,
);
}
}

View File

@ -0,0 +1,29 @@
import type { PluginErrorCode } from '@dorfteich/shared';
/** Compressed upload ceiling for a plugin ZIP (multer rejects larger). */
export const MAX_PLUGIN_ZIP_BYTES = 5 * 1024 * 1024; // 5 MiB
/** Total decompressed ceiling — guards against a zip bomb inflating in memory. */
export const MAX_PLUGIN_UNPACKED_BYTES = 20 * 1024 * 1024; // 20 MiB
/** The two files the package structure hinges on. */
export const MANIFEST_FILE = 'manifest.json';
export const CODE_BUNDLE_FILE = 'plugin.js';
export const STYLES_FILE = 'styles.css';
/**
* A rejected install/uninstall, carrying a stable {@link PluginErrorCode} and,
* for manifest failures, the field-level issues to surface in the API error
* body `details`. Thrown by the package/registry services; the controller maps
* it to the matching HTTP status.
*/
export class PluginPackageError extends Error {
constructor(
readonly code: PluginErrorCode,
message: string,
readonly details?: Record<string, string[]>,
) {
super(message);
this.name = 'PluginPackageError';
}
}

View File

@ -0,0 +1,215 @@
import { readdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { zipSync } from 'fflate';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginWatcherService } from './plugin-watcher.service';
const enc = (text: string) => new TextEncoder().encode(text);
function codeManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'toc',
name: 'Table of Contents',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
extensionPoints: [{ type: 'pageTool', id: 'toc', title: { de: 'Inhalt', en: 'Contents' } }],
permissions: ['readCurrentPage'],
license: 'MIT',
...overrides,
};
}
function pluginZip(manifest: Record<string, unknown>, bundle = 'export default {}'): Buffer {
return Buffer.from(
zipSync({ 'manifest.json': enc(JSON.stringify(manifest)), 'plugin.js': enc(bundle) }),
);
}
/**
* Plugin storage, install API, and directory watcher (issue #71). Exercises the
* GUI upload endpoint, the dropzone watcher, atomic updates, and the uninstall
* guards against a real database and filesystem.
*/
describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let storage: PluginStorageService;
let watcher: PluginWatcherService;
const suffix = uniqueSuffix();
const password = 'plugins installieren macht spass 1';
const admin = { username: `pam-plugins-${suffix}`, displayName: `Pam Plugins ${suffix}` };
const outsider = { username: `orin-plugins-${suffix}`, displayName: `Orin Outside ${suffix}` };
let adminCookie: string;
let outsiderCookie: string;
const api = () => request(app.getHttpServer());
async function loginOf(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
storage = app.get(PluginStorageService);
watcher = app.get(PluginWatcherService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: admin.displayName,
password,
locale: 'en',
});
const verifyToken = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminCookie = await loginOf(admin.username);
const outsiderUser = await users.createUser({
username: outsider.username,
email: `${outsider.username}@example.org`,
displayName: outsider.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(outsiderUser.id);
outsiderCookie = await loginOf(outsider.username);
});
afterAll(async () => {
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await app.close();
});
it('installs via the GUI upload and serves the bundle immutably', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest()), 'toc.zip')
.expect(201);
expect(res.body).toMatchObject({ id: 'toc', version: '1.0.0', kind: 'code', mode: 'disabled' });
const asset = await api().get('/api/v1/plugins/toc/1.0.0/plugin.js').expect(200);
expect(asset.headers['cache-control']).toContain('immutable');
expect(asset.headers['content-type']).toContain('text/javascript');
expect(asset.text).toContain('export default');
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body.map((p: { id: string }) => p.id)).toContain('toc');
});
it('forbids install for non-Site-Admins', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', outsiderCookie)
.attach('file', pluginZip(codeManifest({ id: 'nope' })), 'nope.zip')
.expect(403);
});
it('rejects a schema-invalid manifest with field details', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'Bad Id' })), 'bad.zip')
.expect(400);
expect(res.body.code).toBe('plugin_invalid_manifest');
expect(res.body.details.id).toBeTruthy();
});
it('installs a plugin dropped into the dropzone and quarantines an invalid one', async () => {
await storage.ensureServiceDirs();
const goodPath = join(storage.dropzoneDir, 'dropped-index.zip');
await writeFile(goodPath, pluginZip(codeManifest({ id: 'page-index' })));
const good = await watcher.processDropped(goodPath);
expect(good).toEqual({ installed: true, id: 'page-index' });
expect(
await storage.assetExists(join(storage.versionDir('page-index', '1.0.0'), 'plugin.js')),
).toBe(true);
const badPath = join(storage.dropzoneDir, 'dropped-broken.zip');
await writeFile(badPath, Buffer.from('not a zip'));
const bad = await watcher.processDropped(badPath);
expect(bad.installed).toBe(false);
const quarantined = await readdir(storage.quarantineDir);
expect(quarantined.some((name) => name.endsWith('dropped-broken.zip'))).toBe(true);
});
it('updates only to a higher version, atomically', async () => {
// Install a fresh plugin, then update it.
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.0.0' })), 'v1.zip')
.expect(201);
// A same/lower version is refused.
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.0.0' })), 'v1.zip')
.expect(409)
.expect((r) => expect(r.body.code).toBe('plugin_version_not_higher'));
// A higher version installs; its assets exist and the old version is gone.
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.1.0' })), 'v2.zip')
.expect(201);
await api().get('/api/v1/plugins/updatable/1.1.0/plugin.js').expect(200);
// The pointer moved, so the old version no longer resolves.
await api().get('/api/v1/plugins/updatable/1.0.0/plugin.js').expect(404);
expect(
await storage.assetExists(join(storage.versionDir('updatable', '1.0.0'), 'plugin.js')),
).toBe(false);
});
it('refuses uninstall while required, then removes files and marks it removed', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'removable' })), 'r.zip')
.expect(201);
// Required plugins cannot be uninstalled.
await prisma.plugin.update({ where: { id: 'removable' }, data: { mode: 'REQUIRED' } });
await api()
.delete('/api/v1/admin/plugins/removable')
.set('Cookie', adminCookie)
.expect(409)
.expect((r) => expect(r.body.code).toBe('plugin_required_cannot_uninstall'));
// Made optional, it uninstalls: assets vanish and metadata is tombstoned.
await prisma.plugin.update({ where: { id: 'removable' }, data: { mode: 'OPTIONAL' } });
await api().delete('/api/v1/admin/plugins/removable').set('Cookie', adminCookie).expect(204);
await api().get('/api/v1/plugins/removable/1.0.0/plugin.js').expect(404);
const record = await prisma.plugin.findUnique({ where: { id: 'removable' } });
expect(record?.removedAt).not.toBeNull();
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body.map((p: { id: string }) => p.id)).not.toContain('removable');
});
});

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { PluginAdminController } from './plugin-admin.controller';
import { PluginAssetsController } from './plugin-assets.controller';
import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginWatcherService } from './plugin-watcher.service';
import { PluginsService } from './plugins.service';
/**
* Plugin installation and serving (ADR 0008, issue #71): the ZIP package
* validator, the on-disk store under `PLUGINS_DIR`, the install registry, the
* Site Admin admin endpoints, static asset serving for the sandbox, and the
* dropzone directory watcher.
*/
@Module({
imports: [CommonModule],
controllers: [PluginAdminController, PluginAssetsController],
providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService],
exports: [PluginsService],
})
export class PluginsModule {}

View File

@ -0,0 +1,150 @@
import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client';
import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
import type { PluginInstanceMode, PluginView } 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 } from './plugin.constants';
const DB_MODE_TO_VIEW: Record<DbPluginMode, PluginInstanceMode> = {
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.
*/
@Injectable()
export class PluginsService {
constructor(
private readonly prisma: PrismaService,
private readonly packages: PluginPackageService,
private readonly storage: PluginStorageService,
private readonly clock: ClockService,
) {}
/**
* 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);
}
return this.toView(record);
}
/**
* 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);
}
/** 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(),
};
}
}

View File

@ -26,6 +26,7 @@ export async function createTestApp(
// Fresh scratch directory per test file so upload tests never touch the // Fresh scratch directory per test file so upload tests never touch the
// repository or collide with each other. // repository or collide with each other.
process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-')); process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-'));
process.env.PLUGINS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-plugins-'));
const base = Test.createTestingModule({ imports: [AppModule] }); const base = Test.createTestingModule({ imports: [AppModule] });
const moduleRef = await (customize ? customize(base) : base).compile(); const moduleRef = await (customize ? customize(base) : base).compile();

View File

@ -60,6 +60,9 @@ services:
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>} SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
# Matches the `uploads` volume mount below (ADR 0011). # Matches the `uploads` volume mount below (ADR 0011).
UPLOADS_DIR: /data/uploads UPLOADS_DIR: /data/uploads
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
# Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them.
PLUGINS_DIR: /data/plugins
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62). # Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
PANDOC_URL: http://pandoc:3030 PANDOC_URL: http://pandoc:3030
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67). # Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
@ -69,6 +72,7 @@ services:
networks: [frontend, internal] networks: [frontend, internal]
volumes: volumes:
- uploads:/data/uploads - uploads:/data/uploads
- plugins:/data/plugins
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -161,3 +165,4 @@ networks:
volumes: volumes:
db-data: db-data:
uploads: uploads:
plugins:

View File

@ -138,14 +138,21 @@ slug resolves those rows.
### `plugins` ### `plugins`
`id` (manifest id), `version`, `manifest` (jsonb), `storage_path`, `id` (manifest id, primary key), `name`, `version`, `api_version`,
`kind` (`code` / `section_style`), `instance_mode` `kind` (`code` / `section_style`), `mode`
(`disabled` / `optional` / `required`), `installed_by`, `installed_at`. (`DISABLED` / `OPTIONAL` / `REQUIRED`, the instance mode — issue #72 sets it),
`manifest` (jsonb — the full validated manifest, so serving/admin views never
re-read disk), `installed_at`, `updated_at`, `removed_at` (soft-delete
tombstone: set on uninstall, files removed, so existing `plugin_block` nodes can
still resolve the manifest fallback). Unpacked bundles live on the plugins
volume at `<PLUGINS_DIR>/<id>/<version>/`; the path is derived from id+version,
not stored (issue #71).
### `pond_plugins` ### `pond_plugins`
(`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional` (`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional`
plugins; `required` plugins are active everywhere. plugins; `required` plugins are active everywhere. Rows cascade-delete with
their pond or plugin, and are dropped when a plugin is uninstalled.
## Quotas and settings ## Quotas and settings

View File

@ -4,3 +4,4 @@ export * from './host';
export * from './manifest'; export * from './manifest';
export * from './plugin'; export * from './plugin';
export * from './rpc'; export * from './rpc';
export * from './version';

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { compareVersions, isHigherVersion } from './version';
describe('compareVersions', () => {
it('orders by major, then minor, then patch', () => {
expect(compareVersions('1.0.0', '2.0.0')).toBe(-1);
expect(compareVersions('1.2.0', '1.1.9')).toBe(1);
expect(compareVersions('1.0.10', '1.0.9')).toBe(1);
expect(compareVersions('1.2.3', '1.2.3')).toBe(0);
});
});
describe('isHigherVersion', () => {
it('is true only for a strictly greater version', () => {
expect(isHigherVersion('1.0.1', '1.0.0')).toBe(true);
expect(isHigherVersion('1.0.0', '1.0.0')).toBe(false);
expect(isHigherVersion('0.9.0', '1.0.0')).toBe(false);
});
});

View File

@ -0,0 +1,31 @@
/**
* Version comparison for plugin manifests. Versions are validated by the
* manifest schema as `MAJOR.MINOR.PATCH`, so a numeric three-part compare is
* exact no pre-release/build metadata to reason about in v1. The install flow
* (#71) uses this to accept an update only when the uploaded version is higher
* than the installed one.
*/
/** Splits a validated `x.y.z` string into its three numeric parts. */
function parts(version: string): [number, number, number] {
const [major = 0, minor = 0, patch = 0] = version.split('.').map((n) => Number.parseInt(n, 10));
return [major, minor, patch];
}
/** Returns -1 if `a` < `b`, 1 if `a` > `b`, 0 if equal. */
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const pa = parts(a);
const pb = parts(b);
for (let i = 0; i < 3; i += 1) {
const left = pa[i] ?? 0;
const right = pb[i] ?? 0;
if (left < right) return -1;
if (left > right) return 1;
}
return 0;
}
/** Whether `candidate` is a strictly higher version than `current`. */
export function isHigherVersion(candidate: string, current: string): boolean {
return compareVersions(candidate, current) > 0;
}

View File

@ -39,6 +39,17 @@
"import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.", "import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
"network": "Der Server war nicht erreichbar.", "network": "Der Server war nicht erreichbar.",
"grant_exists": "Diese Berechtigung existiert bereits.", "grant_exists": "Diese Berechtigung existiert bereits.",
"plugin_invalid_zip": "Die hochgeladene Datei ist kein gültiges ZIP-Archiv.",
"plugin_bad_structure": "Das Plugin-Paket hat eine ungültige Struktur.",
"plugin_invalid_manifest": "Das Plugin-Manifest ist ungültig.",
"plugin_api_incompatible": "Dieses Plugin benötigt eine nicht unterstützte Plugin-API-Version.",
"plugin_too_large": "Das Plugin-Paket ist zu groß (Limit: {{limitBytes}} Bytes).",
"plugin_missing_bundle": "Ein Code-Plugin muss ein plugin.js-Bundle enthalten.",
"plugin_missing_styles": "Ein Style-Plugin muss eine styles.css-Datei enthalten.",
"plugin_css_unsafe": "Das Plugin-Stylesheet verwendet nicht erlaubte Konstrukte.",
"plugin_version_not_higher": "Ein Update muss eine höhere Version als das installierte haben.",
"plugin_required_cannot_uninstall": "Ein erforderliches Plugin kann nicht deinstalliert werden.",
"plugin_not_found": "Dieses Plugin ist nicht installiert.",
"grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.", "grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.",
"grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.", "grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.",
"grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.", "grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",

View File

@ -39,6 +39,17 @@
"import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.", "import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
"network": "The server could not be reached.", "network": "The server could not be reached.",
"grant_exists": "This grant already exists.", "grant_exists": "This grant already exists.",
"plugin_invalid_zip": "The uploaded file is not a valid ZIP archive.",
"plugin_bad_structure": "The plugin package has an invalid structure.",
"plugin_invalid_manifest": "The plugin manifest is invalid.",
"plugin_api_incompatible": "This plugin targets an unsupported plugin API version.",
"plugin_too_large": "The plugin package is too large (limit: {{limitBytes}} bytes).",
"plugin_missing_bundle": "A code plugin must include a plugin.js bundle.",
"plugin_missing_styles": "A style plugin must include a styles.css file.",
"plugin_css_unsafe": "The plugin stylesheet uses constructs that are not allowed.",
"plugin_version_not_higher": "An update must have a higher version than the installed one.",
"plugin_required_cannot_uninstall": "A required plugin cannot be uninstalled.",
"plugin_not_found": "This plugin is not installed.",
"grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.", "grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.",
"grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.", "grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.",
"grant_subject_id_mismatch": "The grant's subject is inconsistent.", "grant_subject_id_mismatch": "The grant's subject is inconsistent.",

View File

@ -82,6 +82,14 @@ export const apiEnvSchema = z.object({
* dev/test runs point this at the web app's built `public/fonts`. * dev/test runs point this at the web app's built `public/fonts`.
*/ */
FONTS_DIR: z.string().min(1).default('./fonts'), FONTS_DIR: z.string().min(1).default('./fonts'),
/**
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
* loads, plus a `_dropzone/` a Site Admin drops ZIPs into and a
* `_quarantine/` for rejected drops. In Docker a persistent volume mounts
* here; the relative default serves native dev/test runs.
*/
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
}); });
export type ApiEnv = z.infer<typeof apiEnvSchema>; export type ApiEnv = z.infer<typeof apiEnvSchema>;

View File

@ -14,6 +14,7 @@ export * from './links';
export * from './members'; export * from './members';
export * from './pages'; export * from './pages';
export * from './permissions'; export * from './permissions';
export * from './plugins';
export * from './search'; export * from './search';
export * from './ponds'; export * from './ponds';
export * from './quotas'; export * from './quotas';

View File

@ -0,0 +1,59 @@
/**
* Plugin administration types shared between api and web (ADR 0008, issue #71).
* The manifest itself lives in `@dorfteich/plugin-sdk`; these types describe an
* *installed* plugin as the instance stores and surfaces it.
*/
/**
* Instance-level activation a Site Admin sets per plugin (ADR 0008 lifecycle):
* `disabled` installed but inert; `optional` available, Pond Admins toggle
* it per pond; `required` always on everywhere and cannot be uninstalled.
*/
export const PLUGIN_INSTANCE_MODES = ['disabled', 'optional', 'required'] as const;
export type PluginInstanceMode = (typeof PLUGIN_INSTANCE_MODES)[number];
/** One extension point as surfaced to admins (mirrors the manifest entry). */
export interface PluginExtensionPointView {
type: string;
id: string;
title: Record<string, string>;
}
/** An installed plugin as returned by the admin API. */
export interface PluginView {
id: string;
name: string;
version: string;
apiVersion: string;
kind: string;
mode: PluginInstanceMode;
/** Capabilities the manifest declared, shown to the Site Admin at install. */
permissions: string[];
extensionPoints: PluginExtensionPointView[];
/** Base path the sandbox loads assets from: `/plugins/<id>/<version>/`. */
assetBasePath: string;
license: string;
homepage?: string;
installedAt: string;
updatedAt: string;
}
/**
* Machine-readable rejection codes for an install/uninstall attempt. Each is
* also an `errors.<code>` i18n key. `validateManifest` field issues travel in
* the ApiErrorBody `details`.
*/
export const PLUGIN_ERROR_CODES = [
'plugin_invalid_zip',
'plugin_bad_structure',
'plugin_invalid_manifest',
'plugin_api_incompatible',
'plugin_too_large',
'plugin_missing_bundle',
'plugin_missing_styles',
'plugin_css_unsafe',
'plugin_version_not_higher',
'plugin_required_cannot_uninstall',
'plugin_not_found',
] as const;
export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];

9
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
apps/api: apps/api:
dependencies: dependencies:
'@dorfteich/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
'@dorfteich/shared': '@dorfteich/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
@ -56,6 +59,9 @@ importers:
dompurify: dompurify:
specifier: ^3.4.11 specifier: ^3.4.11
version: 3.4.11 version: 3.4.11
fflate:
specifier: ^0.8.3
version: 0.8.3
fractional-indexing: fractional-indexing:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
@ -141,9 +147,6 @@ importers:
'@types/supertest': '@types/supertest':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.3 version: 6.0.3
fflate:
specifier: ^0.8.3
version: 0.8.3
pdf-parse: pdf-parse:
specifier: ^2.4.5 specifier: ^2.4.5
version: 2.4.5 version: 2.4.5