import { promises as fs } from 'fs'; import path from 'path'; import { resolveDeploymentRole, type DeploymentRole } from '@/lib/config/deployment-role'; import { COURSE_FRAMEWORK_DIR } from '@/lib/course-framework/store'; import { assertCoursewareId, isValidCoursewareId, resolveDirectChildPath, } from '@/lib/courseware-repo/identity'; import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; import type { CoursewareRepo } from '@/lib/courseware-repo/types'; export type PublishedCourseReceiptLookup = (classroomId: string) => Promise; function resolvedServerRole(): DeploymentRole { return resolveDeploymentRole( process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE, process.env.NODE_ENV, ); } /** Public-serving deployments must not expose mutable sources of frozen content. */ export function protectsPublishedClassroomSources(role: DeploymentRole): boolean { return role === 'learner' || role === 'server'; } /** * Fail-closed lookup of the current ops-side publication receipts. Remote * publishing writes its immutable registry records on the server deployment, * so the ops process cannot rely on its local courseware registry alone when * protecting the mutable classroom source. * * This intentionally covers only the current receipt. Regeneration clears the * receipt; retaining old published sources forever requires a separate product * decision and durable publication history. */ export async function hasPublishedCourseReceiptSource( classroomId: string, frameworkDir = COURSE_FRAMEWORK_DIR, ): Promise { assertCoursewareId(classroomId); let entries; try { entries = await fs.readdir(frameworkDir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; } for (const entry of entries) { if (!entry.name.endsWith('.json')) continue; if (!entry.isFile()) { throw new Error(`Invalid course publication receipt entry: ${entry.name}`); } const courseId = entry.name.slice(0, -'.json'.length); if (!isValidCoursewareId(courseId)) { throw new Error(`Invalid course publication receipt path: ${entry.name}`); } const filePath = resolveDirectChildPath(path.resolve(frameworkDir), entry.name); const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as unknown; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Invalid course publication receipt document: ${entry.name}`); } const record = parsed as Record; if (record.id !== courseId) { throw new Error(`Course publication receipt identity mismatch: ${entry.name}`); } if (record.publication === undefined) continue; if ( !record.publication || typeof record.publication !== 'object' || Array.isArray(record.publication) ) { throw new Error(`Invalid course publication receipt: ${entry.name}`); } const publication = record.publication as Record; if (publication.receiptVersion !== 1 || !Array.isArray(publication.modules)) { throw new Error(`Invalid course publication receipt: ${entry.name}`); } for (const moduleReceipt of publication.modules) { if (!moduleReceipt || typeof moduleReceipt !== 'object' || Array.isArray(moduleReceipt)) { throw new Error(`Invalid course publication module receipt: ${entry.name}`); } const sourceClassroomId = (moduleReceipt as Record).sourceClassroomId; if (!isValidCoursewareId(sourceClassroomId)) { throw new Error(`Invalid course publication source identity: ${entry.name}`); } if (sourceClassroomId === classroomId) return true; } } return false; } /** * Whether a raw classroom id is the source of any learner-visible frozen * courseware. `coursewareId === classroomId` covers records published before * source ids and public ids were separated. */ export async function isPublishedClassroomSource( classroomId: string, repo: CoursewareRepo = createFileCoursewareRepo(COURSEWARES_DIR), receiptLookup: PublishedCourseReceiptLookup = hasPublishedCourseReceiptSource, ): Promise { const foundInRegistry = repo.hasPublishedSource ? await repo.hasPublishedSource(classroomId) : (await repo.listRecords({ status: 'published' })).some( (record) => record.sourceClassroomId === classroomId || record.coursewareId === classroomId, ); return foundInRegistry || receiptLookup(classroomId); } export async function mayAccessRawClassroom( classroomId: string, options: { role?: DeploymentRole; repo?: CoursewareRepo; receiptLookup?: PublishedCourseReceiptLookup; } = {}, ): Promise { const role = options.role ?? resolvedServerRole(); if (!protectsPublishedClassroomSources(role)) return true; return !(await isPublishedClassroomSource(classroomId, options.repo, options.receiptLookup)); }