import type { IncomingMessage, ServerResponse } from 'node:http'; import { MarketplaceClientError, type CatalogQuery, } from '../../coding-plugins/marketplace-client'; import { PluginPackageStoreError } from '../../coding-plugins/package-store'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import type { CodingPluginMarketplaceService } from '../coding-product-services'; const ROOT = '/api/coding/plugin-marketplace'; const DETAIL = /^\/api\/coding\/plugin-marketplace\/plugins\/([^/]+)$/u; const LIBRARY_MUTATION = /^\/api\/coding\/plugin-marketplace\/library\/([^/]+)$/u; const INSTALLATION = /^\/api\/coding\/plugin-marketplace\/install\/([^/]+)$/u; const BETA_INSTALLATION = /^\/api\/coding\/plugin-marketplace\/install\/([^/]+)\/beta$/u; const UPDATE = /^\/api\/coding\/plugin-marketplace\/update\/([^/]+)$/u; const PLUGIN_ID = /^[a-z][a-z0-9.-]{0,127}$/u; const BOUNDED_MARKETPLACE_CODES = new Set([ 'plugin_auth_required', 'plugin_account_changed', 'plugin_library_required', 'plugin_release_not_ready', 'plugin_release_yanked', 'plugin_incompatible_client', 'plugin_signature_invalid', 'plugin_artifact_invalid', 'plugin_runtime_suspended', 'plugin_backend_unavailable', ]); class PluginMarketplaceRouteError extends Error { constructor( readonly status: 400 | 401 | 404 | 409 | 422 | 503, readonly code: string, message: string, ) { super(message); this.name = 'PluginMarketplaceRouteError'; } } function invalid(message: string): never { throw new PluginMarketplaceRouteError(400, 'plugin_request_invalid', message); } function routePluginId(value: string): string { let decoded: string; try { decoded = decodeURIComponent(value).trim(); } catch { return invalid('plugin id is invalid'); } if (!PLUGIN_ID.test(decoded)) return invalid('plugin id is invalid'); return decoded; } function exactNoQuery(url: URL): void { if (url.search) invalid('This Marketplace route does not accept query parameters'); } function catalogQuery(url: URL): CatalogQuery { const allowed = new Set(['query', 'category', 'featured', 'limit', 'cursor']); for (const key of url.searchParams.keys()) { if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1) { invalid('Catalog query is invalid'); } } const rawFeatured = url.searchParams.get('featured'); if (rawFeatured !== null && rawFeatured !== 'true' && rawFeatured !== 'false') { invalid('featured is invalid'); } const rawLimit = url.searchParams.get('limit'); const limit = rawLimit === null ? undefined : Number(rawLimit); if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) { invalid('limit is invalid'); } const text = (key: string, maximum: number): string | undefined => { const value = url.searchParams.get(key); if (value === null) return undefined; const normalized = value.trim(); if (!normalized || normalized.length > maximum) invalid(`${key} is invalid`); return normalized; }; return { ...(text('query', 120) === undefined ? {} : { query: text('query', 120) }), ...(text('category', 128) === undefined ? {} : { category: text('category', 128) }), ...(rawFeatured === null ? {} : { featured: rawFeatured === 'true' }), ...(limit === undefined ? {} : { limit }), ...(text('cursor', 1024) === undefined ? {} : { cursor: text('cursor', 1024) }), }; } async function exactEmptyBody(req: IncomingMessage): Promise { const body = await parseJsonBody(req); if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body as Record).length !== 0) { invalid('This Marketplace action does not accept a request body'); } } function sendError(res: ServerResponse, error: unknown): void { if (error instanceof PluginMarketplaceRouteError) { sendJson(res, error.status, { success: false, code: error.code, error: error.message }); return; } if (error instanceof MarketplaceClientError) { const status = error.status === 401 || error.code === 'marketplace_auth_required' ? 401 : error.status === 404 ? 404 : error.code === 'marketplace_account_changed' ? 409 : error.code === 'marketplace_request_invalid' ? 400 : error.status === 403 ? 403 : error.status === 409 ? 409 : error.status === 422 ? 422 : 503; const code = BOUNDED_MARKETPLACE_CODES.has(error.code) ? error.code : status === 401 ? 'plugin_auth_required' : status === 403 ? 'plugin_library_required' : status === 404 ? 'plugin_not_found' : status === 409 ? (error.code === 'marketplace_account_changed' ? 'plugin_account_changed' : 'plugin_release_not_ready') : status === 422 ? 'plugin_validation_failed' : status === 400 ? 'plugin_request_invalid' : 'plugin_backend_unavailable'; sendJson(res, status, { success: false, code, error: status === 401 ? 'Marketplace authentication is required' : status === 403 ? 'Marketplace Library access is required' : status === 404 ? 'Plugin was not found' : status === 409 ? (error.code === 'marketplace_account_changed' ? 'Marketplace account changed' : 'Plugin Release is not ready') : status === 422 ? 'Plugin validation failed' : status === 400 ? 'Marketplace request is invalid' : 'Marketplace service is temporarily unavailable', }); return; } if (error instanceof PluginPackageStoreError) { const status = error.code === 'plugin_release_unavailable' || error.code === 'plugin_beta_selection_required' || error.code === 'plugin_release_conflict' || error.code === 'plugin_release_not_ready' || error.code === 'plugin_incompatible_client' || error.code === 'plugin_release_yanked' || error.code === 'plugin_runtime_suspended' ? 409 : error.code === 'plugin_library_required' ? 403 : error.code === 'plugin_artifact_invalid' || error.code === 'plugin_signature_invalid' || error.code === 'plugin_manifest_invalid' ? 422 : error.code === 'plugin_account_changed' ? 409 : 503; const code = BOUNDED_MARKETPLACE_CODES.has(error.code) ? error.code : error.code === 'plugin_account_changed' ? 'plugin_account_changed' : status === 409 ? 'plugin_release_not_ready' : status === 422 ? 'plugin_artifact_invalid' : 'plugin_backend_unavailable'; sendJson(res, status, { success: false, code, error: status === 409 ? 'Plugin Release is not ready' : status === 422 ? 'Plugin artifact is invalid' : 'Plugin installation is temporarily unavailable', }); 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: 'Marketplace service is temporarily unavailable', }); } function service(ctx: HostApiContext): CodingPluginMarketplaceService | null { const products = ctx.codingProducts as (HostApiContext['codingProducts'] & { marketplace?: CodingPluginMarketplaceService; }) | undefined; return products?.pluginMarketplace ?? products?.marketplace ?? null; } function isKnownPath(pathname: string): boolean { return pathname === `${ROOT}/catalog` || pathname === `${ROOT}/library` || DETAIL.test(pathname) || LIBRARY_MUTATION.test(pathname) || INSTALLATION.test(pathname) || BETA_INSTALLATION.test(pathname) || UPDATE.test(pathname); } export async function handlePluginMarketplaceRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (!isKnownPath(url.pathname)) return false; const marketplace = service(ctx); if (!marketplace) { sendJson(res, 503, { success: false, code: 'plugin_backend_unavailable', error: 'Marketplace service is temporarily unavailable', }); return true; } try { if (url.pathname === `${ROOT}/catalog` && req.method === 'GET') { sendJson(res, 200, await marketplace.readCatalog(catalogQuery(url))); return true; } const detailMatch = DETAIL.exec(url.pathname); if (detailMatch && req.method === 'GET') { exactNoQuery(url); sendJson(res, 200, await marketplace.readDetail(routePluginId(detailMatch[1]))); return true; } if (url.pathname === `${ROOT}/library` && req.method === 'GET') { exactNoQuery(url); sendJson(res, 200, await marketplace.readLibrary()); return true; } const libraryMatch = LIBRARY_MUTATION.exec(url.pathname); if (libraryMatch && (req.method === 'PUT' || req.method === 'DELETE')) { exactNoQuery(url); await exactEmptyBody(req); const pluginId = routePluginId(libraryMatch[1]); sendJson(res, 200, req.method === 'PUT' ? await marketplace.acquire(pluginId) : await marketplace.remove(pluginId)); return true; } const installationMatch = INSTALLATION.exec(url.pathname); if (installationMatch && (req.method === 'POST' || req.method === 'DELETE')) { exactNoQuery(url); await exactEmptyBody(req); const pluginId = routePluginId(installationMatch[1]); const result = req.method === 'DELETE' ? await marketplace.uninstall(pluginId) : await marketplace.install(pluginId); sendJson(res, 200, result); return true; } const betaInstallationMatch = BETA_INSTALLATION.exec(url.pathname); if (betaInstallationMatch && req.method === 'POST') { exactNoQuery(url); await exactEmptyBody(req); sendJson(res, 200, await marketplace.installBeta(routePluginId(betaInstallationMatch[1]))); return true; } const updateMatch = UPDATE.exec(url.pathname); if (updateMatch && req.method === 'POST') { exactNoQuery(url); await exactEmptyBody(req); sendJson(res, 200, await marketplace.update(routePluginId(updateMatch[1]))); return true; } return false; } catch (error) { sendError(res, error); return true; } } export const handleCodingPluginMarketplaceRoutes = handlePluginMarketplaceRoutes;