// 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>; } 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; } const publishLocks = new Map>(); async function withPublishLock(coursewareId: string, fn: () => Promise): Promise { assertCoursewareId(coursewareId); const previous = publishLocks.get(coursewareId) ?? Promise.resolve(); let release!: () => void; const current = new Promise((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 { 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 { 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>; 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 { 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>; 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>, ): Array> { const slides: Array> = [ ...(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>, ): Promise { 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(); 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, }; }