feat: productionize learning engine and classroom
This commit is contained in:
@@ -7,15 +7,90 @@ import {
|
||||
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<NextResponse> {
|
||||
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, pathname, request.method)) {
|
||||
if (isApiPath(pathname)) {
|
||||
if (shouldReturnNotFoundAtServerBoundary(role, boundaryPathname, request.method)) {
|
||||
if (isApiPath(boundaryPathname)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, errorCode: 'NOT_FOUND', error: 'Not found' },
|
||||
{ status: 404 },
|
||||
@@ -32,16 +107,16 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
const isOpsPath =
|
||||
pathname === '/courses' ||
|
||||
pathname.startsWith('/courses/') ||
|
||||
pathname === '/publish' ||
|
||||
pathname === '/api/courses' ||
|
||||
pathname.startsWith('/api/courses/') ||
|
||||
pathname === '/api/ops' ||
|
||||
pathname.startsWith('/api/ops/');
|
||||
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 (pathname.startsWith('/api/')) {
|
||||
if (boundaryPathname.startsWith('/api/')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@@ -61,13 +136,31 @@ export async function middleware(request: NextRequest) {
|
||||
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;
|
||||
if (isOpsPath && process.env.NODE_ENV === 'production' && !accessCode) {
|
||||
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 ACCESS_CODE',
|
||||
error: 'Operations deployment is not configured with Works identity',
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
@@ -78,7 +171,10 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Whitelist: access-code endpoints, health check
|
||||
if (pathname.startsWith('/api/access-code/') || pathname === '/api/health') {
|
||||
if (
|
||||
boundaryPathname.startsWith('/api/access-code/') ||
|
||||
isHealthPath
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -89,7 +185,7 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
// API requests without valid cookie → 401
|
||||
if (pathname.startsWith('/api/')) {
|
||||
if (boundaryPathname.startsWith('/api/')) {
|
||||
return NextResponse.json(
|
||||
{ success: false, errorCode: 'INVALID_REQUEST', error: 'Access code required' },
|
||||
{ status: 401 },
|
||||
|
||||
Reference in New Issue
Block a user