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'; export const WORKS_SQUARE_API_BASE_URL_ENV = 'WORKS_SQUARE_API_BASE_URL'; export const WORKS_OPERATIONS_ADMIN_USERNAME = 'jiaoyuop'; 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 configuredWorksSquareApiOrigin(): string | null { const raw = process.env[WORKS_SQUARE_API_BASE_URL_ENV]?.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; } } async function authorizeWithWorksSession(request: NextRequest, origin: string) { const authorization = request.headers.get('authorization')?.trim() ?? ''; if (!/^Bearer\s+\S+$/.test(authorization) || authorization.length > 8192) { return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session required'); } try { const response = await fetch(`${origin}/api/auth/me`, { method: 'GET', headers: { Authorization: authorization, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(5000), }); if (!response.ok) { return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session is invalid'); } const identity = (await response.json()) as { username?: unknown; site_role?: unknown }; if ( identity.username !== WORKS_OPERATIONS_ADMIN_USERNAME || identity.site_role !== 'admin' ) { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Works administrator role required'); } return null; } catch { return apiError(API_ERROR_CODES.INTERNAL_ERROR, 503, 'Works identity service is unavailable'); } } export async function requireOpsAccess(request: NextRequest) { if (!isOpsDeployment()) { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled'); } if (!hasValidOpsMutationOrigin(request)) { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Cross-origin operations request denied'); } const worksOrigin = configuredWorksSquareApiOrigin(); if (worksOrigin) return authorizeWithWorksSession(request, worksOrigin); // Production Learning Ops always delegates identity to Works. The legacy // ACCESS_CODE fallback is deliberately development-only. if (process.env.NODE_ENV === 'production') { return apiError( API_ERROR_CODES.INTERNAL_ERROR, 503, `Operations identity is not configured (${WORKS_SQUARE_API_BASE_URL_ENV})`, ); } const accessCode = process.env.ACCESS_CODE; if (!accessCode) 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'); } return null; }