import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js'; import { deployTestServerCommand, deployWorkServerCommand, listServerCommands, readWorkServerDeploymentState, restartServerCommand, serverCommandKeys, } from '../services/server-command-service.js'; import { readTestServerDeploymentState } from '../services/test-server-deployment-service.js'; import { cancelServerRestartReservation, confirmServerRestartReservation, getRestartReservationWorkloadSummary, requestImmediateRestartRecovery, getServerRestartReservation, scheduleServerRestartReservation, } from '../services/server-restart-reservation-service.js'; const serverCommandParamSchema = z.object({ key: z.enum(serverCommandKeys), }); const restartReservationBodySchema = z.object({ target: z.enum(['all', 'test', 'work-server']).optional(), autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(), }); const CHAT_SHARE_PATH_PREFIX = '/chat/share/'; function getImmediateRestartBlockInfo( key: z.infer['key'], workloadSummary: Awaited>, ) { const codexPendingCount = workloadSummary.codexRunningCount + workloadSummary.codexQueuedCount; const automationPendingCount = workloadSummary.automationRunningCount + workloadSummary.automationQueuedCount; if (key === 'test') { const pendingCount = codexPendingCount + automationPendingCount; if (pendingCount > 0) { return { pendingCount, message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`, }; } return null; } if (key === 'work-server') { return null; } return null; } function getRequestAccessToken(request: FastifyRequest) { const tokenHeader = request.headers['x-access-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); } function getRequestChatShareToken(request: FastifyRequest) { const tokenHeader = request.headers['x-chat-share-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); } function resolveChatSharePath(token: string) { return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`; } async function resolveSharedServerCommandAccessContext(request: FastifyRequest) { const shareToken = getRequestChatShareToken(request); if (!shareToken) { return null; } const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken)); if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) { return null; } const allowedAppIds = managedResource.token.allowedAppIds ?? []; const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean)); if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) { return null; } return { scope: 'shared' as const, allowedKeys: new Set(['work-server', 'test']), }; } function getRequestClientId(request: FastifyRequest) { const clientIdHeader = request.headers['x-client-id']; return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim(); } function getRequestAppOrigin(request: FastifyRequest) { const appOriginHeader = request.headers['x-app-origin']; const appOrigin = Array.isArray(appOriginHeader) ? appOriginHeader[0] : appOriginHeader; if (appOrigin?.trim()) { return appOrigin.trim(); } const originHeader = request.headers.origin; const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader; return origin?.trim() ?? ''; } async function resolveServerCommandAccessContext(request: FastifyRequest) { if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) { return { scope: 'full' as const }; } return resolveSharedServerCommandAccessContext(request); } function sendAccessDenied(reply: FastifyReply) { reply.status(403); void reply.send({ message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.', }); } export async function registerServerCommandRoutes(app: FastifyInstance) { app.get('/api/server-commands', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const items = await listServerCommands(); return { ok: true, items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)), }; }); app.post('/api/server-commands/:key/actions/restart', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const { key } = serverCommandParamSchema.parse(request.params); if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) { reply.status(403); return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' }; } if (key === 'test' || key === 'work-server') { const workloadSummary = await getRestartReservationWorkloadSummary(); const blockInfo = getImmediateRestartBlockInfo(key, workloadSummary); if (blockInfo) { reply.status(409); return { ok: false, message: blockInfo.message, workloadSummary, }; } } try { const result = await restartServerCommand(key); return { ok: true, item: result.server, commandOutput: result.commandOutput, restartState: result.restartState, }; } catch (error) { const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.'; const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null; if (statusCode === 409) { reply.status(409); return { ok: false, message }; } if (key !== 'test' && key !== 'work-server') { throw error; } if (!/(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message)) { throw error; } await requestImmediateRestartRecovery(app.log, key, message); const server = (await listServerCommands()).find((item) => item.key === key); if (!server) { throw new Error(`${key} 서버 상태를 다시 읽지 못했습니다.`); } return { ok: true, item: server, commandOutput: `${message}\n\n빌드 실패를 감지해 Codex 자동 개선과 재기동 재시도를 시작했습니다.`, restartState: 'accepted' as const, }; } }); app.get('/api/server-commands/work-server/deployment', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) { reply.status(403); return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버 배포 상태를 확인할 수 없습니다.' }; } return { ok: true, item: (await readWorkServerDeploymentState()) ?? null, }; }); app.post('/api/server-commands/work-server/actions/deploy', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) { reply.status(403); return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버를 배포할 수 없습니다.' }; } const result = await deployWorkServerCommand(); return { ok: true, item: result.server, commandOutput: result.commandOutput, restartState: result.restartState, deployment: result.deployment ?? result.server.deployment ?? null, }; }); app.get('/api/server-commands/test/deployment', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) { reply.status(403); return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버 배포 상태를 확인할 수 없습니다.' }; } return { ok: true, item: (await readTestServerDeploymentState()) ?? null, }; }); app.post('/api/server-commands/test/actions/deploy', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) { reply.status(403); return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버를 배포할 수 없습니다.' }; } try { const result = await deployTestServerCommand(); return { ok: true, item: result.server, commandOutput: result.commandOutput, restartState: result.restartState, testDeployment: result.testDeployment ?? null, }; } catch (error) { const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null; if (statusCode === 409) { reply.status(409); return { ok: false, message: error instanceof Error ? error.message : 'TEST 서버 배포가 이미 진행 중입니다.' }; } throw error; } }); app.get('/api/server-commands/restart-reservation', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } return { ok: true, item: await getServerRestartReservation(), }; }); app.put('/api/server-commands/restart-reservation', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } let payload: unknown = request.body ?? {}; if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = {}; } } const parsed = restartReservationBodySchema.parse(payload ?? {}); if (accessContext.scope !== 'full' && parsed.target !== 'work-server') { return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' }); } return { ok: true, item: await scheduleServerRestartReservation({ target: parsed.target, clientId: getRequestClientId(request), appOrigin: getRequestAppOrigin(request), autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds, }), }; }); app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } return { ok: true, item: await confirmServerRestartReservation(app.log), }; }); app.delete('/api/server-commands/restart-reservation', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } return { ok: true, item: await cancelServerRestartReservation(), }; }); }