100 lines
3.0 KiB
TypeScript
100 lines
3.0 KiB
TypeScript
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;
|
|
}
|