Files
makelore/electron/api/routes/coding-plugins.ts

131 lines
4.7 KiB
TypeScript

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<string, unknown>;
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<boolean> {
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<unknown>(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;
}
}