// Frozen courseware packager — turns a classroom payload (stage + scenes) // into an immutable, versioned, self-contained bundle ZIP. // // The packager is isomorphic: it runs in the browser (ops workbench, bytes // from IndexedDB) and on the server (publish route, bytes from the asset // registry) — bytes always arrive through injected resolvers. The only global // it needs beyond plain JS is WebCrypto (`crypto.subtle`), which both browsers // and Node ≥19 provide. // // Publish policy ("freeze"): the bundle is complete only when every speech // action resolves to narration bytes, every media record resolves to bytes, // and interactive HTML inlines without failures. `requireComplete` turns any // gap into a hard error — the publish route should pass `true` so a broken // courseware can never be published. import { inlineHtmlAssets, type InlineReport } from '@/lib/export/inline-assets'; import type { SpeechAction } from '@/lib/types/action'; import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage'; import type { MediaFileRecord } from '@/lib/utils/database'; import { buildManifest } from './serialize'; import { extractKnowledgePack, extractQuizPack } from './extract'; import { FROZEN_BUNDLE_CONTENT_HASH_VERSION, FROZEN_BUNDLE_FORMAT_VERSION, FROZEN_BUNDLE_KIND, FROZEN_BUNDLE_KNOWLEDGE_FILE, FROZEN_BUNDLE_MANIFEST_FILE, FROZEN_BUNDLE_QUIZ_FILE, type CompletenessReport, type FrozenBundleMeta, type KnowledgePack, type MissingResource, type QuizPack, } from './types'; export interface PackageSources { coursewareId: string; /** * Publish version claim written into `bundle.json`. Server-side builders pass * the version allocated under the registry lock. Browser packaging leaves * this unset (defaults to 1); an unversioned upload is treated as a template * and the registry stamps the allocated version into the stored ZIP. */ version?: number; stage: Stage; scenes: Scene[]; agentConfigs?: GeneratedAgentConfig[]; /** Media records to ship (caller-owned listing, e.g. asset registry). */ mediaRecords?: MediaFileRecord[]; /** Resolve narration bytes by audioId. Absent audioId → missing resource. */ resolveAudioBytes?: (audioId: string) => Promise; /** Resolve media bytes by record. Absent record → missing resource. */ resolveMediaBytes?: (record: MediaFileRecord) => Promise; /** * Optional scene preparation hook (e.g. PBL scenes → design templates before * persistence). Defaults to identity — the ops end wires its real prep. */ prepareScenes?: (stageId: string, scenes: readonly Scene[]) => Promise; /** Fetch implementation for inlining interactive HTML assets. Default: global fetch. */ fetchImpl?: typeof fetch; publishedAt?: string; appVersion?: string; /** Fail the whole publish on any missing resource. Default false (report only). */ requireComplete?: boolean; /** Require generated bytes for narration scripts. Disable only when TTS was intentionally off. */ requireNarrationAudio?: boolean; /** Require at least one portable agent persona snapshot. */ requireAgentRoster?: boolean; /** Require at least one interactive Scene with generated, non-empty HTML. */ requireInteractiveHtml?: boolean; /** Remove online import-map fallbacks and reject every residual HTML asset URL. */ strictInteractiveAssets?: boolean; } export interface PackageResult { zip: Blob; contentHash: string; entryCount: number; manifest: ReturnType; meta: FrozenBundleMeta; completeness: CompletenessReport; quiz: QuizPack; knowledge: KnowledgePack; inlineReport: InlineReport; } interface CollectedAudio { path: string; bytes: Uint8Array; sha: string; ext: string; audioId: string; } interface CollectedMedia { path: string; bytes: Uint8Array; sha: string; ext: string; elementId: string; posterPath?: string; posterBytes?: Uint8Array; record: MediaFileRecord; } async function sha256Hex(bytes: Uint8Array): Promise { // Isomorphic: WebCrypto exists in browsers and Node ≥19. The cast covers the // TS 5.7 `Uint8Array` → `BufferSource` mismatch; callers // always hold real ArrayBuffer-backed views (jszip output / arrayBuffer()). const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); } const encoder = new TextEncoder(); const decoder = new TextDecoder(); function contentHashVersion(value: unknown): 1 | typeof FROZEN_BUNDLE_CONTENT_HASH_VERSION { if (value === undefined) return 1; if (value === 1 || value === FROZEN_BUNDLE_CONTENT_HASH_VERSION) return value; throw new Error(`Unsupported frozen bundle content hash version: ${String(value)}`); } /** Hash one payload file under the selected backward-compatible algorithm. */ async function hashPayloadFile( name: string, bytes: Uint8Array, version: 1 | typeof FROZEN_BUNDLE_CONTENT_HASH_VERSION, ): Promise { if (version >= 2 && name === 'manifest.json') { const parsed = JSON.parse(decoder.decode(bytes)) as Record; // exportedAt records transport time, not classroom content. Normalizing it // makes a lost-response retry package the same immutable payload identity. delete parsed.exportedAt; return sha256Hex(encoder.encode(JSON.stringify(parsed))); } return sha256Hex(bytes); } async function blobToBytes(blob: Blob): Promise { return new Uint8Array(await blob.arrayBuffer()); } function missing( kind: MissingResource['kind'], ref: string, reason: string, sceneId?: string, ): MissingResource { return { kind, ref, reason, ...(sceneId ? { sceneId } : {}) }; } function residualExecutableNetworkDependencies(html: string): string[] { const dependencies = new Set(); for (const match of html.matchAll(/]*>([\s\S]*?)<\/script>/gi)) { const script = match[1] ?? ''; for (const url of script.match(/https?:\/\/[^\s'"`<>\\)]+/gi) ?? []) { dependencies.add(url); } // Inlining pyodide.js does not inline its wasm/stdlib downloads. Until the // player ships a frozen local Pyodide distribution, treating loadPyodide // as complete would create a package that predictably fails under the // offline CSP (`connect-src 'none'`). if (/\bloadPyodide\s*\(/.test(script)) dependencies.add('dynamic:loadPyodide'); } return [...dependencies]; } /** * Package a classroom into a frozen bundle. See module doc for policy. */ export async function packageCourseware(sources: PackageSources): Promise { const JSZip = (await import('jszip')).default; const zip = new JSZip(); const { coursewareId, version = 1, stage, scenes: rawScenes, mediaRecords = [], resolveAudioBytes, resolveMediaBytes, prepareScenes, fetchImpl, publishedAt = new Date().toISOString(), appVersion = '0.0.0', requireComplete = false, requireNarrationAudio = true, requireAgentRoster = false, requireInteractiveHtml = false, strictInteractiveAssets = false, } = sources; const scenes = prepareScenes ? await prepareScenes(stage.id, rawScenes) : [...rawScenes]; const agentConfigs = sources.agentConfigs ?? stage.generatedAgentConfigs ?? []; if (requireAgentRoster && agentConfigs.length === 0) { throw new Error(`Frozen bundle has no portable agent roster (${coursewareId} v${version})`); } const missingResources: MissingResource[] = []; // ── 1. Collect narration audio ───────────────────────────────────────── const audioIdToPath = new Map(); const collectedAudio: CollectedAudio[] = []; for (const scene of scenes) { for (const action of scene.actions ?? []) { if (action.type !== 'speech') continue; const speech = action as SpeechAction; if (!speech.audioId) { if (requireNarrationAudio) { missingResources.push( missing( 'audio', `${scene.id}:${speech.text.slice(0, 40)}`, 'speech action without audioId', scene.id, ), ); } continue; } if (audioIdToPath.has(speech.audioId)) continue; const record = await resolveAudioBytes?.(speech.audioId); if (!record) { if (requireNarrationAudio) { missingResources.push(missing('audio', speech.audioId, 'no narration bytes', scene.id)); } continue; } const ext = record.type.split('/')[1]?.replace('mpeg', 'mp3') || 'mp3'; const path = `audio/${speech.audioId}.${ext}`; const bytes = await blobToBytes(record); audioIdToPath.set(speech.audioId, path); collectedAudio.push({ path, bytes, sha: await sha256Hex(bytes), ext, audioId: speech.audioId, }); zip.file(path, bytes); } } // ── 2. Collect media ─────────────────────────────────────────────────── const collectedMedia: CollectedMedia[] = []; for (const record of mediaRecords) { if (record.error) { missingResources.push(missing('media', record.id, 'media task failed', record.stageId)); continue; } const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id; const blob = await resolveMediaBytes?.(record); if (!blob) { missingResources.push(missing('media', elementId, 'no media bytes', record.stageId)); continue; } const ext = record.mimeType?.split('/')[1] || 'jpg'; const path = `media/${elementId}.${ext}`; const bytes = await blobToBytes(blob); let posterPath: string | undefined; let posterBytes: Uint8Array | undefined; if (record.poster) { posterPath = path.replace(/\.\w+$/, '.poster.jpg'); posterBytes = await blobToBytes(record.poster); } collectedMedia.push({ path, bytes, sha: await sha256Hex(bytes), ext, elementId, posterPath, posterBytes, record, }); zip.file(path, bytes); if (posterPath && posterBytes) zip.file(posterPath, posterBytes); } // ── 3. Inline interactive HTML ───────────────────────────────────────── const inlineReport: InlineReport = { inlined: [], failed: [] }; const preparedScenes: Scene[] = (await Promise.all( scenes.map(async (scene) => { const content = scene.content; if ( content?.type !== 'interactive' || !('html' in content) || typeof content.html !== 'string' || !content.html ) { return scene; } const { html, report, unresolved } = await inlineHtmlAssets(content.html, { ...(fetchImpl ? { fetchImpl } : {}), ...(strictInteractiveAssets ? { keepImportmapFallbacks: false } : {}), }); inlineReport.inlined.push(...report.inlined); inlineReport.failed.push(...report.failed); if (strictInteractiveAssets) { for (const url of unresolved) { missingResources.push( missing('interactive', url, 'interactive asset remained external', scene.id), ); } for (const dependency of residualExecutableNetworkDependencies(html)) { missingResources.push( missing( 'interactive', dependency, dependency === 'dynamic:loadPyodide' ? 'Pyodide runtime is not frozen for offline playback' : 'inline script retains an external runtime URL', scene.id, ), ); } } return { ...scene, content: { ...content, html } } as Scene; }), )).map((scene) => { if (requireNarrationAudio || !scene.actions?.length) return scene; const actions = scene.actions.map((action) => { if (action.type !== 'speech' || (action.audioId && audioIdToPath.has(action.audioId))) { return action; } // A deliberately silent bundle must not retain a remote audio URL. The // player will use its narration-duration fallback and never reach the // network for a resource the operator explicitly chose not to generate. const { audioUrl: _audioUrl, ...silentSpeech } = action; return silentSpeech; }); return { ...scene, actions } as Scene; }); for (const failure of inlineReport.failed) { missingResources.push( missing('interactive', failure.url, 'interactive asset could not be inlined'), ); } if (requireInteractiveHtml) { const interactiveHtmlScenes = preparedScenes.filter( (scene) => scene.content?.type === 'interactive' && typeof scene.content.html === 'string' && scene.content.html.trim().length > 0, ); if (interactiveHtmlScenes.length === 0) { missingResources.push( missing('interactive', coursewareId, 'no interactive scene with generated HTML'), ); } } // ── 4. Media index (mirrors the interactive-ZIP export) ──────────────── const mediaIndex: Record = {}; for (const audio of collectedAudio) { mediaIndex[audio.path] = { type: 'audio', format: audio.ext }; } for (const media of collectedMedia) { mediaIndex[media.path] = { type: 'generated', mimeType: media.record.mimeType, size: media.record.size, prompt: media.record.prompt, }; } if (requireNarrationAudio) { // Strict narration packages expose the gap to the player and to the // completeness gate. TTS-disabled packages intentionally omit the ref. for (const scene of scenes) { for (const action of scene.actions ?? []) { if (action.type === 'speech') { const audioId = (action as SpeechAction).audioId; if (audioId && !audioIdToPath.has(audioId)) { mediaIndex[`audio/${audioId}.mp3`] = { type: 'audio', missing: true }; } } } } } // ── 5. Manifest ──────────────────────────────────────────────────────── const manifest = buildManifest( stage, preparedScenes, { appVersion, audioIdToPath, agentConfigs, exportedAt: publishedAt, }, mediaIndex, ); // ── 6. Quiz + knowledge packs ────────────────────────────────────────── const quiz = extractQuizPack(preparedScenes); const knowledge: KnowledgePack = extractKnowledgePack(coursewareId, stage, preparedScenes); // ── 7. Completeness ──────────────────────────────────────────────────── const completeness: CompletenessReport = { complete: missingResources.length === 0, missing: missingResources, audioCount: collectedAudio.length, mediaCount: collectedMedia.length, interactiveScenes: preparedScenes.filter((s) => s.content?.type === 'interactive').length, }; if (requireComplete && !completeness.complete) { const detail = completeness.missing.map((m) => `${m.kind}:${m.ref} (${m.reason})`).join('; '); throw new Error(`Frozen bundle incomplete (${coursewareId} v${version}): ${detail}`); } // ── 8. Deterministic content hash over the payload ───────────────────── // Every file except bundle.json (which carries the hash) contributes its // byte-sha256 under its ZIP path; paths are sorted before hashing so the // hash is independent of ZIP internals and verifiable after upload by // {@link computeBundleContentHash}. const manifestJson = JSON.stringify(manifest, null, 2); const quizJson = JSON.stringify(quiz, null, 2); const knowledgeJson = JSON.stringify(knowledge, null, 2); const hashParts: Array<[string, string]> = [ [ 'manifest.json', await hashPayloadFile( 'manifest.json', encoder.encode(manifestJson), FROZEN_BUNDLE_CONTENT_HASH_VERSION, ), ], [FROZEN_BUNDLE_QUIZ_FILE, await sha256Hex(new TextEncoder().encode(quizJson))], [FROZEN_BUNDLE_KNOWLEDGE_FILE, await sha256Hex(new TextEncoder().encode(knowledgeJson))], ]; for (const audio of collectedAudio) hashParts.push([audio.path, audio.sha]); for (const media of collectedMedia) { hashParts.push([media.path, media.sha]); if (media.posterPath && media.posterBytes) { hashParts.push([media.posterPath, await sha256Hex(media.posterBytes)]); } } hashParts.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const contentHash = await sha256Hex(new TextEncoder().encode(JSON.stringify(hashParts))); // ── 9. Bundle metadata + files ───────────────────────────────────────── const meta: FrozenBundleMeta = { kind: FROZEN_BUNDLE_KIND, formatVersion: FROZEN_BUNDLE_FORMAT_VERSION, coursewareId, version, publishedAt, appVersion, stageName: stage.name, ...(stage.languageDirective ? { language: stage.languageDirective } : {}), sceneCount: preparedScenes.length, contentHash, contentHashVersion: FROZEN_BUNDLE_CONTENT_HASH_VERSION, knowledgeVersion: 1, runtimeCapabilities: { offlinePlayback: true, htmlSandbox: { externalNetwork: false, hostBridgeOnly: true }, packaged: { audio: collectedAudio.length > 0, media: collectedMedia.length > 0, interactiveHtml: preparedScenes.some((scene) => scene.content?.type === 'interactive'), pblTemplate: preparedScenes.some((scene) => scene.content?.type === 'pbl'), objectiveQuizGrading: preparedScenes.some((scene) => scene.content?.type === 'quiz'), }, hostBridges: { agent: 'online-optional', asr: 'online-optional', pblEvaluation: 'online-optional', subjectiveQuizGrading: 'online-optional', }, }, }; zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify({ meta, completeness }, null, 2)); zip.file('manifest.json', manifestJson); zip.file(FROZEN_BUNDLE_QUIZ_FILE, quizJson); zip.file(FROZEN_BUNDLE_KNOWLEDGE_FILE, knowledgeJson); const bytes = await zip.generateAsync({ type: 'uint8array' }); const zipBlob = new Blob([bytes as unknown as BlobPart], { type: 'application/zip' }); const entryCount = Object.values(zip.files).filter((file) => !file.dir).length; return { zip: zipBlob, contentHash, entryCount, manifest, meta, completeness, quiz, knowledge, inlineReport, }; } /** Normalize a ZIP source for jszip (Node's Blob is not directly consumable). */ async function toZipInput( zipBlob: Blob | Uint8Array | ArrayBuffer, ): Promise { if (typeof Blob !== 'undefined' && zipBlob instanceof Blob) return zipBlob.arrayBuffer(); // The guard above can't statically exclude Blob (Blob may be undefined at // runtime, defeating the instanceof narrowing); callers never pass a Blob // here when the global is absent. return zipBlob as Uint8Array | ArrayBuffer; } /** * Recompute the deterministic payload hash from a ZIP's files (everything * except `bundle.json`). Must agree with `packageCourseware().contentHash`; * the publish route uses this to reject corrupted or tampered uploads. */ export async function computeBundleContentHash( zipBlob: Blob | Uint8Array | ArrayBuffer, ): Promise { const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await toZipInput(zipBlob)); const bundleFile = zip.file(FROZEN_BUNDLE_MANIFEST_FILE); if (!bundleFile) { throw new Error(`Frozen bundle is missing ${FROZEN_BUNDLE_MANIFEST_FILE}`); } const bundleDocument = JSON.parse(await bundleFile.async('string')) as { meta?: Pick; }; const hashVersion = contentHashVersion(bundleDocument.meta?.contentHashVersion); const hashParts: Array<[string, string]> = []; for (const [name, file] of Object.entries(zip.files)) { if (file.dir || name === FROZEN_BUNDLE_MANIFEST_FILE) continue; const bytes = new Uint8Array(await file.async('uint8array')); hashParts.push([name, await hashPayloadFile(name, bytes, hashVersion)]); } hashParts.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return sha256Hex(new TextEncoder().encode(JSON.stringify(hashParts))); } /** * Rewrite only the registry-owned publish version in `bundle.json`. * * Browser packaging cannot safely predict the next monotonic registry * version. The publish service calls this after allocating the version under * its per-courseware lock. `bundle.json` is deliberately excluded from the * deterministic payload hash, so this operation preserves `contentHash` while * making the stored ZIP's immutable identity agree with the registry record. */ export async function rewriteFrozenBundleVersion( zipBlob: Blob | Uint8Array | ArrayBuffer, version: number, ): Promise { if (!Number.isInteger(version) || version < 1) { throw new Error(`Invalid frozen bundle version: ${version}`); } const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await toZipInput(zipBlob)); const manifestFile = zip.file(FROZEN_BUNDLE_MANIFEST_FILE); if (!manifestFile) { throw new Error(`Frozen bundle is missing ${FROZEN_BUNDLE_MANIFEST_FILE}`); } const document = JSON.parse(await manifestFile.async('string')) as { meta?: FrozenBundleMeta; completeness?: CompletenessReport; }; if (!document.meta || !document.completeness) { throw new Error(`Frozen bundle has invalid ${FROZEN_BUNDLE_MANIFEST_FILE}`); } document.meta = { ...document.meta, version }; zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify(document, null, 2)); return zip.generateAsync({ type: 'uint8array' }); } /** Unzip a frozen bundle's document files (manifest/bundle/quiz/knowledge) without extracting media. */ export async function readFrozenBundleDocuments(zipBlob: Blob | Uint8Array | ArrayBuffer): Promise<{ manifest: ReturnType; meta: FrozenBundleMeta; completeness: CompletenessReport; quiz: QuizPack; knowledge: KnowledgePack; entryCount: number; }> { const JSZip = (await import('jszip')).default; const data = await toZipInput(zipBlob); const zip = await JSZip.loadAsync(data); const parse = async (name: string): Promise => JSON.parse(await zip.file(name)!.async('string')) as T; const [manifest, bundleDocument, quiz, knowledge] = await Promise.all([ parse>('manifest.json'), parse<{ meta: FrozenBundleMeta; completeness: CompletenessReport }>( FROZEN_BUNDLE_MANIFEST_FILE, ), parse(FROZEN_BUNDLE_QUIZ_FILE), parse(FROZEN_BUNDLE_KNOWLEDGE_FILE), ]); return { manifest, meta: bundleDocument.meta, completeness: bundleDocument.completeness, quiz, knowledge, entryCount: Object.values(zip.files).filter((file) => !file.dir).length, }; }