feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

View File

@@ -1,74 +1,31 @@
import type { DeploymentRole } from '@/lib/config/deployment-role';
const SERVER_HIDDEN_API_ROOTS = [
'/api/courses',
'/api/ops',
'/api/access-code',
// This browser-side CORS convenience proxy has no authenticated server-tier
// contract. Keeping it off the headless public API also closes DNS-rebinding
// and cloud-metadata SSRF paths until learner identity is introduced.
'/api/proxy-media',
'/api/usage',
'/api/provider/probe-models',
'/api/azure-voices',
'/api/verify-model',
'/api/verify-image-provider',
'/api/verify-video-provider',
'/api/verify-pdf-provider',
'/api/export-video',
// The current persistence token is a development convenience compiled into
// the client. It is not a tenant/owner boundary and must stay off L2.
'/api/persistence',
] as const;
export function isApiPath(pathname: string): boolean {
return pathname === '/api' || pathname.startsWith('/api/');
}
function isPathFamily(pathname: string, root: string): boolean {
return pathname === root || pathname.startsWith(`${root}/`);
}
function withoutTrailingSlash(pathname: string): string {
return pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
}
/**
* A server deployment is a headless data/API service. It deliberately hides
* every page plus APIs owned by the operations workbench. Other APIs continue
* to their route-level authentication until learner ownership is defined.
* The Learning Engine exposes one token-protected runtime surface. Original
* browser APIs remain callable internally by runtime route handlers, but are
* never reachable as unauthenticated ClusterIP endpoints.
*/
export function shouldReturnNotFoundAtServerBoundary(
role: DeploymentRole,
pathname: string,
method = 'GET',
): boolean {
if (role !== 'server') {
if (role !== 'server') return false;
const normalized = withoutTrailingSlash(pathname);
if (!isApiPath(normalized)) return true;
if (normalized === '/api/runtime/v1' || normalized.startsWith('/api/runtime/v1/')) {
return false;
}
const normalizedPathname = withoutTrailingSlash(pathname);
if (!isApiPath(normalizedPathname)) {
return true;
if (normalized === '/api/internal/course-publish' && method.toUpperCase() === 'POST') {
return false;
}
if (SERVER_HIDDEN_API_ROOTS.some((root) => isPathFamily(normalizedPathname, root))) {
return true;
}
const normalizedMethod = method.toUpperCase();
if (
normalizedPathname === '/api/generate-classroom' &&
(normalizedMethod === 'GET' || normalizedMethod === 'HEAD')
) {
// A global job list has no owner filter. Per-job and POST generation stay
// available until the learner capability contract is introduced. HEAD is
// GET-equivalent in Next.js and must not bypass this list boundary.
return true;
}
if (normalizedPathname === '/api/internal/course-publish' && normalizedMethod !== 'POST') {
return true;
}
return false;
return true;
}