412 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|