126 lines
5.0 KiB
TypeScript
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));
|
|
}
|