mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
410 lines
19 KiB
TypeScript
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();
|
|
}
|
|
}
|