457 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|