Files
TREK/server/src/nest/plugins/plugins.controller.ts
T

410 lines
19 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, MissingRequiredSettingError } 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';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import type { PluginActionResult, PluginInstanceConfigResponse, PluginInstanceConfigUpdated } from '@trek/shared';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
// Straight from sessionManager, not the src/mcp barrel: that one evaluates
// readEnv().mcp at module scope and installs the sweep interval, which a domain
// module must not drag into every test that mocks app-config partially.
import { invalidateMcpSessions } from '../../mcp/sessionManager';
/**
* 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,
private readonly env: RuntimeEnvService,
) {}
@Get()
async list() {
return await 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: PluginInstallDto) {
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.
// Dependency resolution pins versions internally but never DELIBERATELY, so
// only the plain path below recomputes the update hold.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
return res;
} catch (e) {
throw registryFailure(e, 'install failed');
}
}
/** Sideload a plugin from an uploaded .zip/.tar.gz (registers INACTIVE). */
@ManagedForbidden(
'a sideloaded archive skips the signature check every registry install performs',
{ enforcedInHandler: true },
)
@Post('upload')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 + 4096 } }))
async upload(@UploadedFile() file?: Express.Multer.File) {
// In the handler, not the guard: guards run before the multipart parser and
// the client would get an ECONNRESET rather than this 403 (PROFILE-015).
if (isManagedBlocked(this.env)) {
throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403);
}
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.
*/
@ManagedForbidden('a linked directory skips the signature check the registry install performs')
@Post('link')
@HttpCode(200)
async link(@Body() body: PluginLinkDto) {
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')
async getConfig(@Param('id') id: string): Promise<PluginInstanceConfigResponse> {
return {
fields: await this.plugins.instanceSettingsFields(id),
config: await this.plugins.getInstanceConfig(id),
actions: await this.runtime.actionsOf(id, 'instance'),
};
}
/**
* Save the admin-owned `scope:'instance'` settings. A RUNNING plugin gets its config
* once, in the child's init envelope — so a save re-spawns it (like the egress-hosts
* PUT), and `restarted` tells the UI whether that happened. A respawn that fails
* answers 409 `{ error, code: 'RESTART_FAILED', config }`: the save itself landed,
* so the envelope still carries the stored config.
*/
@Put(':id/config')
async updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto): Promise<PluginInstanceConfigUpdated> {
// Same gate as the egress-hosts twin: the respawn below is a spawn, and the kill
// switch is read live, so a save must not start a child while plugins are off.
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
let config: Record<string, unknown>;
try {
config = await this.plugins.updateInstanceConfig(id, body || {});
} catch (e) {
if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400);
throw e;
}
try {
return { config, restarted: await this.runtime.respawnIfActive(id) };
} catch (e) {
// The config IS written by this point, only bringing the child back up failed
// (a widened permission set awaiting re-consent, a dependency that went away).
// Reporting that as a failed save would be a lie, so the envelope carries the
// saved config and names the restart as the part that broke. `disable()` leaves
// the row enabled while no child runs, so record the plugin as off: the enable
// toggle is where the real reason is offered as a decision the admin can take.
await this.runtime.deactivate(id).catch(() => {});
const reason = e instanceof Error ? e.message : 'restart failed';
throw new HttpException(
{ error: `Settings saved, but ${id} could not be restarted: ${reason}`, code: 'RESTART_FAILED', config },
409,
);
}
}
/**
* Run one of the plugin's `scope:'instance'` actions ("Purge cache", "Test SMTP").
* ADMIN-INITIATED: the acting user is the clicking admin, bound host-side, so the
* handler sees `ctx.config` plus the admin's own settings and any trip read is checked
* against them. The scope is fixed by this route — a user-tab key is refused here.
*
* Returns the SAME 404 `{ error: 'Plugin is not active' }` body as
* PluginUserSettingsController.runAction, and a failing action is a RESULT, not a
* server error, on both — but the two routes reach 404 by checking DIFFERENT things.
* This route asks `runtime.isActive(id)`, the supervisor's live-process check: is
* there actually a forked child to run the action in. The user route instead reads
* the plugin's DB `status` column. They can disagree — a plugin can be `status =
* 'active'` in the DB with no live child (e.g. it crashed and hasn't been
* respawned yet) — so a 404 here does not always mean the row says inactive; it can
* also mean a stale-active row with nothing behind it. The client (`useInstanceSettings`)
* treats this 404 as authoritative and flips its own `active` flag to match.
*/
@Post(':id/actions/:key')
@HttpCode(200)
async runAction(
@Param('id') id: string,
@Param('key') key: string,
@Req() req: Request & { user?: { id: number } },
): Promise<PluginActionResult> {
const adminId = req.user?.id;
if (!pluginsEnabled() || adminId == null || !this.runtime.isActive(id)) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
return await this.runtime.invokeAction(id, key, adminId, 'instance');
} catch (e) {
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
}
}
/**
* 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')
async egressHosts(@Param('id') id: string) {
return { supported: await this.runtime.wantsOperatorEgress(id), hosts: await this.runtime.operatorEgressHosts(id) };
}
@Put(':id/egress-hosts')
async setEgressHosts(@Param('id') id: string, @Body() body: PluginEgressHostsDto) {
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: PluginActivateDto) {
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);
}
invalidateMcpSessions();
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);
invalidateMcpSessions();
return { status: 'inactive' };
}
/** DEV-ONLY: re-fork a dev-linked plugin so it picks up rebuilt code. */
@ManagedForbidden('reloading from disk reintroduces whatever a sideload put there')
@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, @Body() body?: PluginUpdateDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
// An explicit version is the rollback path: install exactly what the admin picked
// (the TREK-compat gate still refuses in selectVersion). Absent, the runtime
// resolves the newest compatible version itself.
const res = await this.runtime.update(id, { version: body?.version });
// A deliberate non-latest pick holds future updates; landing on the newest
// (any path) releases a stale hold. Only after success — a failed update
// changed nothing and must not touch the flag.
await this.registry.recomputeUpdateHold(id, res.version, !!body?.version);
invalidateMcpSessions();
return res;
} catch (e) {
throw registryFailure(e, 'update failed');
}
}
/** Release a per-plugin update hold (set by a deliberate non-latest install). */
@Post(':id/resume-updates')
@HttpCode(200)
async resumeUpdates(@Param('id') id: string) {
if (!(await this.plugins.resumeUpdates(id))) throw new HttpException({ error: `plugin ${id} not found` }, 404);
return { updateHold: false };
}
/**
* 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: PluginRetrustDto,
@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 {
const res = await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
invalidateMcpSessions();
return res;
} catch (e) {
throw registryFailure(e, 'retrust failed');
}
}
@Post(':id/uninstall')
@HttpCode(200)
async uninstall(@Param('id') id: string, @Body() body: PluginUninstallDto) {
await this.runtime.uninstall(id, !!body?.deleteData);
invalidateMcpSessions();
return { status: 'uninstalled' };
}
@Get(':id/errors')
async errors(@Param('id') id: string) {
return { errors: await this.plugins.errors(id) };
}
@Get(':id/audit')
async audit(@Param('id') id: string) {
return { audit: await this.plugins.auditLog(id) };
}
@Get(':id/budget')
async budget(@Param('id') id: string) {
return { budget: await 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')
async clearErrors(@Param('id') id: string) {
await this.plugins.clearErrors(id);
return { ok: true };
}
@Post('rescan')
@HttpCode(200)
async rescan() {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
return await this.runtime.rescan();
}
}