Files
TREK/server/src/nest/plugins/plugins.controller.ts
T
2026-07-27 22:09:08 +02:00

293 lines
12 KiB
TypeScript

import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request } from 'express';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
import { DependencyCycleError } from './dependencies';
import { PluginRegistryService, RegistryError } from './registry/registry.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
/**
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
*
* The code is the whole point: the admin UI offers the re-trust override only for
* SIGNATURE_KEY_CHANGED, and it must read that from a field, not by string-matching the
* message. Drop the code here and the client is left guessing from prose, which sooner
* or later means offering "re-trust" on a signature that simply doesn't verify.
* TrekExceptionFilter passes a `{ error, code }` body through verbatim.
*
* A missing plugin is a 404, not a 400 — the request was well-formed, the thing just
* isn't there. Only `assertRetrustable` raises NOT_FOUND, so this is scoped to /retrust:
* install's "not in registry" carries no code and update throws a plain Error, and both
* keep their existing 400.
*/
function registryFailure(e: unknown, fallback: string): HttpException {
const code = e instanceof RegistryError ? e.code : undefined;
const error = e instanceof Error ? e.message : fallback;
return new HttpException(code ? { error, code } : { error }, code === 'NOT_FOUND' ? 404 : 400);
}
/**
* /api/admin/plugins — admin-only plugin control surface (#plugins).
*
* M0: read-only listing + the runtime-enabled flag.
* M2: activate / deactivate (spawns/kills the isolated child) + instance config.
* Admin-gated like the rest of /api/admin. The proxy namespace /api/plugins/:id
* is a separate controller.
*/
@Controller('api/admin/plugins')
@UseGuards(JwtAuthGuard, AdminGuard)
export class PluginsController {
constructor(
private readonly plugins: PluginsService,
private readonly runtime: PluginRuntimeService,
private readonly registry: PluginRegistryService,
) {}
@Get()
list() {
return this.plugins.list();
}
@Get('registry')
browse(@Query('refresh') refresh?: string) {
return this.registry.browse(refresh === '1' || refresh === 'true');
}
@Get('registry/:id')
async registryDetail(@Param('id') id: string) {
try {
return await this.registry.detail(id);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'not found' }, 404);
}
}
@Post('install')
@HttpCode(200)
async install(@Body() body: { id?: string; version?: string; constraint?: string; withDependencies?: boolean }) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
try {
// withDependencies (used by the "resolve missing dependency" admin flow) pulls
// the target + its transitive plugin deps, resolving each to its latest
// compatible version and reporting addons the admin still has to enable.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
} catch (e) {
throw registryFailure(e, 'install failed');
}
}
/** Sideload a plugin from an uploaded .zip/.tar.gz (registers INACTIVE). */
@Post('upload')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 + 4096 } }))
async upload(@UploadedFile() file?: Express.Multer.File) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!file?.buffer?.length) throw new HttpException({ error: 'no file uploaded' }, 400);
try {
return await this.runtime.sideload(file.buffer);
} catch (e) {
throw registryFailure(e, 'upload failed'); // carries TREK_VERSION_INCOMPATIBLE
}
}
/**
* DEV-ONLY: register a plugin from a LOCAL built directory and hot-reload it
* against real data. Gated by TREK_PLUGINS_DEV_LINK on top of admin + kill-switch.
*/
@Post('link')
@HttpCode(200)
async link(@Body() body: { path?: string }) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403);
const dir = body?.path?.trim();
if (!dir) throw new HttpException({ error: 'path is required' }, 400);
try {
return await this.runtime.link(dir);
} catch (e) {
throw registryFailure(e, 'link failed'); // carries TREK_VERSION_INCOMPATIBLE
}
}
@Get(':id/config')
getConfig(@Param('id') id: string) {
return { config: this.plugins.getInstanceConfig(id) };
}
@Put(':id/config')
updateConfig(@Param('id') id: string, @Body() body: Record<string, unknown>) {
return { config: this.plugins.updateInstanceConfig(id, body || {}) };
}
/**
* Operator-supplied egress hosts. A plugin that talks to a SELF-HOSTED service can't
* name the operator's hostname in its manifest, so an admin adds it here — and the
* runtime re-spawns the plugin with the widened allow-list. Admin-only (this controller
* is admin-guarded): an end user can never widen a plugin's egress.
*/
@Get(':id/egress-hosts')
egressHosts(@Param('id') id: string) {
return { supported: this.runtime.wantsOperatorEgress(id), hosts: this.runtime.operatorEgressHosts(id) };
}
@Put(':id/egress-hosts')
async setEgressHosts(@Param('id') id: string, @Body() body: { hosts?: unknown } = {}) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
const hosts = Array.isArray(body.hosts) ? body.hosts.map(String) : [];
try {
return { hosts: await this.runtime.setOperatorEgressHosts(id, hosts) };
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'Invalid hosts' }, 400);
}
}
@Post(':id/activate')
@HttpCode(200)
async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
await this.runtime.activate(id, !!body?.consent);
} catch (e) {
// Re-enabling a plugin whose update widened its permissions must NOT grant
// them silently — surface a distinct code so the UI opens the consent dialog.
if (e instanceof PluginConsentRequired) {
throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409);
}
// Unmet dependency (disabled addon / missing / version-mismatched plugin) —
// the UI offers the right fix (enable addon, or download the dependency).
if (e instanceof PluginDependencyError) {
throw new HttpException({ error: e.message, code: e.code, ...e.detail }, 409);
}
if (e instanceof DependencyCycleError) {
throw new HttpException({ error: e.message, code: 'DEPENDENCY_CYCLE', cyclePath: e.cyclePath }, 409);
}
throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400);
}
return { status: this.runtime.isActive(id) ? 'active' : 'error' };
}
@Post(':id/deactivate')
@HttpCode(200)
async deactivate(@Param('id') id: string) {
// Cascade: disabling a plugin also disables everything that depends on it (a
// dependent can't run without its dependency). The client refresh reflects it.
await this.runtime.deactivateWithDependents(id);
return { status: 'inactive' };
}
/** DEV-ONLY: re-fork a dev-linked plugin so it picks up rebuilt code. */
@Post(':id/reload')
@HttpCode(200)
async reload(@Param('id') id: string) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403);
try {
await this.runtime.reload(id);
} catch (e) {
// A rebuilt manifest that widened permissions must still re-consent, exactly
// like activate — surface the same codes so the admin UI reacts identically.
if (e instanceof PluginConsentRequired) {
throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409);
}
if (e instanceof PluginDependencyError) {
throw new HttpException({ error: e.message, code: e.code, ...e.detail }, 409);
}
throw new HttpException({ error: e instanceof Error ? e.message : 'reload failed' }, 400);
}
return { status: this.runtime.isActive(id) ? 'active' : 'inactive' };
}
@Post(':id/update')
@HttpCode(200)
async update(@Param('id') id: string) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
return await this.runtime.update(id);
} catch (e) {
throw registryFailure(e, 'update failed');
}
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
*
* This is the ONLY override of a signature refusal that exists, and it exists only for
* a changed key: a rotation has a benign explanation, a signature that doesn't verify
* does not. The scoping is enforced in the service (`assertRetrustable`), not by this
* route and not by the UI — calling this directly on a plugin with an INVALID signature
* is refused.
*
* `publicKey` is the full key the admin was SHOWN, echoed back so the server can refuse
* if the registry entry has been re-keyed again since the dialog rendered.
*/
@Post(':id/retrust')
@HttpCode(200)
async retrust(
@Param('id') id: string,
@Body() body: { version?: string; publicKey?: string },
@CurrentUser() user: { id: number },
@Req() req: Request,
) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.version) throw new HttpException({ error: 'version is required' }, 400);
if (!body?.publicKey) throw new HttpException({ error: 'publicKey is required' }, 400);
try {
return await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
} catch (e) {
throw registryFailure(e, 'retrust failed');
}
}
@Post(':id/uninstall')
@HttpCode(200)
async uninstall(@Param('id') id: string, @Body() body: { deleteData?: boolean }) {
await this.runtime.uninstall(id, !!body?.deleteData);
return { status: 'uninstalled' };
}
@Get(':id/errors')
errors(@Param('id') id: string) {
return { errors: this.plugins.errors(id) };
}
@Get(':id/audit')
audit(@Param('id') id: string) {
return { audit: this.plugins.auditLog(id) };
}
@Get(':id/budget')
budget(@Param('id') id: string) {
return { budget: this.plugins.budget(id) };
}
/** GDPR portability: aggregate everything the installed plugins hold about one
* user, for an admin fulfilling a data-access request. Literal-prefixed path, so it
* never collides with the :id routes. */
@Get('user-data/:userId/export')
async exportUserData(@Param('userId') userId: string) {
const id = Number(userId);
if (!Number.isInteger(id) || id <= 0) throw new HttpException({ error: 'invalid user id' }, 400);
return { userId: id, plugins: await this.runtime.exportUserData(id) };
}
@Delete(':id/errors')
clearErrors(@Param('id') id: string) {
this.plugins.clearErrors(id);
return { ok: true };
}
@Post('rescan')
@HttpCode(200)
rescan() {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
return this.runtime.rescan();
}
}