// 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 { 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 }; }