import type { IncomingMessage, ServerResponse } from 'node:http'; import { ProjectPluginServiceError } from '../../coding-plugins/project-service'; import { CodingProjectServiceError } from '../../coding-projects/project-service'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { CodingProjectPluginServiceError } from '../coding-product-services'; const PLUGIN_ROUTE = /^\/api\/coding\/plugins\/([^/]+)$/u; const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; class CodingPluginRouteError extends Error { constructor( readonly status: 400 | 404 | 503, readonly code: string, message: string, ) { super(message); this.name = 'CodingPluginRouteError'; } } function requestError(message: string): never { throw new CodingPluginRouteError(400, 'plugin_request_invalid', message); } function exactProjectId(url: URL): string { if ([...url.searchParams.keys()].some((key) => key !== 'projectId') || url.searchParams.getAll('projectId').length !== 1) { return requestError('GET requires exactly one projectId query parameter'); } const projectId = url.searchParams.get('projectId')?.trim() ?? ''; if (!projectId || projectId.length > 128) return requestError('projectId is invalid'); return projectId; } function exactPutBody(value: unknown): { projectId: string; enabled: boolean } { if (!value || typeof value !== 'object' || Array.isArray(value)) { return requestError('Request body must be an object'); } const body = value as Record; const keys = Object.keys(body).sort(); if (keys.length !== 2 || keys[0] !== 'enabled' || keys[1] !== 'projectId') { return requestError('Request body must contain only projectId and enabled'); } if (typeof body.projectId !== 'string' || !body.projectId.trim() || body.projectId.length > 128) { return requestError('projectId is invalid'); } if (typeof body.enabled !== 'boolean') return requestError('enabled is invalid'); return { projectId: body.projectId.trim(), enabled: body.enabled }; } function routePluginId(value: string): string { let id: string; try { id = decodeURIComponent(value).trim(); } catch { return requestError('plugin id is invalid'); } if (!PLUGIN_ID_PATTERN.test(id)) return requestError('plugin id is invalid'); return id; } function sendError(res: ServerResponse, error: unknown): void { if (error instanceof CodingPluginRouteError || error instanceof CodingProjectPluginServiceError) { sendJson(res, error.status, { success: false, code: error.code, error: error.message }); return; } if (error instanceof CodingProjectServiceError) { sendJson(res, error.status, { success: false, code: error.code, error: error.message }); return; } if (error instanceof ProjectPluginServiceError) { const status = error.code === 'CODING_PLUGIN_UNKNOWN' ? 404 : error.code === 'CODING_PLUGIN_SELECTION_WRITE_FAILED' ? 500 : 400; const code = error.code === 'CODING_PLUGIN_UNKNOWN' ? 'plugin_not_found' : error.code === 'CODING_PLUGIN_SELECTION_WRITE_FAILED' ? 'plugin_selection_write_failed' : 'plugin_request_invalid'; sendJson(res, status, { success: false, code, error: error.message }); return; } if (error instanceof SyntaxError) { sendJson(res, 400, { success: false, code: 'plugin_request_invalid', error: 'Request body is invalid' }); return; } sendJson(res, 503, { success: false, code: 'plugin_backend_unavailable', error: 'Plugin service is temporarily unavailable', }); } export async function handleCodingPluginRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (url.pathname !== '/api/coding/plugins' && !PLUGIN_ROUTE.test(url.pathname)) return false; const plugins = ctx.codingProducts?.plugins; if (!plugins) { sendJson(res, 503, { success: false, code: 'plugin_backend_unavailable', error: 'Plugin service is temporarily unavailable', }); return true; } try { if (url.pathname === '/api/coding/plugins' && req.method === 'GET') { sendJson(res, 200, await plugins.list(exactProjectId(url))); return true; } const match = PLUGIN_ROUTE.exec(url.pathname); if (match && req.method === 'PUT') { if (url.search) requestError('PUT does not accept query parameters'); const body = exactPutBody(await parseJsonBody(req)); sendJson(res, 200, await plugins.setEnabled( body.projectId, routePluginId(match[1]), body.enabled, )); return true; } return false; } catch (error) { sendError(res, error); return true; } }