Files

412 lines
15 KiB
TypeScript

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<string, Promise<FrozenLearningPackageRecord>>();
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<string, unknown>).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<string, string> | 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<string, unknown> {
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<FrozenLearningPackageRecord>,
): Promise<FrozenLearningPackageRecord> {
// 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<FrozenLearningPackageRecord> {
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<FrozenLearningPackageRecord> {
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,
};
}