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. */ export function shouldReturnNotFoundAtServerBoundary( role: DeploymentRole, pathname: string, method = 'GET', ): boolean { if (role !== 'server') { return false; } const normalizedPathname = withoutTrailingSlash(pathname); if (!isApiPath(normalizedPathname)) { return true; } 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; }