// Plugin HTTP routing dispatches registered plugin routes, upgrades, auth policy, and runtime request scope. import type { IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, } from "../../../packages/gateway-protocol/src/client-info.js"; import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/index.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import { runPluginHttpRoute } from "../../plugins/http-route-owner.js"; import type { PluginHttpRouteRegistration, PluginRegistry } from "../../plugins/registry.js"; import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import { rejectWebSocketUpgrade } from "../../shared/websocket-upgrade-reject.js"; import { respondControlUiPluginAuthCookieProbe } from "../control-ui-plugin-auth-cookie.js"; import { finishFailedGatewayHttpResponse } from "../http-common.js"; import type { AuthorizedGatewayHttpRequest } from "../http-utils.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "../server-methods/types.js"; import { runWithGatewayHttpWorkAdmission, runWithGatewayUpgradeWorkAdmission, } from "./http-work-admission.js"; import { resolvePluginRouteRuntimeOperatorScopes } from "./plugin-route-runtime-scopes.js"; import { resolvePluginRoutePathContext, type PluginRoutePathContext, } from "./plugins-http/path-context.js"; import { matchedPluginRoutesRequireGatewayAuth } from "./plugins-http/route-auth.js"; import { findMatchingPluginHttpRoutes } from "./plugins-http/route-match.js"; export { isProtectedPluginRoutePathFromContext, resolvePluginRoutePathContext, type PluginRoutePathContext, } from "./plugins-http/path-context.js"; export { findRegisteredPluginHttpRoute, isRegisteredPluginHttpRoutePath, } from "./plugins-http/route-match.js"; export { isPluginAuthenticatedRoutePath, shouldEnforceGatewayAuthForPluginPath, } from "./plugins-http/route-auth.js"; type SubsystemLogger = ReturnType; type PluginRouteRuntimeScope = Parameters[0]; function resolvePluginRoutePathContextForRequest( req: IncomingMessage, providedPathContext: PluginRoutePathContext | undefined, ): PluginRoutePathContext { if (providedPathContext) { return providedPathContext; } const url = new URL(req.url ?? "/", "http://localhost"); return resolvePluginRoutePathContext(url.pathname); } function createPluginRouteRuntimeClient( scopes: readonly string[], clientIp: string | undefined, requestAuth?: AuthorizedGatewayHttpRequest, ): GatewayRequestOptions["client"] { const authenticatedUserProfile = requestAuth?.authenticatedUserProfile; const operatorRoleActor = requestAuth?.operatorRoleActor; return { connId: `plugin-http:${clientIp ?? "unknown"}`, ...(clientIp ? { clientIp } : {}), ...(authenticatedUserProfile ? { authenticatedUserProfile } : {}), ...(operatorRoleActor ? { internal: { operatorRoleActor } } : {}), connect: { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, version: "internal", platform: "node", mode: GATEWAY_CLIENT_MODES.BACKEND, }, role: "operator", scopes: [...scopes], }, }; } type PluginRouteRuntimeDispatchContext = { gatewayRequestAuth?: AuthorizedGatewayHttpRequest; gatewayRequestOperatorScopes?: readonly string[]; gatewayRequestClientIp?: string; }; function getMissingPluginRouteRuntimeContext( route: PluginHttpRouteRegistration, context: PluginRouteRuntimeDispatchContext, ): "caller auth context" | "caller scope context" | undefined { if (route.auth !== "gateway") { return undefined; } if (route.gatewayRuntimeScopeSurface === "trusted-operator") { return context.gatewayRequestAuth ? undefined : "caller auth context"; } return context.gatewayRequestOperatorScopes === undefined ? "caller scope context" : undefined; } function canRunPluginHttpRouteWithoutAdmission(route: PluginHttpRouteRegistration): boolean { // The manifest entitlement is plugin-wide; require the route-specific trusted operator // surface so an ordinary sibling cannot start work after suspension reports ready. return ( route.auth === "gateway" && route.gatewayRuntimeScopeSurface === "trusted-operator" && route.gatewayMethodDispatchAllowed === true ); } function createPluginRouteRuntimeScope(params: { registry: PluginRegistry; route: PluginHttpRouteRegistration; req: IncomingMessage; gatewayRequestContext?: GatewayRequestContext; gatewayRequestAuth?: AuthorizedGatewayHttpRequest; gatewayRequestOperatorScopes?: readonly string[]; gatewayRequestClientIp?: string; }): PluginRouteRuntimeScope { const runtimeScopes = params.route.auth !== "gateway" ? [] : params.gatewayRequestAuth?.controlUiPluginGrant ? params.gatewayRequestOperatorScopes! : params.route.gatewayRuntimeScopeSurface === "trusted-operator" ? resolvePluginRouteRuntimeOperatorScopes( params.req, params.gatewayRequestAuth!, "trusted-operator", ) : params.gatewayRequestOperatorScopes!; const runtimeClient = createPluginRouteRuntimeClient( runtimeScopes, params.gatewayRequestClientIp, params.route.auth === "gateway" ? params.gatewayRequestAuth : undefined, ); return { pluginRegistry: params.registry, ...(params.route.auth === "gateway" && params.gatewayRequestAuth?.revalidate ? { revalidate: params.gatewayRequestAuth.revalidate } : {}), ...(params.gatewayRequestContext ? { context: params.gatewayRequestContext } : {}), client: runtimeClient, isWebchatConnect: () => false, ...(params.route.pluginId ? { pluginId: params.route.pluginId } : {}), ...(params.route.source ? { pluginSource: params.route.source } : {}), ...(params.route.gatewayMethodDispatchAllowed === true ? { gatewayMethodDispatchAllowed: true } : {}), }; } export type PluginRouteDispatchContext = { gatewayAuthSatisfied?: boolean; gatewayRequestAuth?: AuthorizedGatewayHttpRequest; gatewayRequestOperatorScopes?: readonly string[]; gatewayRequestClientIp?: string; }; export type PluginHttpRequestHandler = ( req: IncomingMessage, res: ServerResponse, pathContext?: PluginRoutePathContext, dispatchContext?: PluginRouteDispatchContext, ) => Promise; export type PluginHttpUpgradeHandler = ( req: IncomingMessage, socket: Duplex, head: Buffer, pathContext?: PluginRoutePathContext, dispatchContext?: PluginRouteDispatchContext, ) => Promise; export function createGatewayPluginRequestHandler(params: { registry: PluginRegistry; getRouteRegistry?: () => PluginRegistry; log: SubsystemLogger; getGatewayRequestContext?: () => GatewayRequestContext | undefined; }): PluginHttpRequestHandler { const { log } = params; return async (req, res, providedPathContext, dispatchContext) => { const registry = params.getRouteRegistry?.() ?? params.registry; const gatewayRequestContext = params.getGatewayRequestContext?.(); const routes = registry.httpRoutes ?? []; if (routes.length === 0) { return false; } const pathContext = resolvePluginRoutePathContextForRequest(req, providedPathContext); const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext); if (matchedRoutes.length === 0) { return false; } const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes); if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) { log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`); return false; } const firstGatewayRoute = matchedRoutes.find((route) => route.auth === "gateway"); const presentedGatewayRequestAuth = dispatchContext?.gatewayRequestAuth; const presentedControlUiPluginGrants = presentedGatewayRequestAuth?.controlUiPluginGrants; const controlUiPluginGrant = presentedControlUiPluginGrants?.find( (grant) => grant.pluginId === firstGatewayRoute?.pluginId, ); if (presentedControlUiPluginGrants && (!firstGatewayRoute || !controlUiPluginGrant)) { log.warn( `plugin http route blocked for mismatched control ui grant (${pathContext.canonicalPath})`, ); res.statusCode = 401; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Unauthorized"); return true; } const gatewayRequestAuth = controlUiPluginGrant ? { ...presentedGatewayRequestAuth!, controlUiPluginGrant, } : presentedGatewayRequestAuth; const gatewayRequestOperatorScopes = controlUiPluginGrant ? controlUiPluginGrant.scopes : dispatchContext?.gatewayRequestOperatorScopes; // Fail closed before invoking any handlers when matched gateway routes are // missing the runtime auth/scope context they require. for (const route of matchedRoutes) { if ( controlUiPluginGrant && route.auth === "gateway" && route.pluginId !== controlUiPluginGrant.pluginId ) { continue; } const missingRuntimeContext = getMissingPluginRouteRuntimeContext(route, { gatewayRequestAuth, gatewayRequestOperatorScopes, }); if (missingRuntimeContext) { log.warn( `plugin http route blocked without ${missingRuntimeContext} (${pathContext.canonicalPath})`, ); return false; } } // The probe is intercepted only after route ownership and cookie auth are // established. Plugin code never sees the reserved capability request. if (controlUiPluginGrant && respondControlUiPluginAuthCookieProbe(req, res)) { return true; } for (const route of matchedRoutes) { if ( controlUiPluginGrant && route.auth === "gateway" && route.pluginId !== controlUiPluginGrant.pluginId ) { continue; } try { const runRoute = async () => (await withPluginRuntimeGatewayRequestScope( createPluginRouteRuntimeScope({ registry, route, req, gatewayRequestContext, gatewayRequestAuth, gatewayRequestOperatorScopes, gatewayRequestClientIp: dispatchContext?.gatewayRequestClientIp, }), async () => runPluginHttpRoute(registry, route, route.handler, () => route.handler(req, res)), )) !== false; // Entitled trusted-operator routes delegate substantive work through Gateway dispatch. // An outer root would make gateway.suspend.prepare nested and permanently unreachable. const handled = canRunPluginHttpRouteWithoutAdmission(route) ? await runRoute() : await runWithGatewayHttpWorkAdmission(res, runRoute); if (handled) { return true; } } catch (err) { log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`); finishFailedGatewayHttpResponse(res); return true; } } return false; }; } export function createGatewayPluginUpgradeHandler(params: { registry: PluginRegistry; getRouteRegistry?: () => PluginRegistry; log: SubsystemLogger; getGatewayRequestContext?: () => GatewayRequestContext | undefined; }): PluginHttpUpgradeHandler { const { log } = params; return async (req, socket, head, providedPathContext, dispatchContext) => { const registry = params.getRouteRegistry?.() ?? params.registry; const gatewayRequestContext = params.getGatewayRequestContext?.(); const routes = registry.httpRoutes ?? []; if (routes.length === 0) { return false; } const pathContext = resolvePluginRoutePathContextForRequest(req, providedPathContext); const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext).filter( (route) => typeof route.handleUpgrade === "function", ); if (matchedRoutes.length === 0) { return false; } const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes); if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) { log.warn(`plugin http upgrade blocked without gateway auth (${pathContext.canonicalPath})`); rejectWebSocketUpgrade(socket, { status: 401 }); return true; } const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth; const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes; for (const route of matchedRoutes) { const missingRuntimeContext = getMissingPluginRouteRuntimeContext(route, { gatewayRequestAuth, gatewayRequestOperatorScopes, }); if (missingRuntimeContext) { log.warn( `plugin http upgrade blocked without ${missingRuntimeContext} (${pathContext.canonicalPath})`, ); rejectWebSocketUpgrade(socket, { status: 401 }); return true; } } for (const route of matchedRoutes) { try { const handled = await runWithGatewayUpgradeWorkAdmission( socket, async () => (await withPluginRuntimeGatewayRequestScope( createPluginRouteRuntimeScope({ registry, route, req, gatewayRequestContext, gatewayRequestAuth, gatewayRequestOperatorScopes, gatewayRequestClientIp: dispatchContext?.gatewayRequestClientIp, }), async () => { const handleUpgrade = route.handleUpgrade!; return runPluginHttpRoute(registry, route, handleUpgrade, () => handleUpgrade(req, socket, head), ); }, )) !== false, ); if (handled) { return true; } } catch (err) { log.warn(`plugin http upgrade failed (${route.pluginId ?? "unknown"}): ${String(err)}`); socket.destroy(); return true; } } return false; }; }