feat: productionize learning engine and classroom
This commit is contained in:
@@ -6,6 +6,8 @@ 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();
|
||||
@@ -69,31 +71,85 @@ export function isOpsDeployment(role = getServerDeploymentRole()): boolean {
|
||||
* 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',
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user