Files
openmaic/OpenMAIC/lib/course-manifest-repo/index.ts
T

149 lines
5.4 KiB
TypeScript

// Large-course mode — course publish facade (L1 → L2).
//
// `publishCourse` is the course-level publish gate:
// 1. bearer token check (same COURSEWARE_PUBLISH_TOKEN as coursewares),
// 2. framework completeness (non-empty module list),
// 3. every module has a published courseware record in the L2 courseware repo
// — a course cannot go live with unpublished modules,
// 4. write the manifest (idempotent, version increments each publish).
//
// Learners read the manifest via GET /api/learn/courses/:id (static, zero LLM).
import { createFileCoursewareRepo, COURSEWARES_DIR } from '@/lib/courseware-repo/store';
import { verifyPublishToken } from '@/lib/courseware-repo';
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
import { courseManifestRepo } from './store';
import type { CourseManifestRecord, CourseManifestRepo } from './types';
export interface PublishCourseInput {
courseId: string;
token: string | null | undefined;
title: string;
summary: string;
language?: string;
modules: {
index: number;
title: string;
description: string;
coursewareId: string;
/** Exact pin returned by a just-completed server publish. */
coursewareVersion?: number;
contentHash?: string;
}[];
repos?: { coursewares?: CoursewareRepo; manifests?: CourseManifestRepo };
}
export type PublishCourseResult =
| { ok: true; record: CourseManifestRecord }
| { ok: false; errorCode: string; error: string };
export async function publishCourse(input: PublishCourseInput): Promise<PublishCourseResult> {
if (!verifyPublishToken(input.token)) {
return { ok: false, errorCode: 'UNAUTHORIZED', error: 'Invalid or missing publish token' };
}
if (!/^[a-zA-Z0-9_-]+$/.test(input.courseId)) {
return {
ok: false,
errorCode: 'INVALID_REQUEST',
error: `Invalid courseId: ${input.courseId}`,
};
}
if (input.modules.length === 0) {
return { ok: false, errorCode: 'INVALID_REQUEST', error: 'Course has no modules to publish' };
}
const sortedIndexes = input.modules
.map((moduleRecord) => moduleRecord.index)
.sort((a, b) => a - b);
if (sortedIndexes.some((index, position) => !Number.isInteger(index) || index !== position + 1)) {
return {
ok: false,
errorCode: 'INVALID_REQUEST',
error: 'Module indexes must be unique and contiguous starting at 1',
};
}
for (const moduleRec of input.modules) {
if (!moduleRec.coursewareId || !moduleRec.title || !moduleRec.description) {
return {
ok: false,
errorCode: 'INVALID_REQUEST',
error: `Module ${moduleRec.index} is missing title/description/coursewareId`,
};
}
if (!/^[a-zA-Z0-9_-]+$/.test(moduleRec.coursewareId)) {
return {
ok: false,
errorCode: 'INVALID_REQUEST',
error: `Module ${moduleRec.index} has an invalid coursewareId`,
};
}
const hasVersion = moduleRec.coursewareVersion !== undefined;
const hasHash = moduleRec.contentHash !== undefined;
if (hasVersion !== hasHash) {
return {
ok: false,
errorCode: 'INVALID_REQUEST',
error: `Module ${moduleRec.index} must provide both coursewareVersion and contentHash`,
};
}
}
// Gate and resolve: every module must point at one exact, complete,
// published courseware record. Keep the resolved identity for the manifest.
const records = input.repos?.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR);
const resolvedModules: Array<{
input: PublishCourseInput['modules'][number];
version: number;
contentHash: string;
}> = [];
for (const moduleRec of input.modules) {
const published = moduleRec.coursewareVersion
? await records.getRecord(moduleRec.coursewareId, moduleRec.coursewareVersion)
: await records.getLatestRecord(moduleRec.coursewareId, { status: 'published' });
if (!published || published.status !== 'published') {
return {
ok: false,
errorCode: 'MODULE_NOT_PUBLISHED',
error: `Module ${moduleRec.index} "${moduleRec.title}" is not published (courseware ${moduleRec.coursewareId})`,
};
}
if (!published.complete || !/^[0-9a-f]{64}$/.test(published.contentHash)) {
return {
ok: false,
errorCode: 'MODULE_INCOMPLETE',
error: `Module ${moduleRec.index} "${moduleRec.title}" has no complete immutable bundle`,
};
}
if (moduleRec.contentHash && moduleRec.contentHash !== published.contentHash) {
return {
ok: false,
errorCode: 'MODULE_VERSION_MISMATCH',
error: `Module ${moduleRec.index} courseware hash changed before the course manifest was committed`,
};
}
resolvedModules.push({
input: moduleRec,
version: published.version,
contentHash: published.contentHash,
});
}
const manifests = input.repos?.manifests ?? courseManifestRepo;
const record = await manifests.saveNextRecord({
schemaVersion: 2,
courseId: input.courseId,
title: input.title,
summary: input.summary,
...(input.language ? { language: input.language } : {}),
modules: resolvedModules.map(({ input: moduleRecord, version, contentHash }) => ({
index: moduleRecord.index,
title: moduleRecord.title,
description: moduleRecord.description,
coursewareId: moduleRecord.coursewareId,
coursewareVersion: version,
contentHash,
})),
publishedAt: new Date().toISOString(),
});
return { ok: true, record };
}