import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types'; import { COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, COURSE_PUBLISH_SERVER_BASE_URL_ENV, COURSE_PUBLISH_TIMEOUT_MS_ENV, CoursePublishTransportError, DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, DEFAULT_COURSE_PUBLISH_TIMEOUT_MS, MAX_COURSE_PUBLISH_METADATA_BYTES, parseRemoteCoursePublishMetadata, positiveIntegerEnv, resolveCoursePublishEndpoint, type RemoteCoursePublishErrorBody, type RemoteCoursePublishMetadata, type RemoteCoursePublishSuccessBody, } from '@/lib/server/course-publish-contract'; const MAX_RESPONSE_BYTES = 1024 * 1024; export interface RemoteCoursePublishUpload { index: number; zipBytes: Uint8Array; } export interface PublishCourseToServerOptions { metadata: RemoteCoursePublishMetadata; archives: RemoteCoursePublishUpload[]; baseUrl?: string; token?: string | null; fetchImpl?: typeof fetch; } export interface PublishCourseToServerResult { record: CourseManifestRecord; idempotent: boolean; } function isErrorBody(value: unknown): value is RemoteCoursePublishErrorBody { if (!value || typeof value !== 'object') return false; const body = value as Partial; return ( body.success === false && typeof body.errorCode === 'string' && typeof body.error === 'string' && (body.phase === 'request' || body.phase === 'staging' || body.phase === 'commit' || body.phase === 'rollback') ); } async function readResponseJson(response: Response): Promise { const text = await response.text(); if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BYTES) { throw new CoursePublishTransportError( 'UPSTREAM_RESPONSE_INVALID', 'Course publish server response exceeds the size limit', 'request', 502, ); } try { return JSON.parse(text) as unknown; } catch { throw new CoursePublishTransportError( 'UPSTREAM_RESPONSE_INVALID', 'Course publish server returned invalid JSON', 'request', 502, ); } } /** One server-only multipart request carrying course metadata plus every module ZIP. */ export async function publishCourseToServer( options: PublishCourseToServerOptions, ): Promise { const metadata = parseRemoteCoursePublishMetadata(options.metadata); const configuredBaseUrl = options.baseUrl?.trim() || process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim(); if (!configuredBaseUrl) { throw new CoursePublishTransportError( 'SERVER_URL_MISSING', `${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not configured`, 'request', 503, ); } const token = options.token ?? process.env.COURSEWARE_PUBLISH_TOKEN; if (!token) { throw new CoursePublishTransportError( 'PUBLISH_TOKEN_MISSING', 'COURSEWARE_PUBLISH_TOKEN is not configured', 'request', 503, ); } const archiveByIndex = new Map(options.archives.map((archive) => [archive.index, archive])); if (archiveByIndex.size !== metadata.modules.length) { throw new CoursePublishTransportError( 'MODULE_ARCHIVE_LAYOUT_MISMATCH', 'Module ZIP count does not match metadata', 'request', 400, ); } const metadataJson = JSON.stringify(metadata); const metadataBytes = new TextEncoder().encode(metadataJson).byteLength; if (metadataBytes > MAX_COURSE_PUBLISH_METADATA_BYTES) { throw new CoursePublishTransportError( 'METADATA_TOO_LARGE', 'Course publish metadata exceeds the size limit', 'request', 413, ); } const maxUploadBytes = positiveIntegerEnv( COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, ); const archiveBytes = options.archives.reduce( (total, archive) => total + archive.zipBytes.byteLength, 0, ); if (metadataBytes + archiveBytes > maxUploadBytes) { throw new CoursePublishTransportError( 'COURSE_UPLOAD_TOO_LARGE', `Course publish exceeds ${maxUploadBytes} bytes`, 'request', 413, ); } const form = new FormData(); form.set('metadata', metadataJson); for (const moduleRecord of metadata.modules) { const archive = archiveByIndex.get(moduleRecord.index); if (!archive) { throw new CoursePublishTransportError( 'MODULE_ARCHIVE_MISSING', `Module ${moduleRecord.index} ZIP is missing`, 'request', 400, moduleRecord.index, ); } form.set( `module-${moduleRecord.index}`, new File([archive.zipBytes as unknown as BlobPart], `module-${moduleRecord.index}.zip`, { type: 'application/zip', }), ); } const endpoint = resolveCoursePublishEndpoint(configuredBaseUrl); const timeoutMs = positiveIntegerEnv( COURSE_PUBLISH_TIMEOUT_MS_ENV, DEFAULT_COURSE_PUBLISH_TIMEOUT_MS, ); let response: Response; try { response = await (options.fetchImpl ?? fetch)(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, redirect: 'error', signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { throw new CoursePublishTransportError( error instanceof DOMException && error.name === 'TimeoutError' ? 'UPSTREAM_TIMEOUT' : 'UPSTREAM_UNAVAILABLE', 'Course publish server could not be reached', 'request', 502, undefined, error instanceof Error ? error.message : undefined, ); } const body = await readResponseJson(response); if (!response.ok || isErrorBody(body)) { if (isErrorBody(body)) { throw new CoursePublishTransportError( body.errorCode, body.error, body.phase, response.status, body.moduleIndex, body.details, ); } throw new CoursePublishTransportError( 'UPSTREAM_RESPONSE_INVALID', `Course publish server failed with HTTP ${response.status}`, 'request', 502, ); } const success = body as Partial; if ( success.success !== true || !success.record || success.record.schemaVersion !== 2 || success.record.courseId !== metadata.courseId || typeof success.idempotent !== 'boolean' ) { throw new CoursePublishTransportError( 'UPSTREAM_RESPONSE_INVALID', 'Course publish server returned an invalid receipt', 'request', 502, ); } return { record: success.record, idempotent: success.idempotent }; }