364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
// Large-course mode API — publish a course (L1 → L2).
|
|
//
|
|
// POST /api/courses/:courseId/publish
|
|
//
|
|
// Authorization: the ops deployment role + ACCESS_CODE session are checked at
|
|
// the route boundary. The server-only COURSEWARE_PUBLISH_TOKEN is then used for
|
|
// the internal L1→L2 publish gate and is never sent to the browser.
|
|
|
|
import { type NextRequest } from 'next/server';
|
|
import { apiError, apiSuccess, API_ERROR_CODES } from '@/lib/server/api-response';
|
|
import { createLogger } from '@/lib/logger';
|
|
import {
|
|
isValidCourseId,
|
|
readCourseRecordReconciled,
|
|
updateCourseRecord,
|
|
} from '@/lib/course-framework/store';
|
|
import { COURSEWARE_PUBLISH_TOKEN_ENV, isPublishTokenConfigured } from '@/lib/courseware-repo';
|
|
import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types';
|
|
import type { CoursePublicationReceipt } from '@/lib/course-framework/types';
|
|
import {
|
|
configuredWorksSquareApiOrigin,
|
|
requireOpsAccess,
|
|
} from '@/lib/server/ops-access';
|
|
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
|
|
import { isCourseGenerationRunning } from '@/lib/course-framework/runner';
|
|
import {
|
|
CoursePublishValidationError,
|
|
revalidateCoursePublishSnapshot,
|
|
validateCoursePublishSnapshot,
|
|
type CoursePublishSnapshot,
|
|
} from '@/lib/course-framework/publish-validation';
|
|
import {
|
|
ClassroomSourceMutationInProgressError,
|
|
CoursePublishInProgressError,
|
|
runClassroomSourcesPublishExclusive,
|
|
runCoursePublishExclusive,
|
|
} from '@/lib/course-framework/publish-state';
|
|
import { courseModuleCoursewareId } from '@/lib/course-framework/publish-identity';
|
|
import { packagePersistedClassroom } from '@/lib/server/classroom-courseware-publish';
|
|
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
|
|
import {
|
|
COURSEWARE_BUNDLES_DIR,
|
|
createFileBundleByteStore,
|
|
} from '@/lib/courseware-repo/bundle-store';
|
|
import {
|
|
COURSE_PUBLISH_PROTOCOL_VERSION,
|
|
COURSE_PUBLISH_SERVER_BASE_URL_ENV,
|
|
CoursePublishTransportError,
|
|
resolveConfiguredCoursewarePublicBaseUrl,
|
|
} from '@/lib/server/course-publish-contract';
|
|
import { publishCourseToServer } from '@/lib/server/course-publish-transport';
|
|
import { commitRemoteCoursePublish } from '@/lib/server/course-publish-transaction';
|
|
|
|
const log = createLogger('Course Publish API');
|
|
export const maxDuration = 900;
|
|
|
|
function buildPublicationReceipt(
|
|
snapshot: CoursePublishSnapshot,
|
|
manifest: CourseManifestRecord,
|
|
expectedHashes: ReadonlyMap<number, string>,
|
|
): CoursePublicationReceipt {
|
|
if (
|
|
manifest.schemaVersion !== 2 ||
|
|
manifest.courseId !== snapshot.courseId ||
|
|
manifest.modules.length !== snapshot.modules.length
|
|
) {
|
|
throw new CoursePublishTransportError(
|
|
'PUBLICATION_RECEIPT_MISMATCH',
|
|
'Publish server returned a manifest for a different course snapshot',
|
|
'commit',
|
|
502,
|
|
);
|
|
}
|
|
const modules = snapshot.modules.map(
|
|
({ module: sourceModule, outputDigest, sourceRevisionHash }) => {
|
|
const pin = manifest.modules.find((entry) => entry.index === sourceModule.index);
|
|
const expectedCoursewareId = courseModuleCoursewareId(snapshot.courseId, sourceModule.index);
|
|
const expectedHash = expectedHashes.get(sourceModule.index);
|
|
if (
|
|
!pin ||
|
|
pin.title !== sourceModule.title ||
|
|
pin.description !== sourceModule.description ||
|
|
pin.coursewareId !== expectedCoursewareId ||
|
|
!pin.coursewareVersion ||
|
|
!pin.contentHash ||
|
|
pin.contentHash !== expectedHash
|
|
) {
|
|
throw new CoursePublishTransportError(
|
|
'PUBLICATION_RECEIPT_MISMATCH',
|
|
`Publish receipt identity drifted for module ${sourceModule.index}`,
|
|
'commit',
|
|
502,
|
|
sourceModule.index,
|
|
);
|
|
}
|
|
return {
|
|
index: sourceModule.index,
|
|
sourceClassroomId: outputDigest.classroomId,
|
|
sourceRevisionHash,
|
|
sourceSemanticHash: outputDigest.semanticHash,
|
|
coursewareId: pin.coursewareId,
|
|
coursewareVersion: pin.coursewareVersion,
|
|
contentHash: pin.contentHash,
|
|
};
|
|
},
|
|
);
|
|
return {
|
|
receiptVersion: 1,
|
|
manifestVersion: manifest.version,
|
|
publishedAt: manifest.publishedAt,
|
|
modules,
|
|
};
|
|
}
|
|
|
|
export async function POST(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ courseId: string }> },
|
|
) {
|
|
const denied = await requireOpsAccess(request);
|
|
if (denied) return denied;
|
|
|
|
try {
|
|
const { courseId } = await params;
|
|
if (!isValidCourseId(courseId)) {
|
|
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, `Invalid course id: ${courseId}`);
|
|
}
|
|
const worksOrigin = configuredWorksSquareApiOrigin();
|
|
if (worksOrigin) {
|
|
const authorization = request.headers.get('authorization') ?? '';
|
|
const finalizeResponse = await fetch(
|
|
`${worksOrigin}/api/admin/learning/generations/${encodeURIComponent(courseId)}/finalize`,
|
|
{
|
|
method: 'POST',
|
|
headers: { Authorization: authorization, Accept: 'application/json' },
|
|
redirect: 'error',
|
|
cache: 'no-store',
|
|
signal: AbortSignal.timeout(10 * 60_000),
|
|
},
|
|
);
|
|
const finalized = (await finalizeResponse.json().catch(() => null)) as {
|
|
id?: unknown;
|
|
status?: unknown;
|
|
} | null;
|
|
if (!finalizeResponse.ok || typeof finalized?.id !== 'string') {
|
|
return apiError(
|
|
API_ERROR_CODES.UPSTREAM_ERROR,
|
|
finalizeResponse.status >= 400 && finalizeResponse.status < 500
|
|
? finalizeResponse.status
|
|
: 502,
|
|
'Works could not finalize the aggregate learning course',
|
|
);
|
|
}
|
|
const publishResponse = await fetch(
|
|
`${worksOrigin}/api/admin/learning/courses/${encodeURIComponent(finalized.id)}/publish`,
|
|
{
|
|
method: 'POST',
|
|
headers: { Authorization: authorization, Accept: 'application/json' },
|
|
redirect: 'error',
|
|
cache: 'no-store',
|
|
signal: AbortSignal.timeout(30_000),
|
|
},
|
|
);
|
|
const published = (await publishResponse.json().catch(() => null)) as {
|
|
id?: unknown;
|
|
status?: unknown;
|
|
} | null;
|
|
if (!publishResponse.ok || typeof published?.id !== 'string') {
|
|
return apiError(
|
|
API_ERROR_CODES.UPSTREAM_ERROR,
|
|
publishResponse.status >= 400 && publishResponse.status < 500
|
|
? publishResponse.status
|
|
: 502,
|
|
'Works registered the course package but could not publish it',
|
|
);
|
|
}
|
|
await updateCourseRecord(courseId, {
|
|
externalPublication: {
|
|
courseId: published.id,
|
|
status: published.status === 'published' ? 'published' : 'ready',
|
|
publishedAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
return apiSuccess({ record: published, aggregate: true });
|
|
}
|
|
if (!isPublishTokenConfigured()) {
|
|
return apiError(
|
|
API_ERROR_CODES.INTERNAL_ERROR,
|
|
503,
|
|
`Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`,
|
|
);
|
|
}
|
|
|
|
if (isCourseGenerationRunning(courseId)) {
|
|
return apiError(
|
|
API_ERROR_CODES.INVALID_REQUEST,
|
|
409,
|
|
'Course generation is still running; wait before publishing',
|
|
);
|
|
}
|
|
|
|
const sourceBaseUrl = buildRequestOrigin(request);
|
|
const remoteServerBaseUrl = process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim();
|
|
const result = await runCoursePublishExclusive(courseId, async () => {
|
|
const record = await readCourseRecordReconciled(courseId);
|
|
if (!record) {
|
|
throw new CoursePublishValidationError('COURSE_NOT_FOUND', `Course not found: ${courseId}`);
|
|
}
|
|
const snapshot = await validateCoursePublishSnapshot(record);
|
|
|
|
return runClassroomSourcesPublishExclusive(
|
|
snapshot.modules.map(({ classroom }) => classroom.id),
|
|
async () => {
|
|
if (remoteServerBaseUrl) {
|
|
const archives = [];
|
|
const expectedHashes = new Map<number, string>();
|
|
for (const validatedModule of snapshot.modules) {
|
|
const index = validatedModule.module.index;
|
|
const coursewareId = courseModuleCoursewareId(courseId, index);
|
|
// The placeholder version is canonicalized by the server inside the
|
|
// same lock that allocates the real immutable version.
|
|
const packaged = await packagePersistedClassroom(validatedModule.classroom, {
|
|
coursewareId,
|
|
version: 1,
|
|
baseUrl: sourceBaseUrl,
|
|
// Audit timestamp only. contentHash v2 excludes this volatile
|
|
// field, so an unchanged source is idempotent across both timeout
|
|
// retries and an explicit repeated publish.
|
|
publishedAt: record.updatedAt,
|
|
});
|
|
expectedHashes.set(index, packaged.contentHash);
|
|
archives.push({
|
|
index,
|
|
zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()),
|
|
});
|
|
}
|
|
|
|
// Never transmit a snapshot that changed while its ZIPs were frozen.
|
|
await revalidateCoursePublishSnapshot(snapshot);
|
|
const remoteResult = await publishCourseToServer({
|
|
baseUrl: remoteServerBaseUrl,
|
|
token: process.env[COURSEWARE_PUBLISH_TOKEN_ENV] ?? null,
|
|
metadata: {
|
|
protocolVersion: COURSE_PUBLISH_PROTOCOL_VERSION,
|
|
courseId,
|
|
title: record.framework!.courseTitle,
|
|
summary: record.framework!.summary,
|
|
language: record.framework!.languageDirective,
|
|
modules: snapshot.modules.map(({ module: moduleRecord, classroom }) => ({
|
|
index: moduleRecord.index,
|
|
title: moduleRecord.title,
|
|
description: moduleRecord.description,
|
|
coursewareId: courseModuleCoursewareId(courseId, moduleRecord.index),
|
|
sourceClassroomId: classroom.id,
|
|
})),
|
|
},
|
|
archives,
|
|
});
|
|
const publication = buildPublicationReceipt(
|
|
snapshot,
|
|
remoteResult.record,
|
|
expectedHashes,
|
|
);
|
|
await updateCourseRecord(courseId, { publication });
|
|
return { ok: true as const, record: remoteResult.record };
|
|
}
|
|
|
|
const coursewareRecords = createFileCoursewareRepo(COURSEWARES_DIR);
|
|
const coursewareBytes = createFileBundleByteStore(COURSEWARE_BUNDLES_DIR);
|
|
const publicBaseUrl = resolveConfiguredCoursewarePublicBaseUrl(sourceBaseUrl);
|
|
const expectedHashes = new Map<number, string>();
|
|
const archives = [];
|
|
for (const validatedModule of snapshot.modules) {
|
|
const index = validatedModule.module.index;
|
|
const coursewareId = courseModuleCoursewareId(courseId, index);
|
|
const packaged = await packagePersistedClassroom(validatedModule.classroom, {
|
|
coursewareId,
|
|
version: 1,
|
|
baseUrl: sourceBaseUrl,
|
|
publishedAt: record.updatedAt,
|
|
});
|
|
expectedHashes.set(index, packaged.contentHash);
|
|
archives.push({
|
|
index,
|
|
zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()),
|
|
});
|
|
}
|
|
|
|
await revalidateCoursePublishSnapshot(snapshot);
|
|
const localResult = await commitRemoteCoursePublish({
|
|
metadata: {
|
|
protocolVersion: COURSE_PUBLISH_PROTOCOL_VERSION,
|
|
courseId,
|
|
title: record.framework!.courseTitle,
|
|
summary: record.framework!.summary,
|
|
language: record.framework!.languageDirective,
|
|
modules: snapshot.modules.map(({ module: moduleRecord, classroom }) => ({
|
|
index: moduleRecord.index,
|
|
title: moduleRecord.title,
|
|
description: moduleRecord.description,
|
|
coursewareId: courseModuleCoursewareId(courseId, moduleRecord.index),
|
|
sourceClassroomId: classroom.id,
|
|
})),
|
|
},
|
|
archives,
|
|
token: process.env[COURSEWARE_PUBLISH_TOKEN_ENV] ?? null,
|
|
publicBaseUrl,
|
|
repos: { coursewares: coursewareRecords, bundles: coursewareBytes },
|
|
});
|
|
const publication = buildPublicationReceipt(snapshot, localResult.record, expectedHashes);
|
|
await updateCourseRecord(courseId, { publication });
|
|
return { ok: true as const, record: localResult.record };
|
|
},
|
|
);
|
|
});
|
|
|
|
log.info(`Course published: ${courseId} v${result.record.version}`);
|
|
return apiSuccess({ record: result.record });
|
|
} catch (error) {
|
|
if (error instanceof CoursePublishInProgressError) {
|
|
return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message);
|
|
}
|
|
if (error instanceof ClassroomSourceMutationInProgressError) {
|
|
return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message);
|
|
}
|
|
if (error instanceof CoursePublishValidationError) {
|
|
return apiError(
|
|
API_ERROR_CODES.INVALID_REQUEST,
|
|
error.code === 'COURSE_NOT_FOUND' ? 404 : 409,
|
|
error.message,
|
|
`${error.code}${error.moduleIndex ? ` (module ${error.moduleIndex})` : ''}`,
|
|
);
|
|
}
|
|
if (error instanceof CoursePublishTransportError) {
|
|
const remoteInfrastructureFailure =
|
|
error.status >= 500 || error.status === 401 || error.status === 403;
|
|
return apiError(
|
|
remoteInfrastructureFailure
|
|
? API_ERROR_CODES.UPSTREAM_ERROR
|
|
: API_ERROR_CODES.INVALID_REQUEST,
|
|
remoteInfrastructureFailure ? 502 : error.status,
|
|
error.message,
|
|
`${error.errorCode}; phase=${error.phase}${
|
|
error.moduleIndex ? `; module=${error.moduleIndex}` : ''
|
|
}${error.details ? `; ${error.details}` : ''}`,
|
|
);
|
|
}
|
|
if (
|
|
error instanceof Error &&
|
|
/Frozen bundle|cannot be frozen|media|audio|asset|narration|speech|agent|interactive|courseware.*immutable/i.test(
|
|
error.message,
|
|
)
|
|
) {
|
|
return apiError(
|
|
API_ERROR_CODES.INVALID_REQUEST,
|
|
409,
|
|
'Course modules are not ready for frozen publication',
|
|
error.message,
|
|
);
|
|
}
|
|
log.error('Course publish failed:', error);
|
|
return apiError(API_ERROR_CODES.INTERNAL_ERROR, 500, 'Failed to publish course');
|
|
}
|
|
}
|