Files
openmaic/OpenMAIC/lib/courseware-repo/index.ts
2026-08-16 14:58:47 +08:00

457 lines
17 KiB
TypeScript

// Courseware repository facade — publish/validate/list flows for L2.
//
// `publishCourseware` is the server-side publish gate:
// 1. parse the ZIP documents (frozen bundle contract),
// 2. verify identity (coursewareId, kind, format version),
// 3. recompute the deterministic content hash and compare with `bundle.json`
// — rejects corrupted or tampered uploads,
// 4. allocate the registry version and stamp it into unversioned-upload ZIPs,
// 5. persist canonical bytes, then record metadata, and return the record.
//
// The publish route guards this with {@link verifyPublishToken}; the ops end
// must already have run the packager with `requireComplete: true`, so a broken
// courseware can never reach the registry.
import { timingSafeEqual } from 'crypto';
import {
computeBundleContentHash,
readFrozenBundleDocuments,
rewriteFrozenBundleVersion,
} from '@/lib/bundle/packager';
import { FROZEN_BUNDLE_FORMAT_VERSION, FROZEN_BUNDLE_KIND } from '@/lib/bundle/types';
import { COURSEWARES_DIR, createFileCoursewareRepo } from './store';
import {
COURSEWARE_BUNDLES_DIR,
createFileBundleByteStore,
type BundleByteStore,
} from './bundle-store';
import type { CoursewareRecord, CoursewareRepo, CoursewareStatus } from './types';
import { assertCoursewareId, assertCoursewareVersion, isValidCoursewareId } from './identity';
import { slideMediaReferenceSlots } from '@/lib/media/slide-media-slots';
import type { Slide } from '@openmaic/dsl';
export const COURSEWARE_PUBLISH_TOKEN_ENV = 'COURSEWARE_PUBLISH_TOKEN';
/** The ops→server publish token is configured. */
export function isPublishTokenConfigured(): boolean {
return Boolean(process.env[COURSEWARE_PUBLISH_TOKEN_ENV]);
}
/** Constant-time check of the bearer publish token. */
export function verifyPublishToken(token: string | null | undefined): boolean {
const expected = process.env[COURSEWARE_PUBLISH_TOKEN_ENV];
if (!expected || !token) return false;
const tokenBuf = Buffer.from(token);
const expectedBuf = Buffer.from(expected);
if (tokenBuf.length !== expectedBuf.length) return false;
return timingSafeEqual(tokenBuf, expectedBuf);
}
export interface PublishCoursewareOptions {
zipBytes: Uint8Array | ArrayBuffer;
coursewareId: string;
/** Private large-course ownership used by the learner visibility gate. */
courseId?: string;
courseModuleIndex?: number;
/** Private mutable source id; never returned by the learner summary API. */
sourceClassroomId?: string;
/** Origin for building the public bundle URL (see buildRequestOrigin). */
baseUrl: string;
/** Explicit version; defaults to the next monotonic version. */
version?: number;
status?: CoursewareStatus;
repos?: {
records?: CoursewareRepo;
bytes?: BundleByteStore;
};
}
export interface PublishCoursewareResult {
record: CoursewareRecord;
documents: Awaited<ReturnType<typeof readFrozenBundleDocuments>>;
}
export interface PublishBuiltCoursewareOptions {
coursewareId: string;
/** Private large-course ownership used by the learner visibility gate. */
courseId?: string;
courseModuleIndex?: number;
/** Private mutable source id; never returned by the learner summary API. */
sourceClassroomId?: string;
baseUrl: string;
status?: CoursewareStatus;
repos?: PublishCoursewareOptions['repos'];
/** Build a bundle whose internal meta.version equals the allocated version. */
build: (version: number) => Promise<Uint8Array | ArrayBuffer>;
}
const publishLocks = new Map<string, Promise<void>>();
async function withPublishLock<T>(coursewareId: string, fn: () => Promise<T>): Promise<T> {
assertCoursewareId(coursewareId);
const previous = publishLocks.get(coursewareId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
publishLocks.set(coursewareId, current);
try {
await previous;
return await fn();
} finally {
release();
if (publishLocks.get(coursewareId) === current) publishLocks.delete(coursewareId);
}
}
export async function publishCourseware(
options: PublishCoursewareOptions,
): Promise<PublishCoursewareResult> {
assertCoursewareId(options.coursewareId);
if (options.version !== undefined) assertCoursewareVersion(options.version);
return withPublishLock(options.coursewareId, () => publishCoursewareLocked(options));
}
/**
* Allocate the next immutable version and build/publish it under the same
* per-courseware lock. This is the server-side path; browser uploads cannot
* know the next version before packaging.
*/
export async function publishBuiltCourseware(
options: PublishBuiltCoursewareOptions,
): Promise<PublishCoursewareResult> {
assertCoursewareId(options.coursewareId);
return withPublishLock(options.coursewareId, async () => {
const records = options.repos?.records ?? createFileCoursewareRepo(COURSEWARES_DIR);
const version = await records.nextVersion(options.coursewareId);
const zipBytes = await options.build(version);
return publishCoursewareLocked({
zipBytes,
coursewareId: options.coursewareId,
courseId: options.courseId,
courseModuleIndex: options.courseModuleIndex,
sourceClassroomId: options.sourceClassroomId,
baseUrl: options.baseUrl,
version,
status: options.status,
repos: {
records,
...(options.repos?.bytes ? { bytes: options.repos.bytes } : {}),
},
});
});
}
/**
* Run the complete immutable bundle gate without mutating a repository.
* Reused by publish and by transactional idempotency checks so the server
* never acknowledges bytes that the learner would later reject.
*/
export async function inspectPublishableCoursewareBundle(
zipBytes: Uint8Array | ArrayBuffer,
coursewareId: string,
): Promise<{
documents: Awaited<ReturnType<typeof readFrozenBundleDocuments>>;
contentHash: string;
}> {
assertCoursewareId(coursewareId);
const documents = await readFrozenBundleDocuments(zipBytes);
const { meta, completeness } = documents;
if (meta.kind !== FROZEN_BUNDLE_KIND) {
throw new Error(`Not a frozen bundle (kind=${meta.kind})`);
}
if (meta.formatVersion !== FROZEN_BUNDLE_FORMAT_VERSION) {
throw new Error(
`Unsupported bundle format v${meta.formatVersion} (expected v${FROZEN_BUNDLE_FORMAT_VERSION})`,
);
}
if (meta.coursewareId !== coursewareId) {
throw new Error(`Bundle coursewareId mismatch: ${meta.coursewareId} !== ${coursewareId}`);
}
if (!Number.isInteger(meta.version) || meta.version < 1) {
throw new Error(`Invalid bundle version: ${String(meta.version)}`);
}
const contentHash = await computeBundleContentHash(zipBytes);
if (contentHash !== meta.contentHash) {
throw new Error(
`Bundle content hash mismatch: computed ${contentHash}, declared ${meta.contentHash}`,
);
}
if (!completeness.complete) {
const detail = completeness.missing
.map((missing) => `${missing.kind}:${missing.ref}`)
.join('; ');
throw new Error(`Bundle incomplete: ${detail}`);
}
await validateFrozenArchive(zipBytes, documents);
return { documents, contentHash };
}
async function publishCoursewareLocked(
options: PublishCoursewareOptions,
): Promise<PublishCoursewareResult> {
const {
zipBytes,
coursewareId,
courseId,
courseModuleIndex,
sourceClassroomId,
baseUrl,
version,
status = 'published',
} = options;
const records = options.repos?.records ?? createFileCoursewareRepo(COURSEWARES_DIR);
const bytes = options.repos?.bytes ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR);
assertCoursewareId(coursewareId);
if (version !== undefined) assertCoursewareVersion(version);
if ((courseId === undefined) !== (courseModuleIndex === undefined)) {
throw new Error('Large-course ownership requires both courseId and courseModuleIndex');
}
if (courseId !== undefined && !isValidCoursewareId(courseId)) {
throw new Error(`Invalid owning courseId: ${courseId}`);
}
if (
courseModuleIndex !== undefined &&
(!Number.isSafeInteger(courseModuleIndex) || courseModuleIndex < 1)
) {
throw new Error(`Invalid owning course module index: ${String(courseModuleIndex)}`);
}
if (sourceClassroomId !== undefined && !isValidCoursewareId(sourceClassroomId)) {
throw new Error(`Invalid source classroom id: ${sourceClassroomId}`);
}
let persistedZipBytes = zipBytes instanceof Uint8Array ? zipBytes : new Uint8Array(zipBytes);
const inspected = await inspectPublishableCoursewareBundle(persistedZipBytes, coursewareId);
let documents = inspected.documents;
const { meta, manifest, quiz } = documents;
const computedHash = inspected.contentHash;
const resolvedVersion = version ?? (await records.nextVersion(coursewareId));
if (version !== undefined && meta.version !== version) {
throw new Error(`Bundle version mismatch: declared v${meta.version}, requested v${version}`);
}
if (meta.version !== resolvedVersion) {
// Unversioned browser uploads are templates: only the registry can know
// the next version without a race. Canonicalize bundle.json after version
// allocation, under the same per-courseware lock used for persistence.
persistedZipBytes = await rewriteFrozenBundleVersion(persistedZipBytes, resolvedVersion);
documents = await readFrozenBundleDocuments(persistedZipBytes);
if (
documents.meta.version !== resolvedVersion ||
documents.meta.contentHash !== computedHash ||
(await computeBundleContentHash(persistedZipBytes)) !== computedHash
) {
throw new Error(`Failed to canonicalize frozen bundle version v${resolvedVersion}`);
}
}
const existingRecord = await records.getRecord(coursewareId, resolvedVersion);
if (existingRecord) {
const immutableIdentityMatches =
existingRecord.contentHash === documents.meta.contentHash &&
existingRecord.status === status &&
existingRecord.complete &&
existingRecord.courseId === courseId &&
existingRecord.courseModuleIndex === courseModuleIndex &&
existingRecord.sourceClassroomId === sourceClassroomId;
if (!immutableIdentityMatches) {
throw new Error(
`Courseware ${coursewareId} v${resolvedVersion} already exists and is immutable`,
);
}
const storedZipBytes = await bytes.read(coursewareId, resolvedVersion);
if (!storedZipBytes) {
throw new Error(`Courseware ${coursewareId} v${resolvedVersion} bundle bytes are missing`);
}
let storedInspection: Awaited<ReturnType<typeof inspectPublishableCoursewareBundle>>;
try {
storedInspection = await inspectPublishableCoursewareBundle(storedZipBytes, coursewareId);
} catch {
throw new Error(`Courseware ${coursewareId} v${resolvedVersion} bundle bytes are corrupt`);
}
if (
storedInspection.documents.meta.version !== resolvedVersion ||
storedInspection.contentHash !== existingRecord.contentHash ||
existingRecord.byteSize !== storedZipBytes.byteLength
) {
throw new Error(`Courseware ${coursewareId} v${resolvedVersion} stored identity is invalid`);
}
if (!Buffer.from(storedZipBytes).equals(Buffer.from(persistedZipBytes))) {
throw new Error(
`Courseware ${coursewareId} v${resolvedVersion} already exists and is immutable`,
);
}
return { record: existingRecord, documents };
}
const canonicalMeta = documents.meta;
const storageKey = await bytes.save(coursewareId, resolvedVersion, persistedZipBytes);
const publishedAt = canonicalMeta.publishedAt;
const record: CoursewareRecord = {
coursewareId,
...(courseId !== undefined ? { courseId, courseModuleIndex } : {}),
...(sourceClassroomId ? { sourceClassroomId } : {}),
version: resolvedVersion,
title: manifest.stage.name,
language: canonicalMeta.language,
status,
publishedAt,
contentHash: canonicalMeta.contentHash,
byteSize: persistedZipBytes.byteLength,
entryCount: documents.entryCount,
sceneCount: canonicalMeta.sceneCount,
quizSceneCount: quiz.scenes.length,
knowledgeVersion: canonicalMeta.knowledgeVersion,
complete: true,
bundleUrl: buildBundleDownloadUrl(baseUrl, coursewareId, resolvedVersion),
storageKey,
};
try {
await records.saveRecord(record);
} catch (error) {
await bytes.remove(coursewareId, resolvedVersion).catch(() => undefined);
throw error;
}
return { record, documents };
}
function mediaRefFromPath(zipPath: string, mimeType?: string): string {
const relative = zipPath.startsWith('media/') ? zipPath.slice('media/'.length) : zipPath;
const suffix = mimeType?.split('/')[1];
if (suffix && relative.endsWith(`.${suffix}`)) {
return relative.slice(0, -suffix.length - 1);
}
const slash = relative.lastIndexOf('/');
const dot = relative.lastIndexOf('.');
return dot > slash ? relative.slice(0, dot) : relative;
}
function manifestSlides(
documents: Awaited<ReturnType<typeof readFrozenBundleDocuments>>,
): Array<Pick<Slide, 'background' | 'elements'>> {
const slides: Array<Pick<Slide, 'background' | 'elements'>> = [
...(documents.manifest.stage.whiteboard ?? []),
];
for (const scene of documents.manifest.scenes) {
if (scene.content.type === 'slide') slides.push(scene.content.canvas);
slides.push(...(scene.whiteboards ?? []));
}
return slides;
}
async function validateFrozenArchive(
zipBytes: Uint8Array | ArrayBuffer,
documents: Awaited<ReturnType<typeof readFrozenBundleDocuments>>,
): Promise<void> {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(zipBytes);
const { manifest, meta, completeness } = documents;
if (meta.sceneCount !== manifest.scenes.length) {
throw new Error('Frozen bundle scene count does not match its manifest');
}
if (!manifest.agents?.length || !manifest.agents.some((agent) => agent.role === 'teacher')) {
throw new Error('Frozen bundle must carry a portable teacher roster');
}
const portableMediaRefs = new Set<string>();
for (const [zipPath, media] of Object.entries(manifest.mediaIndex ?? {})) {
if (media.missing) throw new Error(`Frozen bundle marks ${zipPath} as missing`);
if (!zip.file(zipPath)) throw new Error(`Frozen bundle is missing ZIP entry ${zipPath}`);
if (media.type === 'generated' || media.type === 'image') {
portableMediaRefs.add(mediaRefFromPath(zipPath, media.mimeType));
}
}
for (const scene of manifest.scenes) {
if (scene.content.type === 'interactive') {
if (typeof scene.content.html !== 'string' || !scene.content.html.trim()) {
throw new Error(`Interactive scene "${scene.title}" has no frozen HTML`);
}
if (/\b(?:src|href)\s*=\s*["']https?:\/\//i.test(scene.content.html)) {
throw new Error(`Interactive scene "${scene.title}" still references a remote asset`);
}
}
for (const action of scene.actions ?? []) {
if (action.type === 'speech') {
const speech = action as typeof action & { audioRef?: string; audioUrl?: string };
if (!speech.audioRef || speech.audioUrl) {
throw new Error(`Speech action in scene "${scene.title}" is not frozen to bundle audio`);
}
const audioMeta = manifest.mediaIndex[speech.audioRef];
if (audioMeta?.type !== 'audio' || !zip.file(speech.audioRef)) {
throw new Error(`Speech action in scene "${scene.title}" has missing bundle audio`);
}
}
if (
action.type === 'discussion' &&
typeof action.agentIndex === 'number' &&
!manifest.agents[action.agentIndex]
) {
throw new Error(`Discussion action in scene "${scene.title}" has an invalid agent index`);
}
}
for (const index of scene.multiAgent?.agentIndices ?? []) {
if (!manifest.agents[index]) {
throw new Error(`Scene "${scene.title}" has an invalid multi-agent index`);
}
}
}
const assertPortableMediaRef = (ref: string | undefined) => {
if (!ref || ref.startsWith('data:')) return;
if (!portableMediaRefs.has(ref)) {
throw new Error(`Frozen bundle has an external or missing media ref: ${ref}`);
}
};
for (const slide of manifestSlides(documents)) {
for (const slot of slideMediaReferenceSlots(slide)) assertPortableMediaRef(slot.read());
}
for (const ref of Object.keys(manifest.stage.videoManifest ?? {})) {
assertPortableMediaRef(ref);
}
const interactiveCount = manifest.scenes.filter(
(scene) => scene.content.type === 'interactive',
).length;
if (interactiveCount !== completeness.interactiveScenes) {
throw new Error('Frozen bundle interactive scene count does not match completeness metadata');
}
}
export function buildBundleDownloadUrl(
baseUrl: string,
coursewareId: string,
version: number,
): string {
return `${baseUrl.replace(/\/$/, '')}/api/coursewares/${encodeURIComponent(coursewareId)}/bundles/${version}/download`;
}
export function toSummary(record: CoursewareRecord) {
const {
coursewareId,
version,
title,
language,
status,
publishedAt,
contentHash,
sceneCount,
quizSceneCount,
bundleUrl,
} = record;
return {
coursewareId,
version,
title,
language,
status,
publishedAt,
contentHash,
sceneCount,
quizSceneCount,
bundleUrl,
};
}