import type { NextRequest } from 'next/server'; import { resolveDeploymentRole, hasDeploymentCapability } from '@/lib/config/deployment-role'; import { verifyAccessToken } from '@/lib/server/access-token'; import { ACCESS_CODE_COOKIE_NAME } from '@/lib/server/access-token-policy'; import { apiError, API_ERROR_CODES } from '@/lib/server/api-response'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); export const OPS_PUBLIC_ORIGIN_ENV = 'OPS_PUBLIC_ORIGIN'; function configuredOpsOrigin(): string | null | undefined { const configured = process.env[OPS_PUBLIC_ORIGIN_ENV]?.trim(); if (!configured) return undefined; try { const url = new URL(configured); 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 expectedOpsOrigin(request: Request): string | null { const configured = configuredOpsOrigin(); if (configured !== undefined) return configured; try { return new URL(request.url).origin; } catch { return null; } } export function hasValidOpsMutationOrigin(request: Request): boolean { if (SAFE_METHODS.has(request.method.toUpperCase())) return true; const origin = request.headers.get('origin'); if (!origin) return false; try { const expected = expectedOpsOrigin(request); return expected !== null && new URL(origin).origin === expected; } catch { return false; } } export function getServerDeploymentRole() { return resolveDeploymentRole( process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE, process.env.NODE_ENV, ); } export function isOpsDeployment(role = getServerDeploymentRole()): boolean { return hasDeploymentCapability(role, 'manage_courses'); } /** * First-stage operations authorization. * * Deployment role limits which instance can host the workbench. ACCESS_CODE * then authenticates the browser session. A future account/role system can * replace this helper without changing every course route again. */ export function requireOpsAccess(request: NextRequest) { if (!isOpsDeployment()) { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled'); } const accessCode = process.env.ACCESS_CODE; if (!accessCode) { if (process.env.NODE_ENV === 'production') { return apiError( API_ERROR_CODES.INTERNAL_ERROR, 503, 'Operations deployment is not configured with ACCESS_CODE', ); } return null; } const token = request.cookies.get(ACCESS_CODE_COOKIE_NAME)?.value; if (!token || !verifyAccessToken(token, accessCode)) { return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Operations access required'); } if (!hasValidOpsMutationOrigin(request)) { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Cross-origin operations request denied'); } return null; }