import { createHash, createHmac } from 'node:crypto'; import JSZip from 'jszip'; import type { CourseRecord } from '@/lib/course-framework/types'; import { readCourseRecord } from '@/lib/course-framework/store'; import { runCoursePublishExclusive } from '@/lib/course-framework/publish-state'; import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability'; import { persistFrozenLearningPackage, readFrozenLearningPackageRecord, readUniqueMakeloreCoursePackage, type FrozenLearningPackageRecord, } from '@/lib/makelore-course/package'; import { packagePersistedClassroom } from '@/lib/server/classroom-courseware-publish'; import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage'; const finalizations = new Map>(); function sha256(bytes: Uint8Array | string): string { return createHash('sha256').update(bytes).digest('hex'); } function canonicalJson(_key: string, value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; return Object.fromEntries( Object.entries(value as Record).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0, ), ); } function stableSourceDigest(value: unknown): string { const serialized = JSON.stringify(value, canonicalJson); if (serialized === undefined) throw new Error('Learning course source cannot be serialized'); return sha256(serialized); } function assertSafeBundleEntry(name: string): void { if ( !name || name.startsWith('/') || name.includes('\\') || name.includes('\0') || name.split('/').some((part) => part === '.' || part === '..') ) { throw new Error(`Unsafe frozen bundle entry: ${name}`); } } function archiveSignature(bytes: Uint8Array): Record | undefined { const key = process.env.LEARNING_PACKAGE_SIGNING_KEY; if (!key) return undefined; return { algorithm: 'hmac-sha256', keyId: process.env.LEARNING_PACKAGE_SIGNING_KEY_ID?.trim() || 'default', value: createHmac('sha256', key).update(bytes).digest('base64url'), }; } function sceneKinds(classroom: PersistedClassroomData) { return [...new Set(classroom.scenes.map((scene) => scene.type))].filter((kind) => ['slide', 'interactive', 'quiz', 'pbl'].includes(kind), ); } function classroomCapabilities( classroom: PersistedClassroomData, runtimeCapabilities: unknown, ): Record { return { sceneKinds: sceneKinds(classroom), hasAudio: classroom.scenes.some((scene) => scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)), ), hasWhiteboard: Boolean( classroom.stage.whiteboard?.length || classroom.scenes.some((scene) => Boolean(scene.whiteboards?.length)), ), hasAgent: Boolean(classroom.stage.generatedAgentConfigs?.length), runtime: runtimeCapabilities, }; } async function existingFinalization( courseId: string, sourceJobId: string, sourceDigest: string, mode: FrozenLearningPackageRecord['mode'], ) { const record = await readFrozenLearningPackageRecord(courseId); if (!record) return null; const bytes = await readUniqueMakeloreCoursePackage(courseId); if ( !bytes || bytes.byteLength !== record.archiveBytes || sha256(bytes) !== record.archiveSha256 || record.sourceJobId !== sourceJobId || record.mode !== mode || record.sourceDigest !== sourceDigest ) { throw new FrozenLearningCourseMutationError(courseId); } return record; } async function finalizeSerially( key: string, factory: () => Promise, ): Promise { // Different source revisions of the same course must never share an // in-flight promise. Serialize them, then re-evaluate the persisted cache // against each call's sourceDigest inside the factory. const previous = finalizations.get(key); const ready = previous?.then( () => undefined, () => undefined, ) ?? Promise.resolve(); const promise = ready.then(factory); finalizations.set(key, promise); const cleanup = () => { if (finalizations.get(key) === promise) finalizations.delete(key); }; void promise.then(cleanup, cleanup); return promise; } export async function finalizeSingleLearningCourse(input: { jobId: string; classroom: PersistedClassroomData; baseUrl: string; requireInteractiveHtml: boolean; requireNarrationAudio: boolean; }): Promise { return finalizeSerially(`single:${input.classroom.id}`, async () => { const sourceDigest = stableSourceDigest({ sourceDigestVersion: 1, mode: 'single', courseId: input.classroom.id, classroom: { stage: input.classroom.stage, scenes: input.classroom.scenes, }, requireInteractiveHtml: input.requireInteractiveHtml, requireNarrationAudio: input.requireNarrationAudio, }); const existing = await existingFinalization( input.classroom.id, input.jobId, sourceDigest, 'single', ); if (existing) return existing; const packaged = await packagePersistedClassroom(input.classroom, { coursewareId: input.classroom.id, version: 1, baseUrl: input.baseUrl, requireInteractiveHtml: input.requireInteractiveHtml, requireNarrationAudio: input.requireNarrationAudio, }); const bytes = new Uint8Array(await packaged.zip.arrayBuffer()); const signature = archiveSignature(bytes); const record: FrozenLearningPackageRecord = { // Schema v1 keeps the existing runtime Q&A record reader compatible; // mode and exact frozen-archive metadata are additive. schemaVersion: 1, courseId: input.classroom.id, mode: 'single', sourceJobId: input.jobId, sourceDigest, contentHash: packaged.contentHash, archiveSha256: sha256(bytes), archiveBytes: bytes.byteLength, formatVersion: packaged.meta.formatVersion, minPlayerVersion: '2.0.0', title: input.classroom.stage.name, language: input.classroom.stage.languageDirective, sceneCount: input.classroom.scenes.length, moduleCount: 1, capabilities: { ...classroomCapabilities(input.classroom, packaged.meta.runtimeCapabilities), ...(signature ? { archiveSignature: signature } : {}), }, classroom: { stage: input.classroom.stage, scenes: input.classroom.scenes }, createdAt: packaged.meta.publishedAt, }; await persistFrozenLearningPackage(record, bytes); return record; }); } export async function finalizeLargeLearningCourse(input: { jobId: string; course: CourseRecord; baseUrl: string; }): Promise { return finalizeSerially(`large:${input.course.id}`, () => runCoursePublishExclusive(input.course.id, async () => { // The persisted record is authoritative in production. The fallback keeps // the pure finalizer usable for isolated packaging tests. const course = (await readCourseRecord(input.course.id)) ?? input.course; if (course.status !== 'completed' || !course.framework || course.modules.length === 0) { throw new Error('Large course is not complete'); } const moduleSources = [] as Array<{ moduleRecord: CourseRecord['modules'][number]; classroom: PersistedClassroomData; }>; for (const moduleRecord of [...course.modules].sort((a, b) => a.index - b.index)) { if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) { throw new Error(`Large course module ${moduleRecord.index} is not complete`); } const classroom = await readClassroom(moduleRecord.classroomId); if (!classroom) throw new Error(`Classroom ${moduleRecord.classroomId} is unavailable`); moduleSources.push({ moduleRecord, classroom }); } const sourceDigest = stableSourceDigest({ sourceDigestVersion: 1, mode: 'large', courseId: course.id, framework: course.framework, requireInteractiveHtml: course.interactiveMode ?? true, requireNarrationAudio: course.enableTTS ?? true, modules: moduleSources.map(({ moduleRecord, classroom }) => ({ index: moduleRecord.index, title: moduleRecord.title, description: moduleRecord.description, classroomId: moduleRecord.classroomId, classroom: { stage: classroom.stage, scenes: classroom.scenes }, })), }); const existing = await existingFinalization(course.id, input.jobId, sourceDigest, 'large'); if (existing) return existing; const modulePackages = [] as Array<{ index: number; title: string; description: string; coursewareId: string; classroomId: string; contentHash: string; archiveSha256: string; archiveBytes: number; archivePath: string; sceneCount: number; formatVersion: number; bytes: Uint8Array; capabilities: unknown; }>; for (const { moduleRecord, classroom } of moduleSources) { const coursewareId = `${course.id}_m${moduleRecord.index}`; const packaged = await packagePersistedClassroom(classroom, { coursewareId, version: 1, baseUrl: input.baseUrl, requireInteractiveHtml: course.interactiveMode ?? true, requireNarrationAudio: course.enableTTS ?? true, }); const bytes = new Uint8Array(await packaged.zip.arrayBuffer()); modulePackages.push({ index: moduleRecord.index, title: moduleRecord.title, description: moduleRecord.description, coursewareId, classroomId: classroom.id, contentHash: packaged.contentHash, archiveSha256: sha256(bytes), archiveBytes: bytes.byteLength, archivePath: `modules/${coursewareId}/`, sceneCount: classroom.scenes.length, formatVersion: packaged.meta.formatVersion, bytes, capabilities: packaged.meta.runtimeCapabilities, }); } const structureModules = modulePackages.map(({ bytes: _bytes, capabilities, ...module }) => ({ ...module, capabilities, })); const contentHash = sha256( JSON.stringify([ 'learning-course-aggregate-v1', course.id, structureModules.map((module) => [ module.index, module.coursewareId, module.contentHash, module.archiveSha256, ]), ]), ); const courseStructure = { schemaVersion: 1, kind: 'learning-course-aggregate', courseId: course.id, title: course.framework.courseTitle, summary: course.framework.summary, language: course.framework.languageDirective, targetAudience: course.framework.targetAudience, contentHash, moduleCount: structureModules.length, modules: structureModules, runtimeBoundary: { offline: [ 'playback', 'media', 'audio', 'interactive-html', 'objective-quiz', 'pbl-template', ], hostBridge: ['agent', 'asr', 'pbl-evaluation', 'subjective-quiz-grading'], htmlNetworkAccess: false, }, }; const courseDocument = { schemaVersion: 1, kind: 'learning-course-aggregate', courseId: course.id, title: course.framework.courseTitle, summary: course.framework.summary, language: course.framework.languageDirective, targetAudience: course.framework.targetAudience, contentHash, moduleCount: structureModules.length, modules: structureModules.map((module) => ({ moduleId: module.coursewareId, index: module.index, title: module.title, summary: module.description, sceneCount: module.sceneCount, contentHash: module.contentHash, path: module.archivePath, })), runtimeBoundary: courseStructure.runtimeBoundary, }; const zip = new JSZip(); zip.file('course.json', JSON.stringify(courseDocument, null, 2)); for (const modulePackage of modulePackages) { const source = await JSZip.loadAsync(modulePackage.bytes); for (const required of [ 'manifest.json', 'bundle.json', 'quiz/quiz.json', 'knowledge/knowledge.json', ]) { if (!source.file(required)) { throw new Error(`Module ${modulePackage.coursewareId} is missing ${required}`); } } for (const [name, entry] of Object.entries(source.files)) { if (entry.dir) continue; assertSafeBundleEntry(name); zip.file(`${modulePackage.archivePath}${name}`, await entry.async('uint8array'), { binary: true, }); } } const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); const signature = archiveSignature(bytes); const record: FrozenLearningPackageRecord = { schemaVersion: 2, courseId: course.id, mode: 'large', sourceJobId: input.jobId, sourceDigest, contentHash, archiveSha256: sha256(bytes), archiveBytes: bytes.byteLength, formatVersion: 2, // Aggregate module playback ships in the current Makelore 2.0.0 // consumer; do not advertise a nonexistent 3.x compatibility floor. minPlayerVersion: '2.0.0', title: course.framework.courseTitle, language: course.framework.languageDirective, sceneCount: modulePackages.reduce((total, module) => total + module.sceneCount, 0), moduleCount: modulePackages.length, capabilities: { modular: true, orderedModules: true, productionStagePerModule: true, ...(signature ? { archiveSignature: signature } : {}), }, courseStructure, createdAt: new Date().toISOString(), }; await persistFrozenLearningPackage(record, bytes); return record; }), ); } export function frozenLearningFinalizeResponse(record: FrozenLearningPackageRecord) { return { contractVersion: 2, mode: record.mode, courseId: record.courseId, title: record.title, language: record.language, contentHash: record.contentHash, archiveSha256: record.archiveSha256, archiveBytes: record.archiveBytes, formatVersion: record.formatVersion, minPlayerVersion: record.minPlayerVersion, sceneCount: record.sceneCount, moduleCount: record.moduleCount, capabilities: record.capabilities, courseStructure: record.courseStructure, }; }