import { NextRequest, NextResponse } from 'next/server'; import { hasDeploymentCapability, resolveDeploymentRole } from '@/lib/config/deployment-role'; import { isApiPath, shouldReturnNotFoundAtServerBoundary, } from '@/lib/config/server-request-boundary'; import { verifyAccessTokenWithWebCrypto } from '@/lib/server/access-token-edge'; import { ACCESS_CODE_COOKIE_NAME } from '@/lib/server/access-token-policy'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const OPS_ADMIN_USERNAME = 'jiaoyuop'; function configuredHttpOrigin(name: string): string | null { const raw = process.env[name]?.trim(); if (!raw) return null; try { const url = new URL(raw); if ( (url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password || url.search || url.hash || (url.pathname !== '' && url.pathname !== '/') ) { return null; } return url.origin; } catch { return null; } } function opsError(status: number, errorCode: string, error: string) { return NextResponse.json({ success: false, errorCode, error }, { status }); } async function authorizeOpsApi(request: NextRequest): Promise { const worksOrigin = configuredHttpOrigin('WORKS_SQUARE_API_BASE_URL'); const publicOrigin = configuredHttpOrigin('OPS_PUBLIC_ORIGIN'); if (!worksOrigin || !publicOrigin) { return opsError(503, 'INTERNAL_ERROR', 'Operations identity is not configured'); } if (!SAFE_METHODS.has(request.method.toUpperCase())) { const origin = request.headers.get('origin'); try { if (!origin || new URL(origin).origin !== publicOrigin) { return opsError(403, 'FORBIDDEN', 'Cross-origin operations request denied'); } } catch { return opsError(403, 'FORBIDDEN', 'Cross-origin operations request denied'); } } const authorization = request.headers.get('authorization')?.trim() ?? ''; if (!/^Bearer\s+\S+$/.test(authorization) || authorization.length > 8192) { return opsError(401, 'INVALID_CREDENTIALS', 'Works administrator session required'); } try { const response = await fetch(`${worksOrigin}/api/auth/me`, { method: 'GET', headers: { Authorization: authorization, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(5000), }); if (!response.ok) { return opsError(401, 'INVALID_CREDENTIALS', 'Works administrator session is invalid'); } const identity = (await response.json()) as { username?: unknown; site_role?: unknown }; if (identity.username !== OPS_ADMIN_USERNAME || identity.site_role !== 'admin') { return opsError(403, 'FORBIDDEN', 'Works administrator role required'); } return NextResponse.next(); } catch { return opsError(503, 'INTERNAL_ERROR', 'Works identity service is unavailable'); } } export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const configuredBasePath = process.env.OPENMAIC_BASE_PATH?.replace(/\/+$/, '') || ''; const boundaryPathname = configuredBasePath && (pathname === configuredBasePath || pathname.startsWith(`${configuredBasePath}/`)) ? pathname.slice(configuredBasePath.length) || '/' : pathname; const role = resolveDeploymentRole( process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE, process.env.NODE_ENV, ); if (shouldReturnNotFoundAtServerBoundary(role, boundaryPathname, request.method)) { if (isApiPath(boundaryPathname)) { return NextResponse.json( { success: false, errorCode: 'NOT_FOUND', error: 'Not found' }, { status: 404 }, ); } return new NextResponse('Not Found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8', 'x-content-type-options': 'nosniff', }, }); } const isOpsPath = boundaryPathname === '/courses' || boundaryPathname.startsWith('/courses/') || boundaryPathname === '/publish' || boundaryPathname === '/api/courses' || boundaryPathname.startsWith('/api/courses/') || boundaryPathname === '/api/ops' || boundaryPathname.startsWith('/api/ops/'); if (isOpsPath && !hasDeploymentCapability(role, 'manage_courses')) { if (boundaryPathname.startsWith('/api/')) { return NextResponse.json( { success: false, errorCode: 'FORBIDDEN', error: 'Operations capability is disabled', }, { status: 403 }, ); } return NextResponse.redirect(new URL('/', request.url)); } // Remaining APIs on a dedicated server deployment authenticate at their // route boundaries. A shared ACCESS_CODE from the ops environment must // never preempt the internal Bearer publishing contract. if (role === 'server') { return NextResponse.next(); } const isHealthPath = boundaryPathname === '/api/health' || boundaryPathname === '/api/runtime/v1/health/live' || boundaryPathname === '/api/runtime/v1/health/ready'; if (role === 'ops') { if (isHealthPath) return NextResponse.next(); if (isApiPath(boundaryPathname)) return authorizeOpsApi(request); // Operations pages are authenticated shells. Browser API calls carry the // Works Bearer and are introspected above; ACCESS_CODE is never consulted. return NextResponse.next(); } const accessCode = process.env.ACCESS_CODE; const worksIdentityConfigured = Boolean(process.env.WORKS_SQUARE_API_BASE_URL?.trim()); if (isOpsPath && worksIdentityConfigured) { // Pages are public shells; every data route performs server-side Works // introspection. Bearer sessions are not cookies and must reach the route. return NextResponse.next(); } if (isOpsPath && process.env.NODE_ENV === 'production') { return NextResponse.json( { success: false, errorCode: 'INTERNAL_ERROR', error: 'Operations deployment is not configured with Works identity', }, { status: 503 }, ); } if (!accessCode) { return NextResponse.next(); } // Whitelist: access-code endpoints, health check if ( boundaryPathname.startsWith('/api/access-code/') || isHealthPath ) { return NextResponse.next(); } // Check cookie — validate HMAC signature, not just existence const cookie = request.cookies.get(ACCESS_CODE_COOKIE_NAME); if (cookie?.value && (await verifyAccessTokenWithWebCrypto(cookie.value, accessCode))) { return NextResponse.next(); } // API requests without valid cookie → 401 if (boundaryPathname.startsWith('/api/')) { return NextResponse.json( { success: false, errorCode: 'INVALID_REQUEST', error: 'Access code required' }, { status: 401 }, ); } // Page requests → let through, frontend shows modal return NextResponse.next(); } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|logos/).*)'], };