Files
openmaic/OpenMAIC/middleware.ts
2026-08-16 14:58:47 +08:00

106 lines
3.2 KiB
TypeScript

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';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const role = resolveDeploymentRole(
process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE,
process.env.NODE_ENV,
);
if (shouldReturnNotFoundAtServerBoundary(role, pathname, request.method)) {
if (isApiPath(pathname)) {
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 =
pathname === '/courses' ||
pathname.startsWith('/courses/') ||
pathname === '/publish' ||
pathname === '/api/courses' ||
pathname.startsWith('/api/courses/') ||
pathname === '/api/ops' ||
pathname.startsWith('/api/ops/');
if (isOpsPath && !hasDeploymentCapability(role, 'manage_courses')) {
if (pathname.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 accessCode = process.env.ACCESS_CODE;
if (isOpsPath && process.env.NODE_ENV === 'production' && !accessCode) {
return NextResponse.json(
{
success: false,
errorCode: 'INTERNAL_ERROR',
error: 'Operations deployment is not configured with ACCESS_CODE',
},
{ status: 503 },
);
}
if (!accessCode) {
return NextResponse.next();
}
// Whitelist: access-code endpoints, health check
if (pathname.startsWith('/api/access-code/') || pathname === '/api/health') {
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 (pathname.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/).*)'],
};