Files
openmaic/OpenMAIC/lib/server/published-classroom-access.ts
2026-08-16 14:58:47 +08:00

126 lines
5.0 KiB
TypeScript

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<boolean>;
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<boolean> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<boolean> {
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<boolean> {
const role = options.role ?? resolvedServerRole();
if (!protectsPublishedClassroomSources(role)) return true;
return !(await isPublishedClassroomSource(classroomId, options.repo, options.receiptLookup));
}