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 { 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 { // 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; 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 { 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(); } }