feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

View File

@@ -1,6 +1,7 @@
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 { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
import {
cancelServerRestartReservation,
@@ -16,8 +17,10 @@ const serverCommandParamSchema = z.object({
});
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<typeof serverCommandParamSchema>['key'],
@@ -60,6 +63,39 @@ function getRequestAccessToken(request: FastifyRequest) {
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<string>(['work-server']),
};
}
function getRequestClientId(request: FastifyRequest) {
const clientIdHeader = request.headers['x-client-id'];
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
@@ -78,36 +114,48 @@ function getRequestAppOrigin(request: FastifyRequest) {
return origin?.trim() ?? '';
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
async function resolveServerCommandAccessContext(request: FastifyRequest) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
return { scope: 'full' as const };
}
return resolveSharedServerCommandAccessContext(request);
}
function sendAccessDenied(reply: FastifyReply) {
reply.status(403);
void reply.send({
message: '권한 토큰 필요합니다.',
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
});
return false;
}
export async function registerServerCommandRoutes(app: FastifyInstance) {
app.get('/api/server-commands', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const items = await listServerCommands();
return {
ok: true,
items: await listServerCommands(),
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
};
});
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
if (!ensureAuthorized(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();
@@ -160,7 +208,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -171,7 +221,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -187,9 +239,14 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
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,
@@ -198,7 +255,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -209,7 +268,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}