105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
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<Response | null> {
|
|
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');
|
|
}
|
|
}
|