// Server-only transactional large-course ingestion. // // One multipart request carries `metadata` JSON plus `module-{index}` ZIPs. // The server stages every frozen bundle as unpublished, promotes the complete // batch, commits one schema-v2 manifest, and compensates this batch back to // unpublished on failure. Learner APIs never expose sourceClassroomId. import { type NextRequest, NextResponse } from 'next/server'; import { CoursePublishInProgressError } from '@/lib/course-framework/publish-state'; import type { CourseManifestRepo } from '@/lib/course-manifest-repo/types'; import { COURSEWARE_BUNDLES_DIR, createFileBundleByteStore, type BundleByteStore, } from '@/lib/courseware-repo/bundle-store'; import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; import { COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, COURSEWARE_PUBLIC_BASE_URL_ENV, CoursePublishTransportError, DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES, MAX_COURSE_PUBLISH_METADATA_BYTES, parseRemoteCoursePublishMetadata, positiveIntegerEnv, resolveCoursewarePublicBaseUrl, type RemoteCoursePublishErrorBody, type RemoteCoursePublishSuccessBody, } from '@/lib/server/course-publish-contract'; import { commitRemoteCoursePublish, type CoursePublishTransactionRepos, } from '@/lib/server/course-publish-transaction'; import { buildRequestOrigin } from '@/lib/server/classroom-storage'; import { capBodyStream } from '@/lib/server/capped-stream'; import { createLogger } from '@/lib/logger'; import { COURSEWARE_PUBLISH_TOKEN_ENV, isPublishTokenConfigured, verifyPublishToken, } from '@/lib/courseware-repo'; import type { CoursewareRepo } from '@/lib/courseware-repo/types'; import { getServerDeploymentRole } from '@/lib/server/ops-access'; const log = createLogger('Internal Course Publish API'); export interface CoursePublishRouteDependencies { coursewares?: CoursewareRepo; bundles?: BundleByteStore; manifests?: CourseManifestRepo; publicBaseUrl?: string; } function bearerToken(request: NextRequest): string | null { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null; return header.slice('Bearer '.length).trim() || null; } function errorResponse(error: CoursePublishTransportError): NextResponse { const body: RemoteCoursePublishErrorBody = { success: false, errorCode: error.errorCode, error: error.message, phase: error.phase, ...(error.moduleIndex ? { moduleIndex: error.moduleIndex } : {}), ...(error.details ? { details: error.details } : {}), }; return NextResponse.json(body, { status: error.status }); } function routeRepos(deps: CoursePublishRouteDependencies): Partial { return { coursewares: deps.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR), bundles: deps.bundles ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR), ...(deps.manifests ? { manifests: deps.manifests } : {}), }; } function canonicalPublicBaseUrl( request: NextRequest, deps: CoursePublishRouteDependencies, ): string { const configured = deps.publicBaseUrl?.trim() || 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 on a production server`, 'request', 503, ); } return resolveCoursewarePublicBaseUrl(configured || buildRequestOrigin(request)); } export async function handleCoursePublishRequest( request: NextRequest, deps: CoursePublishRouteDependencies = {}, ): Promise { const role = getServerDeploymentRole(); if (role !== 'server' && role !== 'all') { return errorResponse( new CoursePublishTransportError( 'FORBIDDEN', 'Transactional course publishing is only available on the server deployment', 'request', 403, ), ); } if (!isPublishTokenConfigured()) { return errorResponse( new CoursePublishTransportError( 'PUBLISH_DISABLED', `Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`, 'request', 503, ), ); } const token = bearerToken(request); if (!verifyPublishToken(token)) { return errorResponse( new CoursePublishTransportError( 'UNAUTHORIZED', 'Invalid or missing publish token', 'request', 401, ), ); } try { if (!request.headers.get('content-type')?.toLowerCase().startsWith('multipart/form-data')) { throw new CoursePublishTransportError( 'INVALID_CONTENT_TYPE', 'Course publish requires multipart/form-data', 'request', 415, ); } const maxUploadBytes = positiveIntegerEnv( COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, ); const declaredLength = Number(request.headers.get('content-length') ?? ''); if (Number.isFinite(declaredLength) && declaredLength > maxUploadBytes) { throw new CoursePublishTransportError( 'COURSE_UPLOAD_TOO_LARGE', `Course publish exceeds ${maxUploadBytes} bytes`, 'request', 413, ); } if (!request.body) { throw new CoursePublishTransportError( 'BODY_MISSING', 'Course publish body is missing', 'request', 400, ); } const capped = capBodyStream(request.body, maxUploadBytes); let form: FormData; try { form = await new Response(capped.stream, { headers: { 'content-type': request.headers.get('content-type')! }, }).formData(); } catch (error) { if (capped.exceeded()) { throw new CoursePublishTransportError( 'COURSE_UPLOAD_TOO_LARGE', `Course publish exceeds ${maxUploadBytes} bytes`, 'request', 413, ); } throw new CoursePublishTransportError( 'MULTIPART_INVALID', 'Course publish multipart body could not be parsed', 'request', 400, undefined, error instanceof Error ? error.message : undefined, ); } const metadataField = form.get('metadata'); if (typeof metadataField !== 'string') { throw new CoursePublishTransportError( 'METADATA_MISSING', 'Course publish metadata is missing', 'request', 400, ); } if (new TextEncoder().encode(metadataField).byteLength > MAX_COURSE_PUBLISH_METADATA_BYTES) { throw new CoursePublishTransportError( 'METADATA_TOO_LARGE', 'Course publish metadata exceeds the size limit', 'request', 413, ); } let metadataValue: unknown; try { metadataValue = JSON.parse(metadataField) as unknown; } catch { throw new CoursePublishTransportError( 'INVALID_METADATA', 'Course publish metadata is not valid JSON', 'request', 400, ); } const metadata = parseRemoteCoursePublishMetadata(metadataValue); const perModuleLimit = positiveIntegerEnv( 'COURSEWARE_MAX_UPLOAD_BYTES', DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES, ); const archives = metadata.modules.map((moduleRecord) => { const fieldName = `module-${moduleRecord.index}`; const fields = form.getAll(fieldName); if (fields.length !== 1 || !(fields[0] instanceof File)) { throw new CoursePublishTransportError( 'MODULE_ARCHIVE_MISSING', `Module ${moduleRecord.index} requires exactly one ZIP`, 'request', 400, moduleRecord.index, ); } const file = fields[0]; if (file.size > perModuleLimit) { throw new CoursePublishTransportError( 'MODULE_ARCHIVE_TOO_LARGE', `Module ${moduleRecord.index} ZIP exceeds ${perModuleLimit} bytes`, 'request', 413, moduleRecord.index, ); } return { index: moduleRecord.index, file }; }); const loadedArchives = await Promise.all( archives.map(async ({ index, file }) => ({ index, zipBytes: new Uint8Array(await file.arrayBuffer()), })), ); const result = await commitRemoteCoursePublish({ metadata, archives: loadedArchives, token, publicBaseUrl: canonicalPublicBaseUrl(request, deps), repos: routeRepos(deps), }); const body: RemoteCoursePublishSuccessBody = { success: true, record: result.record, idempotent: result.idempotent, }; return NextResponse.json(body, { status: result.idempotent ? 200 : 201 }); } catch (error) { if (error instanceof CoursePublishTransportError) return errorResponse(error); if (error instanceof CoursePublishInProgressError) { return errorResponse( new CoursePublishTransportError('PUBLISH_IN_PROGRESS', error.message, 'request', 409), ); } log.error('Transactional course publish failed:', error); return errorResponse( new CoursePublishTransportError( 'INTERNAL_ERROR', 'Transactional course publish failed', 'commit', 500, undefined, error instanceof Error ? error.message : undefined, ), ); } }