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
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:
parent
ec6ca80c4d
commit
621aa47244
@ -16,6 +16,7 @@
|
||||
"search:reindex": "tsx src/search/reindex.cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dorfteich/plugin-sdk": "workspace:*",
|
||||
"@dorfteich/shared": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
@ -25,6 +26,7 @@
|
||||
"argon2": "^0.44.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dompurify": "^3.4.11",
|
||||
"fflate": "^0.8.3",
|
||||
"fractional-indexing": "^4.0.0",
|
||||
"i18next": "^26.3.4",
|
||||
"jsdom": "^26.1.0",
|
||||
@ -55,7 +57,6 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"fflate": "^0.8.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
|
||||
@ -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;
|
||||
@ -82,6 +82,7 @@ model Pond {
|
||||
labels Label[]
|
||||
grants RoleGrant[]
|
||||
conversionJobs ConversionJob[]
|
||||
pondPlugins PondPlugin[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("ponds")
|
||||
@ -571,3 +572,53 @@ model ConversionJob {
|
||||
@@index([status, createdAt])
|
||||
@@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")
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import { MailModule } from './mail/mail.module';
|
||||
import { MembersModule } from './members/members.module';
|
||||
import { PagesModule } from './pages/pages.module';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PluginsModule } from './plugins/plugins.module';
|
||||
import { PondsModule } from './ponds/ponds.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
@ -50,6 +51,7 @@ import { VersionsModule } from './versions/versions.module';
|
||||
MembersModule,
|
||||
PublicModule,
|
||||
ImportExportModule,
|
||||
PluginsModule,
|
||||
AuthModule,
|
||||
AdminModule,
|
||||
LoggerModule.forRootAsync({
|
||||
|
||||
83
apps/api/src/plugins/plugin-admin.controller.ts
Normal file
83
apps/api/src/plugins/plugin-admin.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
apps/api/src/plugins/plugin-assets.controller.ts
Normal file
76
apps/api/src/plugins/plugin-assets.controller.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
57
apps/api/src/plugins/plugin-css.ts
Normal file
57
apps/api/src/plugins/plugin-css.ts
Normal 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()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
apps/api/src/plugins/plugin-package.service.test.ts
Normal file
134
apps/api/src/plugins/plugin-package.service.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
124
apps/api/src/plugins/plugin-package.service.ts
Normal file
124
apps/api/src/plugins/plugin-package.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
107
apps/api/src/plugins/plugin-storage.service.ts
Normal file
107
apps/api/src/plugins/plugin-storage.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
106
apps/api/src/plugins/plugin-watcher.service.ts
Normal file
106
apps/api/src/plugins/plugin-watcher.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
29
apps/api/src/plugins/plugin.constants.ts
Normal file
29
apps/api/src/plugins/plugin.constants.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
215
apps/api/src/plugins/plugins.e2e.db.test.ts
Normal file
215
apps/api/src/plugins/plugins.e2e.db.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
24
apps/api/src/plugins/plugins.module.ts
Normal file
24
apps/api/src/plugins/plugins.module.ts
Normal 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 {}
|
||||
150
apps/api/src/plugins/plugins.service.ts
Normal file
150
apps/api/src/plugins/plugins.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ export async function createTestApp(
|
||||
// Fresh scratch directory per test file so upload tests never touch the
|
||||
// repository or collide with each other.
|
||||
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 moduleRef = await (customize ? customize(base) : base).compile();
|
||||
|
||||
@ -60,6 +60,9 @@ services:
|
||||
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
||||
# Matches the `uploads` volume mount below (ADR 0011).
|
||||
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).
|
||||
PANDOC_URL: http://pandoc:3030
|
||||
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
|
||||
@ -69,6 +72,7 @@ services:
|
||||
networks: [frontend, internal]
|
||||
volumes:
|
||||
- uploads:/data/uploads
|
||||
- plugins:/data/plugins
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@ -161,3 +165,4 @@ networks:
|
||||
volumes:
|
||||
db-data:
|
||||
uploads:
|
||||
plugins:
|
||||
|
||||
@ -138,14 +138,21 @@ slug resolves those rows.
|
||||
|
||||
### `plugins`
|
||||
|
||||
`id` (manifest id), `version`, `manifest` (jsonb), `storage_path`,
|
||||
`kind` (`code` / `section_style`), `instance_mode`
|
||||
(`disabled` / `optional` / `required`), `installed_by`, `installed_at`.
|
||||
`id` (manifest id, primary key), `name`, `version`, `api_version`,
|
||||
`kind` (`code` / `section_style`), `mode`
|
||||
(`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_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
|
||||
|
||||
|
||||
@ -4,3 +4,4 @@ export * from './host';
|
||||
export * from './manifest';
|
||||
export * from './plugin';
|
||||
export * from './rpc';
|
||||
export * from './version';
|
||||
|
||||
20
packages/plugin-sdk/src/version.test.ts
Normal file
20
packages/plugin-sdk/src/version.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
packages/plugin-sdk/src/version.ts
Normal file
31
packages/plugin-sdk/src/version.ts
Normal 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;
|
||||
}
|
||||
@ -39,6 +39,17 @@
|
||||
"import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
|
||||
"network": "Der Server war nicht erreichbar.",
|
||||
"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_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.",
|
||||
"grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",
|
||||
|
||||
@ -39,6 +39,17 @@
|
||||
"import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
|
||||
"network": "The server could not be reached.",
|
||||
"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_personal_pond": "A personal pond's only administrator is its owner.",
|
||||
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
||||
|
||||
@ -82,6 +82,14 @@ export const apiEnvSchema = z.object({
|
||||
* dev/test runs point this at the web app's built `public/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>;
|
||||
|
||||
@ -14,6 +14,7 @@ export * from './links';
|
||||
export * from './members';
|
||||
export * from './pages';
|
||||
export * from './permissions';
|
||||
export * from './plugins';
|
||||
export * from './search';
|
||||
export * from './ponds';
|
||||
export * from './quotas';
|
||||
|
||||
59
packages/shared/src/plugins.ts
Normal file
59
packages/shared/src/plugins.ts
Normal 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
9
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@dorfteich/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
'@dorfteich/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@ -56,6 +59,9 @@ importers:
|
||||
dompurify:
|
||||
specifier: ^3.4.11
|
||||
version: 3.4.11
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
fractional-indexing:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
@ -141,9 +147,6 @@ importers:
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
|
||||
Loading…
Reference in New Issue
Block a user