// Large-course mode API — publish a course (L1 → L2). // // POST /api/courses/:courseId/publish // // Authorization: the ops deployment role + ACCESS_CODE session are checked at // the route boundary. The server-only COURSEWARE_PUBLISH_TOKEN is then used for // the internal L1→L2 publish gate and is never sent to the browser. import { type NextRequest } from 'next/server'; import { apiError, apiSuccess, API_ERROR_CODES } from '@/lib/server/api-response'; import { createLogger } from '@/lib/logger'; import { isValidCourseId, readCourseRecordReconciled, updateCourseRecord, } from '@/lib/course-framework/store'; import { COURSEWARE_PUBLISH_TOKEN_ENV, isPublishTokenConfigured } from '@/lib/courseware-repo'; import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types'; import type { CoursePublicationReceipt } from '@/lib/course-framework/types'; import { configuredWorksSquareApiOrigin, requireOpsAccess, } from '@/lib/server/ops-access'; import { buildRequestOrigin } from '@/lib/server/classroom-storage'; import { isCourseGenerationRunning } from '@/lib/course-framework/runner'; import { CoursePublishValidationError, revalidateCoursePublishSnapshot, validateCoursePublishSnapshot, type CoursePublishSnapshot, } from '@/lib/course-framework/publish-validation'; import { ClassroomSourceMutationInProgressError, CoursePublishInProgressError, runClassroomSourcesPublishExclusive, runCoursePublishExclusive, } from '@/lib/course-framework/publish-state'; import { courseModuleCoursewareId } from '@/lib/course-framework/publish-identity'; import { packagePersistedClassroom } from '@/lib/server/classroom-courseware-publish'; import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; import { COURSEWARE_BUNDLES_DIR, createFileBundleByteStore, } from '@/lib/courseware-repo/bundle-store'; import { COURSE_PUBLISH_PROTOCOL_VERSION, COURSE_PUBLISH_SERVER_BASE_URL_ENV, CoursePublishTransportError, resolveConfiguredCoursewarePublicBaseUrl, } from '@/lib/server/course-publish-contract'; import { publishCourseToServer } from '@/lib/server/course-publish-transport'; import { commitRemoteCoursePublish } from '@/lib/server/course-publish-transaction'; const log = createLogger('Course Publish API'); export const maxDuration = 900; function buildPublicationReceipt( snapshot: CoursePublishSnapshot, manifest: CourseManifestRecord, expectedHashes: ReadonlyMap, ): CoursePublicationReceipt { if ( manifest.schemaVersion !== 2 || manifest.courseId !== snapshot.courseId || manifest.modules.length !== snapshot.modules.length ) { throw new CoursePublishTransportError( 'PUBLICATION_RECEIPT_MISMATCH', 'Publish server returned a manifest for a different course snapshot', 'commit', 502, ); } const modules = snapshot.modules.map( ({ module: sourceModule, outputDigest, sourceRevisionHash }) => { const pin = manifest.modules.find((entry) => entry.index === sourceModule.index); const expectedCoursewareId = courseModuleCoursewareId(snapshot.courseId, sourceModule.index); const expectedHash = expectedHashes.get(sourceModule.index); if ( !pin || pin.title !== sourceModule.title || pin.description !== sourceModule.description || pin.coursewareId !== expectedCoursewareId || !pin.coursewareVersion || !pin.contentHash || pin.contentHash !== expectedHash ) { throw new CoursePublishTransportError( 'PUBLICATION_RECEIPT_MISMATCH', `Publish receipt identity drifted for module ${sourceModule.index}`, 'commit', 502, sourceModule.index, ); } return { index: sourceModule.index, sourceClassroomId: outputDigest.classroomId, sourceRevisionHash, sourceSemanticHash: outputDigest.semanticHash, coursewareId: pin.coursewareId, coursewareVersion: pin.coursewareVersion, contentHash: pin.contentHash, }; }, ); return { receiptVersion: 1, manifestVersion: manifest.version, publishedAt: manifest.publishedAt, modules, }; } export async function POST( request: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { const denied = await requireOpsAccess(request); if (denied) return denied; try { const { courseId } = await params; if (!isValidCourseId(courseId)) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, `Invalid course id: ${courseId}`); } const worksOrigin = configuredWorksSquareApiOrigin(); if (worksOrigin) { const authorization = request.headers.get('authorization') ?? ''; const finalizeResponse = await fetch( `${worksOrigin}/api/admin/learning/generations/${encodeURIComponent(courseId)}/finalize`, { method: 'POST', headers: { Authorization: authorization, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(10 * 60_000), }, ); const finalized = (await finalizeResponse.json().catch(() => null)) as { id?: unknown; status?: unknown; } | null; if (!finalizeResponse.ok || typeof finalized?.id !== 'string') { return apiError( API_ERROR_CODES.UPSTREAM_ERROR, finalizeResponse.status >= 400 && finalizeResponse.status < 500 ? finalizeResponse.status : 502, 'Works could not finalize the aggregate learning course', ); } const publishResponse = await fetch( `${worksOrigin}/api/admin/learning/courses/${encodeURIComponent(finalized.id)}/publish`, { method: 'POST', headers: { Authorization: authorization, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(30_000), }, ); const published = (await publishResponse.json().catch(() => null)) as { id?: unknown; status?: unknown; } | null; if (!publishResponse.ok || typeof published?.id !== 'string') { return apiError( API_ERROR_CODES.UPSTREAM_ERROR, publishResponse.status >= 400 && publishResponse.status < 500 ? publishResponse.status : 502, 'Works registered the course package but could not publish it', ); } await updateCourseRecord(courseId, { externalPublication: { courseId: published.id, status: published.status === 'published' ? 'published' : 'ready', publishedAt: new Date().toISOString(), }, }); return apiSuccess({ record: published, aggregate: true }); } if (!isPublishTokenConfigured()) { return apiError( API_ERROR_CODES.INTERNAL_ERROR, 503, `Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`, ); } if (isCourseGenerationRunning(courseId)) { return apiError( API_ERROR_CODES.INVALID_REQUEST, 409, 'Course generation is still running; wait before publishing', ); } const sourceBaseUrl = buildRequestOrigin(request); const remoteServerBaseUrl = process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim(); const result = await runCoursePublishExclusive(courseId, async () => { const record = await readCourseRecordReconciled(courseId); if (!record) { throw new CoursePublishValidationError('COURSE_NOT_FOUND', `Course not found: ${courseId}`); } const snapshot = await validateCoursePublishSnapshot(record); return runClassroomSourcesPublishExclusive( snapshot.modules.map(({ classroom }) => classroom.id), async () => { if (remoteServerBaseUrl) { const archives = []; const expectedHashes = new Map(); for (const validatedModule of snapshot.modules) { const index = validatedModule.module.index; const coursewareId = courseModuleCoursewareId(courseId, index); // The placeholder version is canonicalized by the server inside the // same lock that allocates the real immutable version. const packaged = await packagePersistedClassroom(validatedModule.classroom, { coursewareId, version: 1, baseUrl: sourceBaseUrl, // Audit timestamp only. contentHash v2 excludes this volatile // field, so an unchanged source is idempotent across both timeout // retries and an explicit repeated publish. publishedAt: record.updatedAt, }); expectedHashes.set(index, packaged.contentHash); archives.push({ index, zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()), }); } // Never transmit a snapshot that changed while its ZIPs were frozen. await revalidateCoursePublishSnapshot(snapshot); const remoteResult = await publishCourseToServer({ baseUrl: remoteServerBaseUrl, token: process.env[COURSEWARE_PUBLISH_TOKEN_ENV] ?? null, metadata: { protocolVersion: COURSE_PUBLISH_PROTOCOL_VERSION, courseId, title: record.framework!.courseTitle, summary: record.framework!.summary, language: record.framework!.languageDirective, modules: snapshot.modules.map(({ module: moduleRecord, classroom }) => ({ index: moduleRecord.index, title: moduleRecord.title, description: moduleRecord.description, coursewareId: courseModuleCoursewareId(courseId, moduleRecord.index), sourceClassroomId: classroom.id, })), }, archives, }); const publication = buildPublicationReceipt( snapshot, remoteResult.record, expectedHashes, ); await updateCourseRecord(courseId, { publication }); return { ok: true as const, record: remoteResult.record }; } const coursewareRecords = createFileCoursewareRepo(COURSEWARES_DIR); const coursewareBytes = createFileBundleByteStore(COURSEWARE_BUNDLES_DIR); const publicBaseUrl = resolveConfiguredCoursewarePublicBaseUrl(sourceBaseUrl); const expectedHashes = new Map(); const archives = []; for (const validatedModule of snapshot.modules) { const index = validatedModule.module.index; const coursewareId = courseModuleCoursewareId(courseId, index); const packaged = await packagePersistedClassroom(validatedModule.classroom, { coursewareId, version: 1, baseUrl: sourceBaseUrl, publishedAt: record.updatedAt, }); expectedHashes.set(index, packaged.contentHash); archives.push({ index, zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()), }); } await revalidateCoursePublishSnapshot(snapshot); const localResult = await commitRemoteCoursePublish({ metadata: { protocolVersion: COURSE_PUBLISH_PROTOCOL_VERSION, courseId, title: record.framework!.courseTitle, summary: record.framework!.summary, language: record.framework!.languageDirective, modules: snapshot.modules.map(({ module: moduleRecord, classroom }) => ({ index: moduleRecord.index, title: moduleRecord.title, description: moduleRecord.description, coursewareId: courseModuleCoursewareId(courseId, moduleRecord.index), sourceClassroomId: classroom.id, })), }, archives, token: process.env[COURSEWARE_PUBLISH_TOKEN_ENV] ?? null, publicBaseUrl, repos: { coursewares: coursewareRecords, bundles: coursewareBytes }, }); const publication = buildPublicationReceipt(snapshot, localResult.record, expectedHashes); await updateCourseRecord(courseId, { publication }); return { ok: true as const, record: localResult.record }; }, ); }); log.info(`Course published: ${courseId} v${result.record.version}`); return apiSuccess({ record: result.record }); } catch (error) { if (error instanceof CoursePublishInProgressError) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); } if (error instanceof ClassroomSourceMutationInProgressError) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); } if (error instanceof CoursePublishValidationError) { return apiError( API_ERROR_CODES.INVALID_REQUEST, error.code === 'COURSE_NOT_FOUND' ? 404 : 409, error.message, `${error.code}${error.moduleIndex ? ` (module ${error.moduleIndex})` : ''}`, ); } if (error instanceof CoursePublishTransportError) { const remoteInfrastructureFailure = error.status >= 500 || error.status === 401 || error.status === 403; return apiError( remoteInfrastructureFailure ? API_ERROR_CODES.UPSTREAM_ERROR : API_ERROR_CODES.INVALID_REQUEST, remoteInfrastructureFailure ? 502 : error.status, error.message, `${error.errorCode}; phase=${error.phase}${ error.moduleIndex ? `; module=${error.moduleIndex}` : '' }${error.details ? `; ${error.details}` : ''}`, ); } if ( error instanceof Error && /Frozen bundle|cannot be frozen|media|audio|asset|narration|speech|agent|interactive|courseware.*immutable/i.test( error.message, ) ) { return apiError( API_ERROR_CODES.INVALID_REQUEST, 409, 'Course modules are not ready for frozen publication', error.message, ); } log.error('Course publish failed:', error); return apiError(API_ERROR_CODES.INTERNAL_ERROR, 500, 'Failed to publish course'); } }