360 lines
11 KiB
TypeScript
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);
|
|
}
|