// Courseware registry API — L2 entry points. // // GET /api/coursewares list latest published coursewares // POST /api/coursewares publish a frozen bundle (ops token) // // The publish endpoint accepts a multipart form: // zip: File the frozen bundle ZIP (produced by the ops packager) // coursewareId: string must equal the bundle's internal id // version?: string explicit version (must match ZIP); when omitted the // server allocates and stamps the next monotonic version // status?: string 'published' | 'unpublished' (default 'published') // // Authorization: `Authorization: Bearer `. The route // refuses to publish when the token is not configured (secure by default). import { type NextRequest } from 'next/server'; import { apiError, apiSuccess, API_ERROR_CODES } from '@/lib/server/api-response'; import { buildRequestOrigin } from '@/lib/server/classroom-storage'; import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; import { COURSEWARE_PUBLISH_TOKEN_ENV, isPublishTokenConfigured, publishCourseware, toSummary, verifyPublishToken, } from '@/lib/courseware-repo'; import { createLogger } from '@/lib/logger'; import { getServerDeploymentRole, requireOpsAccess } from '@/lib/server/ops-access'; import { filterLearnerVisibleCoursewares } from '@/lib/courseware-repo/learner-visibility'; import { isValidCoursewareId, isValidCoursewareVersion } from '@/lib/courseware-repo/identity'; import { CoursePublishTransportError, resolveConfiguredCoursewarePublicBaseUrl, } from '@/lib/server/course-publish-contract'; const log = createLogger('Courseware API'); const MAX_UPLOAD_BYTES = Number(process.env.COURSEWARE_MAX_UPLOAD_BYTES ?? 300 * 1024 * 1024); function bearerToken(request: NextRequest): string | null { const header = request.headers.get('authorization'); if (!header?.startsWith('Bearer ')) return null; return header.slice('Bearer '.length).trim() || null; } export async function GET(_request: NextRequest) { try { const repo = createFileCoursewareRepo(COURSEWARES_DIR); const visible = await filterLearnerVisibleCoursewares( await repo.listRecords({ status: 'published' }), ); const byCourseware = new Map(); for (const record of visible) { const current = byCourseware.get(record.coursewareId); if (!current || record.version > current.version) byCourseware.set(record.coursewareId, record); } const latest = [...byCourseware.values()].sort((a, b) => a.publishedAt < b.publishedAt ? 1 : -1, ); return apiSuccess({ items: latest.map(toSummary) }); } catch (error) { log.error('Courseware listing failed:', error); return apiError(API_ERROR_CODES.INTERNAL_ERROR, 500, 'Courseware listing failed'); } } export async function POST(request: NextRequest) { const role = getServerDeploymentRole(); const sameOriginOpsSession = role === 'ops' || role === 'all'; if (sameOriginOpsSession) { const denied = await requireOpsAccess(request); if (denied) return denied; } else if (role === 'server') { if (!isPublishTokenConfigured()) { return apiError( API_ERROR_CODES.INTERNAL_ERROR, 503, `Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`, ); } if (!verifyPublishToken(bearerToken(request))) { return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Invalid publish token'); } } else { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Publishing is disabled on this deployment'); } try { const form = await request.formData(); const zipFile = form.get('zip'); if (!(zipFile instanceof File)) { return apiError(API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing required field: zip'); } const coursewareId = String(form.get('coursewareId') ?? ''); if (!coursewareId) { return apiError( API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing required field: coursewareId', ); } if (!isValidCoursewareId(coursewareId)) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid coursewareId'); } const versionField = form.get('version'); const version = versionField ? Number(versionField) : undefined; if (version !== undefined && !isValidCoursewareVersion(version)) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid version'); } const statusField = String(form.get('status') ?? 'published'); if (statusField !== 'published' && statusField !== 'unpublished') { return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid status'); } if (zipFile.size > MAX_UPLOAD_BYTES) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 413, 'Bundle exceeds upload size limit'); } const zipBytes = new Uint8Array(await zipFile.arrayBuffer()); const baseUrl = resolveConfiguredCoursewarePublicBaseUrl(buildRequestOrigin(request)); const { record } = await publishCourseware({ zipBytes, coursewareId, baseUrl, version, status: statusField, }); log.info(`Published courseware ${record.coursewareId} v${record.version}`); return apiSuccess({ record: toSummary(record) }, 201); } catch (error) { if (error instanceof CoursePublishTransportError) { return apiError(API_ERROR_CODES.INTERNAL_ERROR, error.status, error.message, error.errorCode); } log.error('Courseware publish failed:', error); return apiError( API_ERROR_CODES.INVALID_REQUEST, 400, 'Courseware publish failed', error instanceof Error ? error.message : undefined, ); } }