import { getServerDeploymentRole } from '@/lib/server/ops-access'; import { configuredLearningRuntimeToken } from '@/lib/makelore-runtime/auth'; import type { CourseRecord } from '@/lib/course-framework/types'; import type { LargeCourseControlAction } from '@/lib/makelore-runtime/large-course-control'; export const LEARNING_ENGINE_BASE_URL_ENV = 'LEARNING_ENGINE_BASE_URL'; export const COURSE_PUBLISH_SERVER_BASE_URL_ENV = 'COURSE_PUBLISH_SERVER_BASE_URL'; function validatedRuntimeBaseUrl(raw: string, appendRuntimePath: boolean): string | null { try { const url = new URL(raw); if ( (url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash ) return null; const pathname = url.pathname.replace(/\/+$/, '') || '/'; if (appendRuntimePath) { if (pathname !== '/') return null; url.pathname = '/api/runtime/v1'; } else if (pathname !== '/api/runtime/v1') { return null; } return url.href.replace(/\/+$/, ''); } catch { return null; } } /** Resolve one environment-owned Engine destination; request data never selects it. */ export function configuredLearningEngineRuntimeBaseUrl(): string | null { const runtimeBase = process.env[LEARNING_ENGINE_BASE_URL_ENV]?.trim(); if (runtimeBase) return validatedRuntimeBaseUrl(runtimeBase, false); const publishBase = process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim(); if (publishBase) return validatedRuntimeBaseUrl(publishBase, true); return null; } function unavailable(message: string): Response { return Response.json({ error: 'engine_control_unavailable', message }, { status: 503 }); } /** * Proxy an Ops runner mutation to Engine. Only non-production `all` mode may * return null and use the legacy in-process development runner. */ export async function proxyLargeCourseControlToEngine(options: { request: Request; course: CourseRecord; action: LargeCourseControlAction; moduleIndex?: number; fetchImpl?: typeof fetch; }): Promise { const runtimeBase = configuredLearningEngineRuntimeBaseUrl(); if (!runtimeBase) { if (process.env.NODE_ENV !== 'production' && getServerDeploymentRole() === 'all') return null; return unavailable( `${LEARNING_ENGINE_BASE_URL_ENV} or ${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not configured`, ); } const owner = options.course.ownerPrincipalId?.trim(); if (!owner) { return Response.json( { error: 'course_owner_missing', message: 'Course has no Works owner binding' }, { status: 409 }, ); } const token = configuredLearningRuntimeToken(); if (!token) return unavailable('LEARNING_ENGINE_TOKEN is not configured'); try { const response = await (options.fetchImpl ?? fetch)( `${runtimeBase}/courses/jobs/${encodeURIComponent(options.course.id)}/control`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'X-Owner-User-Id': owner, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ action: options.action, ...(options.moduleIndex === undefined ? {} : { moduleIndex: options.moduleIndex }), }), redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(30_000), }, ); const bytes = await response.arrayBuffer(); if (bytes.byteLength > 1024 * 1024) return unavailable('Engine control response is too large'); return new Response(bytes, { status: response.status, headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json', 'Cache-Control': 'no-store', }, }); } catch { return unavailable('Learning Engine control endpoint is unavailable'); } }