import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import JSZip from 'jszip'; import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract'; import type { FrozenBundleDocument, KnowledgePack, QuizPack } from '@/lib/bundle/types'; import type { ClassroomManifest } from '@/lib/export/classroom-zip-types'; import { readFrozenLearningPackageRecord, readUniqueMakeloreCoursePackage, type FrozenLearningPackageRecord, } from '@/lib/makelore-course/package'; import type { CoursewareKnowledge } from '@/lib/qa/knowledge'; import type { Scene, Stage } from '@/lib/types/stage'; const SAFE_ID = /^[A-Za-z0-9_-]{1,96}$/; const SAFE_COURSE_ID = /^[A-Za-z0-9_-]{1,64}$/; const SHA256 = /^[0-9a-f]{64}$/; const MAX_JSON_CHARS = 64 * 1024 * 1024; export interface RuntimeCourseModuleDescriptor { moduleId: string; index: number; title: string; summary: string; contentHash: string; sceneCount: number; root: string; } export interface MakeloreCourseRecord { schemaVersion: 1 | 2; courseId: string; contentHash: string; title: string; language?: string; mode: 'single' | 'large'; sceneCount: number; moduleCount: number; modules: RuntimeCourseModuleDescriptor[]; /** Only retained for pre-frozen compatibility records. */ classroom?: { stage: Stage; scenes: Scene[] }; frozenRecord?: FrozenLearningPackageRecord; archive?: JSZip; } export interface RuntimeCourseModule { course: MakeloreCourseRecord; descriptor: RuntimeCourseModuleDescriptor; manifest?: ClassroomManifest; knowledge: KnowledgePack; quiz: QuizPack; bundle?: FrozenBundleDocument; /** Compatibility path for legacy classroom.json packages. */ classroom?: { stage: Stage; scenes: Scene[] }; } function courseStoreRoot(): string { const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data'); return resolve( process.env.LEARNING_COURSE_STORE_DIR || process.env.MAKELORE_COURSE_STORE_DIR || resolve(dataRoot, 'makelore-courses'), ); } function sha256(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } function record(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function positiveInteger(value: unknown): number | null { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null; } function safeRoot(value: unknown, moduleId: string): string | null { if (typeof value !== 'string') return null; const normalized = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); if (!normalized || normalized.split('/').some((part) => part === '.' || part === '..')) return null; const expected = `modules/${moduleId}`; return normalized === expected ? `${expected}/` : null; } async function zipJson(zip: JSZip, path: string): Promise { const entry = zip.file(path); if (!entry) throw new Error(`Frozen course is missing ${path}`); const text = await entry.async('string'); if (text.length > MAX_JSON_CHARS) throw new Error(`Frozen course document is too large: ${path}`); return JSON.parse(text) as T; } function singleDescriptor( courseId: string, contentHash: string, title: string, sceneCount: number, moduleId = courseId, ): RuntimeCourseModuleDescriptor { return { moduleId, index: 1, title, summary: '', contentHash, sceneCount, root: '' }; } async function loadFrozenCourse( courseId: string, contentHash: string, ): Promise { const frozen = await readFrozenLearningPackageRecord(courseId); if (!frozen || frozen.contentHash !== contentHash) return null; const bytes = await readUniqueMakeloreCoursePackage(courseId); if ( !bytes || bytes.byteLength !== frozen.archiveBytes || sha256(bytes) !== frozen.archiveSha256 ) { return null; } let archive: JSZip; try { archive = await JSZip.loadAsync(bytes, { checkCRC32: true }); } catch { return null; } if (frozen.mode === 'single') { let bundle: FrozenBundleDocument; try { bundle = await zipJson(archive, 'bundle.json'); } catch { return null; } const moduleId = bundle.meta?.coursewareId; if ( typeof moduleId !== 'string' || !SAFE_ID.test(moduleId) || bundle.meta.contentHash !== contentHash || !archive.file('manifest.json') || !archive.file('knowledge/knowledge.json') || !archive.file('quiz/quiz.json') ) { return null; } return { schemaVersion: frozen.schemaVersion, courseId, contentHash, title: frozen.title, language: frozen.language, mode: 'single', sceneCount: frozen.sceneCount, moduleCount: 1, modules: [singleDescriptor(courseId, contentHash, frozen.title, frozen.sceneCount, moduleId)], frozenRecord: frozen, archive, }; } const structure = record(frozen.courseStructure); const structureModules = Array.isArray(structure?.modules) ? structure.modules.map(record).filter((item): item is Record => item !== null) : []; let courseDocument: Record; try { courseDocument = record(await zipJson(archive, 'course.json')) ?? {}; } catch { return null; } const courseModules = Array.isArray(courseDocument.modules) ? courseDocument.modules.map(record).filter((item): item is Record => item !== null) : []; if ( structure?.kind !== 'learning-course-aggregate' || structure.contentHash !== contentHash || courseDocument.kind !== 'learning-course-aggregate' || courseDocument.courseId !== courseId || courseDocument.contentHash !== contentHash || structureModules.length === 0 || courseModules.length !== structureModules.length ) { return null; } const modules: RuntimeCourseModuleDescriptor[] = []; for (const [position, source] of structureModules.entries()) { const moduleId = source.coursewareId; const moduleHash = source.contentHash; const index = positiveInteger(source.index); const declared = courseModules.find((candidate) => candidate.moduleId === moduleId); const root = safeRoot(declared?.path, typeof moduleId === 'string' ? moduleId : ''); if ( typeof moduleId !== 'string' || !SAFE_ID.test(moduleId) || typeof moduleHash !== 'string' || !SHA256.test(moduleHash) || index === null || !declared || declared.contentHash !== moduleHash || declared.index !== index || !root || !archive.file(`${root}manifest.json`) || !archive.file(`${root}bundle.json`) || !archive.file(`${root}knowledge/knowledge.json`) || !archive.file(`${root}quiz/quiz.json`) ) { return null; } const sceneCount = positiveInteger(source.sceneCount); modules.push({ moduleId, index, title: typeof source.title === 'string' ? source.title : `Module ${position + 1}`, summary: typeof source.description === 'string' ? source.description : '', contentHash: moduleHash, sceneCount: sceneCount ?? 0, root, }); } if ( new Set(modules.map((module) => module.moduleId)).size !== modules.length || modules.some((module, index) => index > 0 && module.index <= modules[index - 1]!.index) ) { return null; } return { schemaVersion: frozen.schemaVersion, courseId, contentHash, title: frozen.title, language: frozen.language, mode: 'large', sceneCount: frozen.sceneCount, moduleCount: modules.length, modules, frozenRecord: frozen, archive, }; } async function loadLegacyCourse( courseId: string, contentHash: string, ): Promise { let value: unknown; try { value = JSON.parse(await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8')); } catch { return null; } const legacy = record(value); const classroom = record(legacy?.classroom); if ( legacy?.schemaVersion !== 1 || legacy.courseId !== courseId || legacy.contentHash !== contentHash || typeof legacy.title !== 'string' || !classroom || !record(classroom.stage) || !Array.isArray(classroom.scenes) ) { return null; } const typedClassroom = classroom as unknown as { stage: Stage; scenes: Scene[] }; return { schemaVersion: 1, courseId, contentHash, title: legacy.title, ...(typeof legacy.language === 'string' ? { language: legacy.language } : {}), mode: 'single', sceneCount: typedClassroom.scenes.length, moduleCount: 1, modules: [singleDescriptor(courseId, contentHash, legacy.title, typedClassroom.scenes.length)], classroom: typedClassroom, }; } export function parseLearningBinding(key: string): { courseId: string; contentHash: string } | null { const split = key.lastIndexOf(':'); if (split <= 0) return null; const courseId = key.slice(0, split); const contentHash = key.slice(split + 1); return SAFE_COURSE_ID.test(courseId) && SHA256.test(contentHash) ? { courseId, contentHash } : null; } /** Resolve only an exact aggregate course ID + content hash. */ export async function loadMakeloreCourse( courseId: string, contentHash: string, ): Promise { if (!SAFE_COURSE_ID.test(courseId) || !SHA256.test(contentHash)) return null; return (await loadFrozenCourse(courseId, contentHash)) ?? loadLegacyCourse(courseId, contentHash); } export async function resolveMakeloreCourseModule( course: MakeloreCourseRecord, selection: { moduleId?: string | null; moduleContentHash?: string | null } = {}, ): Promise { let descriptor: RuntimeCourseModuleDescriptor | undefined; if (course.mode === 'large') { if (!selection.moduleId || !selection.moduleContentHash) return null; descriptor = course.modules.find((module) => module.moduleId === selection.moduleId); if (!descriptor || descriptor.contentHash !== selection.moduleContentHash) return null; } else { descriptor = course.modules[0]; if (!descriptor) return null; if ( selection.moduleId && selection.moduleId !== descriptor.moduleId && selection.moduleId !== course.courseId ) { return null; } if (selection.moduleContentHash && selection.moduleContentHash !== course.contentHash) return null; } if (!course.archive) { const classroom = course.classroom; if (!classroom) return null; return { course, descriptor, classroom, knowledge: extractKnowledgePack(descriptor.moduleId, classroom.stage, classroom.scenes), quiz: extractQuizPack(classroom.scenes), }; } try { const [bundle, manifest, knowledge, quiz] = await Promise.all([ zipJson(course.archive, `${descriptor.root}bundle.json`), zipJson(course.archive, `${descriptor.root}manifest.json`), zipJson(course.archive, `${descriptor.root}knowledge/knowledge.json`), zipJson(course.archive, `${descriptor.root}quiz/quiz.json`), ]); if ( bundle.meta?.coursewareId !== descriptor.moduleId || bundle.meta.contentHash !== descriptor.contentHash || !Array.isArray(manifest.scenes) || !Array.isArray(knowledge.scenes) || !Array.isArray(quiz.scenes) || manifest.scenes.length !== descriptor.sceneCount ) { return null; } return { course, descriptor, manifest, knowledge, quiz, bundle }; } catch { return null; } } export function courseKnowledge(module: RuntimeCourseModule): CoursewareKnowledge { return { coursewareId: module.descriptor.moduleId, title: module.descriptor.title, language: module.bundle?.meta.language ?? module.course.language, knowledge: module.knowledge, quiz: module.quiz, }; } /** Synthetic scene identity used by Makelore when materializing a frozen manifest. */ export function runtimeSceneId( course: MakeloreCourseRecord, module: RuntimeCourseModuleDescriptor, sceneIndex: number, ): string { return `learning_${course.courseId}_${module.moduleId}_s${sceneIndex}`; }