import { courseModuleCoursewareId } from '@/lib/course-framework/publish-identity'; import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types'; export const COURSE_PUBLISH_PROTOCOL_VERSION = 1 as const; export const COURSE_PUBLISH_SERVER_BASE_URL_ENV = 'COURSE_PUBLISH_SERVER_BASE_URL'; export const COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV = 'COURSE_PUBLISH_MAX_UPLOAD_BYTES'; export const COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV = 'COURSE_PUBLISH_ALLOW_INSECURE_HTTP'; export const COURSE_PUBLISH_TIMEOUT_MS_ENV = 'COURSE_PUBLISH_TIMEOUT_MS'; export const COURSEWARE_PUBLIC_BASE_URL_ENV = 'COURSEWARE_PUBLIC_BASE_URL'; export const COURSE_PUBLISH_ENDPOINT_PATH = '/api/internal/course-publish'; export const DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES = 900 * 1024 * 1024; export const DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES = 300 * 1024 * 1024; export const DEFAULT_COURSE_PUBLISH_TIMEOUT_MS = 10 * 60 * 1000; export const MAX_COURSE_PUBLISH_MODULES = 50; export const MAX_COURSE_PUBLISH_METADATA_BYTES = 1024 * 1024; export type CoursePublishPhase = 'request' | 'staging' | 'commit' | 'rollback'; export interface RemoteCoursePublishModule { index: number; title: string; description: string; coursewareId: string; /** Private mutable source id. Never returned by public learner APIs. */ sourceClassroomId: string; } export interface RemoteCoursePublishMetadata { protocolVersion: typeof COURSE_PUBLISH_PROTOCOL_VERSION; courseId: string; title: string; summary: string; language?: string; modules: RemoteCoursePublishModule[]; } export interface RemoteCoursePublishSuccessBody { success: true; record: CourseManifestRecord; idempotent: boolean; } export interface RemoteCoursePublishErrorBody { success: false; errorCode: string; error: string; phase: CoursePublishPhase; moduleIndex?: number; details?: string; } export class CoursePublishTransportError extends Error { constructor( public readonly errorCode: string, message: string, public readonly phase: CoursePublishPhase, public readonly status: number, public readonly moduleIndex?: number, public readonly details?: string, ) { super(message); this.name = 'CoursePublishTransportError'; } } function textField(value: unknown, name: string, maxLength: number, moduleIndex?: number): string { if (typeof value !== 'string' || !value.trim()) { throw new CoursePublishTransportError( 'INVALID_METADATA', `${name} must be a non-empty string`, 'request', 400, moduleIndex, ); } const normalized = value.trim(); if (normalized.length > maxLength) { throw new CoursePublishTransportError( 'INVALID_METADATA', `${name} exceeds ${maxLength} characters`, 'request', 400, moduleIndex, ); } return normalized; } export function parseRemoteCoursePublishMetadata(value: unknown): RemoteCoursePublishMetadata { if (!value || typeof value !== 'object') { throw new CoursePublishTransportError( 'INVALID_METADATA', 'Publish metadata must be an object', 'request', 400, ); } const input = value as Partial; if (input.protocolVersion !== COURSE_PUBLISH_PROTOCOL_VERSION) { throw new CoursePublishTransportError( 'UNSUPPORTED_PROTOCOL', `Unsupported course publish protocol: ${String(input.protocolVersion)}`, 'request', 400, ); } const courseId = textField(input.courseId, 'courseId', 128); if (!/^[a-zA-Z0-9_-]+$/.test(courseId)) { throw new CoursePublishTransportError( 'INVALID_METADATA', `Invalid courseId: ${courseId}`, 'request', 400, ); } if (!Array.isArray(input.modules) || input.modules.length === 0) { throw new CoursePublishTransportError( 'INVALID_METADATA', 'Course publish must include at least one module', 'request', 400, ); } if (input.modules.length > MAX_COURSE_PUBLISH_MODULES) { throw new CoursePublishTransportError( 'INVALID_METADATA', `Course publish exceeds ${MAX_COURSE_PUBLISH_MODULES} modules`, 'request', 400, ); } const modules = [...input.modules] .map((moduleValue, position): RemoteCoursePublishModule => { if (!moduleValue || typeof moduleValue !== 'object') { throw new CoursePublishTransportError( 'INVALID_METADATA', `Module ${position + 1} metadata must be an object`, 'request', 400, position + 1, ); } const moduleInput = moduleValue as Partial; const index = moduleInput.index; if (!Number.isInteger(index) || (index ?? 0) < 1) { throw new CoursePublishTransportError( 'INVALID_METADATA', `Invalid module index: ${String(index)}`, 'request', 400, typeof index === 'number' ? index : position + 1, ); } const resolvedIndex = index as number; const coursewareId = textField(moduleInput.coursewareId, 'coursewareId', 256, resolvedIndex); const expectedCoursewareId = courseModuleCoursewareId(courseId, resolvedIndex); if (coursewareId !== expectedCoursewareId) { throw new CoursePublishTransportError( 'MODULE_IDENTITY_MISMATCH', `Module ${resolvedIndex} coursewareId must be ${expectedCoursewareId}`, 'request', 409, resolvedIndex, ); } const sourceClassroomId = textField( moduleInput.sourceClassroomId, 'sourceClassroomId', 256, resolvedIndex, ); if (!/^[a-zA-Z0-9_-]+$/.test(sourceClassroomId)) { throw new CoursePublishTransportError( 'INVALID_METADATA', `Module ${resolvedIndex} has an invalid sourceClassroomId`, 'request', 400, resolvedIndex, ); } return { index: resolvedIndex, title: textField(moduleInput.title, 'module title', 500, resolvedIndex), description: textField( moduleInput.description, 'module description', 10_000, resolvedIndex, ), coursewareId, sourceClassroomId, }; }) .sort((a, b) => a.index - b.index); if (modules.some((moduleRecord, position) => moduleRecord.index !== position + 1)) { throw new CoursePublishTransportError( 'INVALID_METADATA', 'Module indexes must be unique and contiguous starting at 1', 'request', 400, ); } const language = typeof input.language === 'string' && input.language.trim() ? textField(input.language, 'language', 500) : undefined; return { protocolVersion: COURSE_PUBLISH_PROTOCOL_VERSION, courseId, title: textField(input.title, 'title', 500), summary: textField(input.summary, 'summary', 20_000), ...(language ? { language } : {}), modules, }; } export function positiveIntegerEnv(name: string, fallback: number): number { const raw = process.env[name]?.trim(); if (!raw) return fallback; const value = Number(raw); return Number.isSafeInteger(value) && value > 0 ? value : fallback; } function isLoopback(hostname: string): boolean { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; } /** Resolve one fixed, environment-owned destination; no request data can alter it. */ export function resolveCoursePublishEndpoint(baseUrl: string): URL { let endpoint: URL; try { endpoint = new URL(baseUrl); } catch { throw new CoursePublishTransportError( 'INVALID_SERVER_URL', `${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not a valid URL`, 'request', 503, ); } if (endpoint.protocol !== 'https:' && endpoint.protocol !== 'http:') { throw new CoursePublishTransportError( 'INVALID_SERVER_URL', `${COURSE_PUBLISH_SERVER_BASE_URL_ENV} must use http or https`, 'request', 503, ); } if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) { throw new CoursePublishTransportError( 'INVALID_SERVER_URL', `${COURSE_PUBLISH_SERVER_BASE_URL_ENV} cannot contain credentials, query, or fragment`, 'request', 503, ); } if (endpoint.pathname !== '/' && endpoint.pathname !== '') { throw new CoursePublishTransportError( 'INVALID_SERVER_URL', `${COURSE_PUBLISH_SERVER_BASE_URL_ENV} must be an origin without a path`, 'request', 503, ); } if ( endpoint.protocol === 'http:' && !isLoopback(endpoint.hostname) && process.env[COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV] !== 'true' ) { throw new CoursePublishTransportError( 'INSECURE_SERVER_URL', `Plain HTTP publish requires ${COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV}=true`, 'request', 503, ); } endpoint.pathname = COURSE_PUBLISH_ENDPOINT_PATH; return endpoint; } /** Validate the canonical learner-visible origin stored in registry bundle URLs. */ export function resolveCoursewarePublicBaseUrl(baseUrl: string): string { let origin: URL; try { origin = new URL(baseUrl); } catch { throw new CoursePublishTransportError( 'INVALID_PUBLIC_URL', `${COURSEWARE_PUBLIC_BASE_URL_ENV} is not a valid URL`, 'request', 503, ); } if (origin.protocol !== 'https:' && origin.protocol !== 'http:') { throw new CoursePublishTransportError( 'INVALID_PUBLIC_URL', `${COURSEWARE_PUBLIC_BASE_URL_ENV} must use http or https`, 'request', 503, ); } if ( origin.username || origin.password || origin.search || origin.hash || (origin.pathname !== '/' && origin.pathname !== '') ) { throw new CoursePublishTransportError( 'INVALID_PUBLIC_URL', `${COURSEWARE_PUBLIC_BASE_URL_ENV} must be an origin without credentials, path, query, or fragment`, 'request', 503, ); } if ( origin.protocol === 'http:' && !isLoopback(origin.hostname) && process.env[COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV] !== 'true' ) { throw new CoursePublishTransportError( 'INSECURE_PUBLIC_URL', `Plain HTTP public URLs require ${COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV}=true`, 'request', 503, ); } return origin.origin; } /** * Resolve the learner-visible origin from deployment configuration. Production * publishing never persists request Host/X-Forwarded-Host as a bundle URL. */ export function resolveConfiguredCoursewarePublicBaseUrl(fallback?: string): string { const configured = process.env[COURSEWARE_PUBLIC_BASE_URL_ENV]?.trim(); if (!configured && process.env.NODE_ENV === 'production') { throw new CoursePublishTransportError( 'PUBLIC_URL_MISSING', `${COURSEWARE_PUBLIC_BASE_URL_ENV} is required for production publishing`, 'request', 503, ); } const candidate = configured || fallback?.trim(); if (!candidate) { throw new CoursePublishTransportError( 'PUBLIC_URL_MISSING', `${COURSEWARE_PUBLIC_BASE_URL_ENV} is not configured`, 'request', 503, ); } return resolveCoursewarePublicBaseUrl(candidate); }