106 lines
3.2 KiB
TypeScript
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/).*)'],
|
|
};
|