Files
openmaic/OpenMAIC/lib/server/course-publish-contract.ts
2026-08-16 14:58:47 +08:00

360 lines
11 KiB
TypeScript

import { courseModuleCoursewareId } from '@/lib/course-framework/publish-identity';
import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types';
export const COURSE_PUBLISH_PROTOCOL_VERSION = 1 as const;
export const COURSE_PUBLISH_SERVER_BASE_URL_ENV = 'COURSE_PUBLISH_SERVER_BASE_URL';
export const COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV = 'COURSE_PUBLISH_MAX_UPLOAD_BYTES';
export const COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV = 'COURSE_PUBLISH_ALLOW_INSECURE_HTTP';
export const COURSE_PUBLISH_TIMEOUT_MS_ENV = 'COURSE_PUBLISH_TIMEOUT_MS';
export const COURSEWARE_PUBLIC_BASE_URL_ENV = 'COURSEWARE_PUBLIC_BASE_URL';
export const COURSE_PUBLISH_ENDPOINT_PATH = '/api/internal/course-publish';
export const DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES = 900 * 1024 * 1024;
export const DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES = 300 * 1024 * 1024;
export const DEFAULT_COURSE_PUBLISH_TIMEOUT_MS = 10 * 60 * 1000;
export const MAX_COURSE_PUBLISH_MODULES = 50;
export const MAX_COURSE_PUBLISH_METADATA_BYTES = 1024 * 1024;
export type CoursePublishPhase = 'request' | 'staging' | 'commit' | 'rollback';
export interface RemoteCoursePublishModule {
index: number;
title: string;
description: string;
coursewareId: string;
/** Private mutable source id. Never returned by public learner APIs. */
sourceClassroomId: string;
}
export interface RemoteCoursePublishMetadata {
protocolVersion: typeof COURSE_PUBLISH_PROTOCOL_VERSION;
courseId: string;
title: string;
summary: string;
language?: string;
modules: RemoteCoursePublishModule[];
}
export interface RemoteCoursePublishSuccessBody {
success: true;
record: CourseManifestRecord;
idempotent: boolean;
}
export interface RemoteCoursePublishErrorBody {
success: false;
errorCode: string;
error: string;
phase: CoursePublishPhase;
moduleIndex?: number;
details?: string;
}
export class CoursePublishTransportError extends Error {
constructor(
public readonly errorCode: string,
message: string,
public readonly phase: CoursePublishPhase,
public readonly status: number,
public readonly moduleIndex?: number,
public readonly details?: string,
) {
super(message);
this.name = 'CoursePublishTransportError';
}
}
function textField(value: unknown, name: string, maxLength: number, moduleIndex?: number): string {
if (typeof value !== 'string' || !value.trim()) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`${name} must be a non-empty string`,
'request',
400,
moduleIndex,
);
}
const normalized = value.trim();
if (normalized.length > maxLength) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`${name} exceeds ${maxLength} characters`,
'request',
400,
moduleIndex,
);
}
return normalized;
}
export function parseRemoteCoursePublishMetadata(value: unknown): RemoteCoursePublishMetadata {
if (!value || typeof value !== 'object') {
throw new CoursePublishTransportError(
'INVALID_METADATA',
'Publish metadata must be an object',
'request',
400,
);
}
const input = value as Partial<RemoteCoursePublishMetadata>;
if (input.protocolVersion !== COURSE_PUBLISH_PROTOCOL_VERSION) {
throw new CoursePublishTransportError(
'UNSUPPORTED_PROTOCOL',
`Unsupported course publish protocol: ${String(input.protocolVersion)}`,
'request',
400,
);
}
const courseId = textField(input.courseId, 'courseId', 128);
if (!/^[a-zA-Z0-9_-]+$/.test(courseId)) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`Invalid courseId: ${courseId}`,
'request',
400,
);
}
if (!Array.isArray(input.modules) || input.modules.length === 0) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
'Course publish must include at least one module',
'request',
400,
);
}
if (input.modules.length > MAX_COURSE_PUBLISH_MODULES) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`Course publish exceeds ${MAX_COURSE_PUBLISH_MODULES} modules`,
'request',
400,
);
}
const modules = [...input.modules]
.map((moduleValue, position): RemoteCoursePublishModule => {
if (!moduleValue || typeof moduleValue !== 'object') {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`Module ${position + 1} metadata must be an object`,
'request',
400,
position + 1,
);
}
const moduleInput = moduleValue as Partial<RemoteCoursePublishModule>;
const index = moduleInput.index;
if (!Number.isInteger(index) || (index ?? 0) < 1) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`Invalid module index: ${String(index)}`,
'request',
400,
typeof index === 'number' ? index : position + 1,
);
}
const resolvedIndex = index as number;
const coursewareId = textField(moduleInput.coursewareId, 'coursewareId', 256, resolvedIndex);
const expectedCoursewareId = courseModuleCoursewareId(courseId, resolvedIndex);
if (coursewareId !== expectedCoursewareId) {
throw new CoursePublishTransportError(
'MODULE_IDENTITY_MISMATCH',
`Module ${resolvedIndex} coursewareId must be ${expectedCoursewareId}`,
'request',
409,
resolvedIndex,
);
}
const sourceClassroomId = textField(
moduleInput.sourceClassroomId,
'sourceClassroomId',
256,
resolvedIndex,
);
if (!/^[a-zA-Z0-9_-]+$/.test(sourceClassroomId)) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
`Module ${resolvedIndex} has an invalid sourceClassroomId`,
'request',
400,
resolvedIndex,
);
}
return {
index: resolvedIndex,
title: textField(moduleInput.title, 'module title', 500, resolvedIndex),
description: textField(
moduleInput.description,
'module description',
10_000,
resolvedIndex,
),
coursewareId,
sourceClassroomId,
};
})
.sort((a, b) => a.index - b.index);
if (modules.some((moduleRecord, position) => moduleRecord.index !== position + 1)) {
throw new CoursePublishTransportError(
'INVALID_METADATA',
'Module indexes must be unique and contiguous starting at 1',
'request',
400,
);
}
const language =
typeof input.language === 'string' && input.language.trim()
? textField(input.language, 'language', 500)
: undefined;
return {
protocolVersion: COURSE_PUBLISH_PROTOCOL_VERSION,
courseId,
title: textField(input.title, 'title', 500),
summary: textField(input.summary, 'summary', 20_000),
...(language ? { language } : {}),
modules,
};
}
export function positiveIntegerEnv(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
}
function isLoopback(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
}
/** Resolve one fixed, environment-owned destination; no request data can alter it. */
export function resolveCoursePublishEndpoint(baseUrl: string): URL {
let endpoint: URL;
try {
endpoint = new URL(baseUrl);
} catch {
throw new CoursePublishTransportError(
'INVALID_SERVER_URL',
`${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not a valid URL`,
'request',
503,
);
}
if (endpoint.protocol !== 'https:' && endpoint.protocol !== 'http:') {
throw new CoursePublishTransportError(
'INVALID_SERVER_URL',
`${COURSE_PUBLISH_SERVER_BASE_URL_ENV} must use http or https`,
'request',
503,
);
}
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
throw new CoursePublishTransportError(
'INVALID_SERVER_URL',
`${COURSE_PUBLISH_SERVER_BASE_URL_ENV} cannot contain credentials, query, or fragment`,
'request',
503,
);
}
if (endpoint.pathname !== '/' && endpoint.pathname !== '') {
throw new CoursePublishTransportError(
'INVALID_SERVER_URL',
`${COURSE_PUBLISH_SERVER_BASE_URL_ENV} must be an origin without a path`,
'request',
503,
);
}
if (
endpoint.protocol === 'http:' &&
!isLoopback(endpoint.hostname) &&
process.env[COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV] !== 'true'
) {
throw new CoursePublishTransportError(
'INSECURE_SERVER_URL',
`Plain HTTP publish requires ${COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV}=true`,
'request',
503,
);
}
endpoint.pathname = COURSE_PUBLISH_ENDPOINT_PATH;
return endpoint;
}
/** Validate the canonical learner-visible origin stored in registry bundle URLs. */
export function resolveCoursewarePublicBaseUrl(baseUrl: string): string {
let origin: URL;
try {
origin = new URL(baseUrl);
} catch {
throw new CoursePublishTransportError(
'INVALID_PUBLIC_URL',
`${COURSEWARE_PUBLIC_BASE_URL_ENV} is not a valid URL`,
'request',
503,
);
}
if (origin.protocol !== 'https:' && origin.protocol !== 'http:') {
throw new CoursePublishTransportError(
'INVALID_PUBLIC_URL',
`${COURSEWARE_PUBLIC_BASE_URL_ENV} must use http or https`,
'request',
503,
);
}
if (
origin.username ||
origin.password ||
origin.search ||
origin.hash ||
(origin.pathname !== '/' && origin.pathname !== '')
) {
throw new CoursePublishTransportError(
'INVALID_PUBLIC_URL',
`${COURSEWARE_PUBLIC_BASE_URL_ENV} must be an origin without credentials, path, query, or fragment`,
'request',
503,
);
}
if (
origin.protocol === 'http:' &&
!isLoopback(origin.hostname) &&
process.env[COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV] !== 'true'
) {
throw new CoursePublishTransportError(
'INSECURE_PUBLIC_URL',
`Plain HTTP public URLs require ${COURSE_PUBLISH_ALLOW_INSECURE_HTTP_ENV}=true`,
'request',
503,
);
}
return origin.origin;
}
/**
* Resolve the learner-visible origin from deployment configuration. Production
* publishing never persists request Host/X-Forwarded-Host as a bundle URL.
*/
export function resolveConfiguredCoursewarePublicBaseUrl(fallback?: string): string {
const configured = process.env[COURSEWARE_PUBLIC_BASE_URL_ENV]?.trim();
if (!configured && process.env.NODE_ENV === 'production') {
throw new CoursePublishTransportError(
'PUBLIC_URL_MISSING',
`${COURSEWARE_PUBLIC_BASE_URL_ENV} is required for production publishing`,
'request',
503,
);
}
const candidate = configured || fallback?.trim();
if (!candidate) {
throw new CoursePublishTransportError(
'PUBLIC_URL_MISSING',
`${COURSEWARE_PUBLIC_BASE_URL_ENV} is not configured`,
'request',
503,
);
}
return resolveCoursewarePublicBaseUrl(candidate);
}