Files
ai-code-app/etc/servers/work-server/src/routes/chat.ts
2026-05-29 07:57:56 +09:00

4333 lines
150 KiB
TypeScript

import { createHmac, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { access, mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import {
getSharedResourceTokenDetailBySharePath,
recordSharedResourceTokenUsage,
resolveSharedResourceTokenEffectiveExpiresAt,
type SharedResourceTokenSettingSnapshot,
upsertSharedResourceToken,
validateSharedResourceAccessPinBySharePath,
} from '../services/shared-resource-token-service.js';
import {
ensureChatSessionResourceDirectories,
getActiveChatService,
getChatRuntimeController,
} from '../services/chat-service.js';
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
assignSharedResourceTokenToRequests,
appendChatConversationActivityLine,
appendChatConversationMessage,
buildChatPromptTargetSignature,
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
cancelUnansweredChatConversationRequest,
clearChatConversationData,
createChatConversation,
deleteUnansweredChatConversationRequest,
deleteChatConversation,
ensureChatConversationTables,
getChatConversation,
getChatConversationRequest,
MANAGED_CHAT_SHARE_SESSION_PREFIX,
listChatConversationActivityLogsByRequestIds,
listChatConversationMessages,
listChatConversationRequests,
listChatSourceChangeSnapshots,
listChatConversationDetailPage,
listChatConversations,
markChatConversationRequestManualCompletion,
ChatConversationManualCompletionBlockedError,
markChatConversationResponsesRead,
persistChatConversationPromptSelection,
upsertChatConversationRequest,
updateChatConversationContext,
hasPendingAttentionVerificationRequest,
} from '../services/chat-room-service.js';
import {
archiveChatShareTokenRoomMap,
ensureDefaultChatShareTokenRoomMap,
getChatShareTokenRoomMap,
resolveChatShareTokenRoomSessionIds,
upsertChatShareTokenRoomMap,
type ChatShareRoomLinkContext,
type ChatShareTokenRoomMapItem,
} from '../services/chat-share-room-map-service.js';
import { chatRuntimeService } from '../services/chat-runtime-service.js';
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
import { openResourceManagerPreviewStream } from '../services/resource-manager-service.js';
import { getTokenSettingById, type TokenSettingRecord } from '../services/token-setting-config-service.js';
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 300 * 1024 * 1024;
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 450 * 1024 * 1024;
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
const CHAT_LEGACY_PUBLIC_ROUTE_PREFIX = '/public/.codex_chat/';
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
const CHAT_SHARE_ROUTE_PREFIX = '/api/chat/shares';
const CHAT_SHARE_TOKEN_VERSION = 1;
const RESOURCE_MANAGER_ROOT_PREFIX = 'resource/';
type ChatShareKind = 'request-bundle' | 'inquiry-message' | 'prompt';
type ChatShareTokenPayload = {
version: number;
kind: ChatShareKind;
sessionId: string;
requestId: string;
tokenSettingId: string;
tokenSettingName: string;
tokenSettingDefaultExpiresInMinutes: number;
tokenSettingAllowedAppIds: string[];
tokenSettingMaxTokensPer30Days: number;
tokenSettingMaxTokensPer7Days: number;
tokenSettingMaxTokensPer5Hours: number;
tokenSettingOneTimeTokenLimit: number;
managedResourceTokenId?: string;
sourceMessageId?: number;
promptIndex?: number;
promptSignature?: string;
};
const chatPromptContextRefSchema = z
.object({
key: z.literal('prompt_parent_question'),
promptTitle: z.string().trim().min(1).max(500),
promptDescription: z.string().trim().max(1000).optional().nullable(),
parentQuestionText: z.string().trim().max(1000).optional().nullable(),
})
.optional()
.nullable();
const chatComposerAttachmentSchema = z.object({
id: z.string().trim().min(1).max(240),
name: z.string().trim().min(1).max(500),
path: z.string().trim().min(1).max(4000),
publicUrl: z.string().trim().min(1).max(4000),
size: z.number().finite().min(0).max(CHAT_ATTACHMENT_FILE_SIZE_LIMIT),
mimeType: z.string().trim().min(1).max(255),
});
async function findExistingActivePromptFollowupRequest(
sessionId: string,
parentRequestId: string,
followupText: string,
) {
const normalizedSessionId = sessionId.trim();
const normalizedParentRequestId = parentRequestId.trim();
const normalizedFollowupText = followupText.trim();
if (!normalizedSessionId || !normalizedParentRequestId || !normalizedFollowupText) {
return null;
}
const requests = await listChatConversationRequests(normalizedSessionId, 1000);
return requests.find((request) =>
request.requestOrigin === 'prompt'
&& (request.parentRequestId?.trim() || '') === normalizedParentRequestId
&& request.userText.trim() === normalizedFollowupText
&& (request.status === 'queued' || request.status === 'accepted' || request.status === 'started')
) ?? null;
}
export function resolvePromptFollowupMode(mode?: 'queue' | 'direct' | null) {
return mode === 'direct' ? 'direct' : 'queue';
}
function encodeBase64Url(value: string) {
return Buffer.from(value, 'utf-8').toString('base64url');
}
function decodeBase64Url(value: string) {
return Buffer.from(value, 'base64url').toString('utf-8');
}
function signChatSharePayload(encodedPayload: string) {
return createHmac('sha256', env.SERVER_COMMAND_ACCESS_TOKEN).update(encodedPayload).digest('base64url');
}
function createChatShareToken(payload: ChatShareTokenPayload) {
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
const signature = signChatSharePayload(encodedPayload);
return `${encodedPayload}.${signature}`;
}
function parseChatShareToken(token: string) {
const normalizedToken = token.trim();
if (!normalizedToken) {
return null;
}
const delimiterIndex = normalizedToken.lastIndexOf('.');
if (delimiterIndex <= 0 || delimiterIndex >= normalizedToken.length - 1) {
return null;
}
const encodedPayload = normalizedToken.slice(0, delimiterIndex);
const signature = normalizedToken.slice(delimiterIndex + 1);
const expectedSignature = signChatSharePayload(encodedPayload);
if (signature !== expectedSignature) {
return null;
}
try {
const parsed = JSON.parse(decodeBase64Url(encodedPayload)) as Partial<ChatShareTokenPayload>;
if (
parsed.version !== CHAT_SHARE_TOKEN_VERSION ||
(parsed.kind !== 'request-bundle' && parsed.kind !== 'inquiry-message' && parsed.kind !== 'prompt') ||
typeof parsed.sessionId !== 'string' ||
typeof parsed.requestId !== 'string' ||
typeof parsed.tokenSettingId !== 'string' ||
typeof parsed.tokenSettingName !== 'string'
) {
return null;
}
return {
version: CHAT_SHARE_TOKEN_VERSION,
kind: parsed.kind,
sessionId: parsed.sessionId.trim(),
requestId: parsed.requestId.trim(),
tokenSettingId: parsed.tokenSettingId.trim(),
tokenSettingName: parsed.tokenSettingName.trim(),
tokenSettingDefaultExpiresInMinutes:
typeof parsed.tokenSettingDefaultExpiresInMinutes === 'number' ? parsed.tokenSettingDefaultExpiresInMinutes : 0,
tokenSettingAllowedAppIds: Array.isArray(parsed.tokenSettingAllowedAppIds)
? parsed.tokenSettingAllowedAppIds.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
: [],
tokenSettingMaxTokensPer30Days:
typeof parsed.tokenSettingMaxTokensPer30Days === 'number' ? parsed.tokenSettingMaxTokensPer30Days : 0,
tokenSettingMaxTokensPer7Days:
typeof parsed.tokenSettingMaxTokensPer7Days === 'number' ? parsed.tokenSettingMaxTokensPer7Days : 0,
tokenSettingMaxTokensPer5Hours:
typeof parsed.tokenSettingMaxTokensPer5Hours === 'number' ? parsed.tokenSettingMaxTokensPer5Hours : 0,
tokenSettingOneTimeTokenLimit:
typeof parsed.tokenSettingOneTimeTokenLimit === 'number' ? parsed.tokenSettingOneTimeTokenLimit : 0,
managedResourceTokenId: typeof parsed.managedResourceTokenId === 'string' ? parsed.managedResourceTokenId.trim() : undefined,
sourceMessageId: typeof parsed.sourceMessageId === 'number' ? parsed.sourceMessageId : undefined,
promptIndex: typeof parsed.promptIndex === 'number' ? parsed.promptIndex : undefined,
promptSignature: typeof parsed.promptSignature === 'string' ? parsed.promptSignature.trim() : undefined,
} satisfies ChatShareTokenPayload;
} catch {
return null;
}
}
function resolveChatSharePath(token: string) {
return `/chat/share/${encodeURIComponent(token)}`;
}
function resolveChatShareTokenSettingSnapshot(tokenPayload: ChatShareTokenPayload): SharedResourceTokenSettingSnapshot {
return {
id: tokenPayload.tokenSettingId,
name: tokenPayload.tokenSettingName,
defaultExpiresInMinutes: tokenPayload.tokenSettingDefaultExpiresInMinutes,
maxTokensPer30Days: tokenPayload.tokenSettingMaxTokensPer30Days,
maxTokensPer7Days: tokenPayload.tokenSettingMaxTokensPer7Days,
maxTokensPer5Hours: tokenPayload.tokenSettingMaxTokensPer5Hours,
oneTimeTokenLimit: tokenPayload.tokenSettingOneTimeTokenLimit,
allowedAppIds: tokenPayload.tokenSettingAllowedAppIds,
};
}
type ChatShareResolvedRoom = {
sessionId: string;
requestId: string;
isDefault: boolean;
sortOrder: number;
title: string;
requestBadgeLabel: string | null;
chatTypeId: string | null;
lastChatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
linkContext: ChatShareRoomLinkContext | null;
createdAt: string | null;
updatedAt: string | null;
};
function mapResolvedShareRoomItem(room: ChatShareTokenRoomMapItem): ChatShareResolvedRoom {
return {
sessionId: room.sessionId,
requestId: room.rootRequestId,
isDefault: room.isDefault,
sortOrder: room.sortOrder,
title: room.title,
requestBadgeLabel: room.requestBadgeLabel,
chatTypeId: room.chatTypeId,
lastChatTypeId: room.lastChatTypeId,
contextLabel: room.contextLabel,
contextDescription: room.contextDescription,
notifyOffline: room.notifyOffline,
linkContext: room.linkContext,
createdAt: room.createdAt,
updatedAt: room.conversationUpdatedAt ?? room.updatedAt,
};
}
async function resolveManagedShareRooms(args: {
managedResource:
| Awaited<ReturnType<typeof resolveManagedChatShareContext>>['managedResource']
| null;
tokenPayload: ChatShareTokenPayload;
}) {
const managedResourceTokenId = args.managedResource?.token.id?.trim() || '';
if (!managedResourceTokenId || args.tokenPayload.kind !== 'request-bundle') {
return [
{
sessionId: args.tokenPayload.sessionId,
requestId: args.tokenPayload.requestId,
isDefault: true,
sortOrder: 0,
title: '공유 채팅방',
requestBadgeLabel: null,
chatTypeId: null,
lastChatTypeId: null,
contextLabel: null,
contextDescription: null,
notifyOffline: false,
linkContext: null,
createdAt: null,
updatedAt: null,
} satisfies ChatShareResolvedRoom,
];
}
const ensuredRooms = await ensureDefaultChatShareTokenRoomMap({
tokenId: managedResourceTokenId,
sessionId: args.tokenPayload.sessionId,
rootRequestId: args.tokenPayload.requestId,
createdByClientId: null,
});
if (ensuredRooms.length === 0) {
return [
{
sessionId: args.tokenPayload.sessionId,
requestId: args.tokenPayload.requestId,
isDefault: true,
sortOrder: 0,
title: '공유 채팅방',
requestBadgeLabel: null,
chatTypeId: null,
lastChatTypeId: null,
contextLabel: null,
contextDescription: null,
notifyOffline: false,
linkContext: null,
createdAt: null,
updatedAt: null,
} satisfies ChatShareResolvedRoom,
];
}
return ensuredRooms.map((room) => mapResolvedShareRoomItem(room));
}
async function resolveActiveManagedShareRoom(args: {
managedResource:
| Awaited<ReturnType<typeof resolveManagedChatShareContext>>['managedResource']
| null;
tokenPayload: ChatShareTokenPayload;
requestedSessionId?: string | null;
}) {
const rooms = await resolveManagedShareRooms({
managedResource: args.managedResource,
tokenPayload: args.tokenPayload,
});
const requestedSessionId = args.requestedSessionId?.trim() || '';
const requestedRoom = requestedSessionId ? rooms.find((room) => room.sessionId === requestedSessionId) ?? null : null;
const defaultRoom = rooms.find((room) => room.isDefault) ?? rooms[0] ?? null;
const activeRoom = requestedRoom ?? defaultRoom;
return {
rooms,
activeRoom,
requestedRoomMatched: !requestedSessionId || Boolean(requestedRoom),
};
}
function createManagedChatShareTokenId() {
return `chat_share_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
}
function createManagedChatShareSessionId() {
return `chat-share-room-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
}
function createManagedChatShareRequestId() {
return `share-room-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
}
function createManagedChatShareMessageIds() {
const baseId = Date.now() * 1000 + Math.floor(Math.random() * 100);
return {
userMessageId: baseId,
};
}
function normalizeShareRoomLinkContext(input: {
linkedSessionId?: string | null;
linkedRequestId?: string | null;
linkedTitle?: string | null;
linkedRequestPreview?: string | null;
linkedChatTypeLabel?: string | null;
}): ChatShareRoomLinkContext | null {
const sourceSessionId = input.linkedSessionId?.trim() || '';
const sourceRequestId = input.linkedRequestId?.trim() || '';
if (!sourceSessionId || !sourceRequestId) {
return null;
}
return {
kind: 'linked-session',
sourceSessionId,
sourceRequestId,
sourceTitle: input.linkedTitle?.trim() || null,
sourceRequestPreview: input.linkedRequestPreview?.trim() || null,
sourceChatTypeLabel: input.linkedChatTypeLabel?.trim() || null,
linkedAt: new Date().toISOString(),
};
}
function sortShareMessages(messages: ListedChatConversationMessage[]) {
return [...messages].sort((left, right) => {
if (left.id !== right.id) {
return left.id - right.id;
}
return left.timestamp.localeCompare(right.timestamp);
});
}
function sortShareRequests(requests: ListedChatConversationRequest[]) {
return [...requests].sort((left, right) => {
const createdAtDiff = left.createdAt.localeCompare(right.createdAt);
if (createdAtDiff !== 0) {
return createdAtDiff;
}
return left.requestId.localeCompare(right.requestId);
});
}
async function materializeManagedShareConversation(args: {
shareSnapshot: NonNullable<Awaited<ReturnType<typeof buildChatShareSnapshot>>>;
managedResourceTokenId: string;
ownerClientId: string | null;
shareTitle: string;
}) {
const { shareSnapshot, managedResourceTokenId, ownerClientId, shareTitle } = args;
const sessionId = createManagedChatShareSessionId();
const requestIdSet = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean));
const sortedRequests = sortShareRequests(shareSnapshot.requests);
const sortedMessages = sortShareMessages(shareSnapshot.messages);
const sourceConversation = shareSnapshot.conversation;
await createChatConversation({
sessionId,
clientId: ownerClientId,
title: shareTitle,
draftText: '',
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
codexModel: sourceConversation?.codexModel ?? null,
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
generalSectionName: sourceConversation?.generalSectionName ?? null,
contextLabel: sourceConversation?.contextLabel ?? null,
contextDescription: sourceConversation?.contextDescription ?? null,
notifyOffline: true,
});
for (const message of sortedMessages) {
await appendChatConversationMessage(
{
sessionId,
clientId: ownerClientId,
title: shareTitle,
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
codexModel: sourceConversation?.codexModel ?? null,
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
generalSectionName: sourceConversation?.generalSectionName ?? null,
contextLabel: sourceConversation?.contextLabel ?? null,
contextDescription: sourceConversation?.contextDescription ?? null,
notifyOffline: true,
},
{
sessionId,
messageId: message.id,
author: message.author,
text: message.text,
timestamp: message.timestamp,
clientRequestId: message.clientRequestId?.trim() || null,
parts: message.parts ?? [],
},
);
}
for (const request of sortedRequests) {
const normalizedParentRequestId = request.parentRequestId?.trim() || '';
await upsertChatConversationRequest(sessionId, {
requestId: request.requestId,
requesterClientId: request.requesterClientId ?? ownerClientId,
chatTypeId: request.chatTypeId ?? sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
chatTypeLabel: request.chatTypeLabel ?? sourceConversation?.contextLabel ?? null,
requestOrigin: request.requestOrigin,
sharedResourceTokenId: managedResourceTokenId,
parentRequestId: normalizedParentRequestId && requestIdSet.has(normalizedParentRequestId) ? normalizedParentRequestId : null,
status: request.status,
statusMessage: request.statusMessage,
userMessageId: request.userMessageId,
userText: request.userText,
responseMessageId: request.responseMessageId,
responseText: request.responseText,
usageSnapshot: request.usageSnapshot,
totalTokens: request.totalTokens,
});
}
for (const activityLog of shareSnapshot.activityLogs) {
let lineNo = 1;
for (const line of activityLog.lines) {
await appendChatConversationActivityLine(sessionId, activityLog.requestId, line, lineNo);
lineNo += 1;
}
}
await assignSharedResourceTokenToRequests(
sessionId,
sortedRequests.map((item) => item.requestId),
managedResourceTokenId,
);
return {
sessionId,
requestId:
sortedRequests.find((item) => item.requestId.trim() === shareSnapshot.targetRequest.requestId.trim())?.requestId
?? shareSnapshot.targetRequest.requestId,
};
}
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
case '.ts':
case '.tsx':
case '.js':
case '.jsx':
case '.mjs':
case '.cjs':
case '.json':
case '.css':
case '.txt':
case '.diff':
return 'text/plain; charset=utf-8';
case '.html':
case '.htm':
return 'text/html; charset=utf-8';
case '.md':
case '.markdown':
return 'text/markdown; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.ico':
return 'image/x-icon';
case '.pdf':
return 'application/pdf';
default:
return 'application/octet-stream';
}
}
function buildChatResourcePublicUrl(relativePath: string) {
const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/')}`;
}
function normalizeChatResourceWildcard(wildcard: string) {
const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, '');
if (!cleaned) {
return '';
}
if (cleaned.startsWith('.codex_chat/')) {
return cleaned;
}
if (cleaned === RESOURCE_MANAGER_ROOT_PREFIX.slice(0, -1) || cleaned.startsWith(RESOURCE_MANAGER_ROOT_PREFIX)) {
return cleaned;
}
return path.posix.join('.codex_chat', cleaned);
}
async function serveChatPublicResource(
repoPath: string,
wildcard: string,
reply: FastifyReply,
) {
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
if (!requestedRelativePath) {
return reply.code(404).send({
message: '채팅 리소스를 찾을 수 없습니다.',
});
}
const publicRoot = path.join(repoPath, 'public');
const absolutePath = path.resolve(publicRoot, requestedRelativePath);
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
return reply.code(403).send({
message: '허용되지 않은 경로입니다.',
});
}
try {
await access(absolutePath);
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
return reply.code(404).send({
message: '채팅 리소스를 찾을 수 없습니다.',
});
}
} catch {
return reply.code(404).send({
message: '채팅 리소스를 찾을 수 없습니다.',
});
}
reply.header('Cache-Control', 'no-store');
reply.type(resolveStaticContentType(absolutePath));
return reply.send(createReadStream(absolutePath));
}
async function serveChatSharedResource(
repoPath: string,
wildcard: string,
reply: FastifyReply,
) {
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
const resourceManagerPrefix = `${RESOURCE_MANAGER_ROOT_PREFIX}`;
if (requestedRelativePath.startsWith(resourceManagerPrefix)) {
const resourceManagerRelativePath = requestedRelativePath.slice(resourceManagerPrefix.length).replace(/^\/+/, '');
if (!resourceManagerRelativePath) {
return reply.code(404).send({
message: '공유 리소스를 찾을 수 없습니다.',
});
}
try {
const preview = await openResourceManagerPreviewStream(repoPath, resourceManagerRelativePath);
reply.header('Cache-Control', 'no-store');
reply.header('Content-Length', String(preview.size));
reply.type(preview.contentType);
return reply.send(preview.createStream());
} catch (error) {
const statusCode =
typeof error === 'object' && error && 'statusCode' in error && typeof error.statusCode === 'number'
? error.statusCode
: 500;
const message =
error instanceof Error && error.message.trim()
? error.message
: '공유 리소스를 불러오지 못했습니다.';
return reply.code(statusCode).send({
message,
});
}
}
return serveChatPublicResource(repoPath, requestedRelativePath, reply);
}
function sanitizeChatAttachmentFileName(fileName: string) {
const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' ');
const compact = normalized || 'attachment';
return compact.length > 120 ? compact.slice(-120) : compact;
}
function resolveAttachmentFallbackExtension(mimeType: string | undefined) {
const normalizedMimeType = String(mimeType ?? '').trim().toLowerCase();
switch (normalizedMimeType) {
case 'application/pdf':
return 'pdf';
case 'application/zip':
return 'zip';
case 'image/bmp':
return 'bmp';
case 'image/gif':
return 'gif';
case 'image/heic':
return 'heic';
case 'image/heif':
return 'heif';
case 'image/jpeg':
return 'jpg';
case 'image/png':
return 'png';
case 'image/tiff':
return 'tiff';
case 'image/webp':
return 'webp';
default:
return 'bin';
}
}
function resolveChatAttachmentDisplayFileName(fileName: string | undefined, mimeType: string | undefined) {
const normalizedFileName = String(fileName ?? '').trim();
if (normalizedFileName) {
return normalizedFileName;
}
const extension = resolveAttachmentFallbackExtension(mimeType);
const baseName = String(mimeType ?? '').trim().toLowerCase().startsWith('image/') ? 'pasted-image' : 'attachment';
return `${baseName}-${Date.now().toString(36)}.${extension}`;
}
function resolveChatAttachmentRepoPath() {
return resolveMainProjectRoot();
}
async function saveChatAttachmentFile(args: {
sessionId: string;
fileName?: string;
mimeType?: string;
contentBase64: string;
}) {
const buffer = Buffer.from(args.contentBase64, 'base64');
return saveChatAttachmentBuffer({
sessionId: args.sessionId,
fileName: args.fileName,
mimeType: args.mimeType,
buffer,
});
}
async function saveChatAttachmentBuffer(args: {
sessionId: string;
fileName?: string;
mimeType?: string;
buffer: Buffer;
}) {
const buffer = args.buffer;
if (buffer.byteLength === 0) {
return {
ok: false as const,
statusCode: 400,
message: '업로드할 파일 내용을 찾지 못했습니다.',
};
}
if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) {
return {
ok: false as const,
statusCode: 413,
message: '첨부 파일은 300MB 이하만 업로드할 수 있습니다.',
};
}
const displayFileName = resolveChatAttachmentDisplayFileName(args.fileName, args.mimeType);
const safeFileName = sanitizeChatAttachmentFileName(displayFileName);
const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
const relativePath = path.posix.join(
'public',
'.codex_chat',
args.sessionId,
'resource',
'uploads',
`${fileToken}-${safeFileName}`,
);
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
await ensureChatSessionResourceDirectories(resolveChatAttachmentRepoPath(), args.sessionId);
await writeFile(absolutePath, buffer);
return {
ok: true as const,
item: {
id: randomUUID(),
name: displayFileName,
path: relativePath,
publicUrl: buildChatResourcePublicUrl(relativePath),
size: buffer.byteLength,
mimeType: args.mimeType?.trim() || 'application/octet-stream',
},
};
}
const chatAttachmentJsonBodySchema = z.object({
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
fileName: z.string().trim().max(255).optional(),
mimeType: z.string().trim().max(200).optional(),
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
});
const chatAttachmentShareJsonBodySchema = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
fileName: z.string().trim().max(255).optional(),
mimeType: z.string().trim().max(200).optional(),
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
});
function decodeChatAttachmentHeaderValue(value: unknown) {
const normalized = String(Array.isArray(value) ? value[0] ?? '' : value ?? '').trim();
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function parseChatAttachmentBinaryHeaders(headers: Record<string, unknown>, options?: { allowOptionalSessionId?: boolean }) {
const sessionId = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-session-id']);
const fileName = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-file-name']);
const mimeType = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-mime-type']);
const schema = options?.allowOptionalSessionId
? z.object({
sessionId: z.string().trim().max(120).regex(/^[A-Za-z0-9._:-]+$/).optional().nullable(),
fileName: z.string().trim().max(255).optional(),
mimeType: z.string().trim().max(200).optional(),
})
: z.object({
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
fileName: z.string().trim().max(255).optional(),
mimeType: z.string().trim().max(200).optional(),
});
return schema.parse({
sessionId: sessionId || (options?.allowOptionalSessionId ? null : ''),
fileName: fileName || undefined,
mimeType: mimeType || undefined,
});
}
function isOctetStreamRequest(contentTypeHeader: unknown) {
const contentType = String(Array.isArray(contentTypeHeader) ? contentTypeHeader[0] ?? '' : contentTypeHeader ?? '').toLowerCase();
return contentType.startsWith('application/octet-stream');
}
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
const raw = request.headers['x-client-id'];
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
}
function canViewAllConversations(request: { headers: Record<string, unknown> }) {
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
}
function applyChatApiNoStoreHeaders(reply: FastifyReply) {
reply.header('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate');
reply.header('Pragma', 'no-cache');
reply.header('Expires', '0');
reply.header('Surrogate-Control', 'no-store');
}
type ListedChatConversationRequest = Awaited<ReturnType<typeof listChatConversationRequests>>[number];
type ListedChatConversationMessage = Awaited<ReturnType<typeof listChatConversationMessages>>[number];
type ListedChatConversationActivityLog = Awaited<ReturnType<typeof listChatConversationActivityLogsByRequestIds>>[number];
function resolveShareRootRequestId(
requestId: string,
requestMap: Map<string, ListedChatConversationRequest>,
kind: ChatShareKind,
) {
const normalizedRequestId = requestId.trim();
if (!normalizedRequestId || kind !== 'request-bundle') {
return normalizedRequestId;
}
const visitedRequestIds = new Set<string>();
let currentRequestId = normalizedRequestId;
while (currentRequestId && !visitedRequestIds.has(currentRequestId)) {
visitedRequestIds.add(currentRequestId);
const currentRequest = requestMap.get(currentRequestId);
const parentRequestId = currentRequest?.parentRequestId?.trim() || '';
if (!parentRequestId || !requestMap.has(parentRequestId)) {
break;
}
currentRequestId = parentRequestId;
}
return currentRequestId;
}
function buildChildRequestIdsByParentRequestId(requests: ListedChatConversationRequest[]) {
const nextMap = new Map<string, string[]>();
requests.forEach((request) => {
const parentRequestId = request.parentRequestId?.trim() || '';
const requestId = request.requestId.trim();
if (!parentRequestId || !requestId) {
return;
}
const currentChildRequestIds = nextMap.get(parentRequestId) ?? [];
currentChildRequestIds.push(requestId);
nextMap.set(parentRequestId, currentChildRequestIds);
});
return nextMap;
}
function collectShareScopeRequestIds(
rootRequestId: string,
childRequestIdsByParentRequestId: Map<string, string[]>,
) {
const normalizedRootRequestId = rootRequestId.trim();
if (!normalizedRootRequestId) {
return [];
}
const orderedRequestIds: string[] = [];
const pendingRequestIds = [normalizedRootRequestId];
const visitedRequestIds = new Set<string>();
while (pendingRequestIds.length > 0) {
const currentRequestId = pendingRequestIds.shift()?.trim() || '';
if (!currentRequestId || visitedRequestIds.has(currentRequestId)) {
continue;
}
visitedRequestIds.add(currentRequestId);
orderedRequestIds.push(currentRequestId);
(childRequestIdsByParentRequestId.get(currentRequestId) ?? []).forEach((childRequestId) => {
if (!visitedRequestIds.has(childRequestId)) {
pendingRequestIds.push(childRequestId);
}
});
}
return orderedRequestIds;
}
function resolvePromptTarget(
messages: ListedChatConversationMessage[],
tokenPayload: ChatShareTokenPayload,
) {
const preferredMessage = tokenPayload.sourceMessageId
? messages.find((message) => message.id === tokenPayload.sourceMessageId)
: null;
const candidateMessages = preferredMessage
? [preferredMessage, ...messages.filter((message) => message.id !== preferredMessage.id)]
: messages;
for (const message of candidateMessages) {
const promptParts: Array<{
index: number;
part: Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>;
}> = (message.parts ?? []).flatMap((part: NonNullable<ListedChatConversationMessage['parts']>[number], index: number) =>
part.type === 'prompt' ? [{ index, part }] : [],
);
const directMatch = tokenPayload.promptIndex != null ? promptParts[tokenPayload.promptIndex] ?? null : null;
if (
directMatch &&
tokenPayload.promptSignature &&
buildChatPromptTargetSignature(directMatch.part) === tokenPayload.promptSignature
) {
return {
sourceMessageId: message.id,
promptIndex: directMatch.index,
prompt: directMatch.part,
};
}
const matchedPrompt = promptParts.find(({ part }) =>
tokenPayload.promptSignature ? buildChatPromptTargetSignature(part) === tokenPayload.promptSignature : false,
);
if (matchedPrompt) {
return {
sourceMessageId: message.id,
promptIndex: matchedPrompt.index,
prompt: matchedPrompt.part,
};
}
}
return null;
}
function isPromptPartResolved(
part: Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>,
) {
return part.readOnly === true || part.resolvedBy != null;
}
function hasUnresolvedPromptPart(message: ListedChatConversationMessage) {
const promptParts = (message.parts ?? []).filter(
(
part: NonNullable<ListedChatConversationMessage['parts']>[number],
): part is Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }> => part.type === 'prompt',
);
return promptParts.some(
(part: Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>) =>
!isPromptPartResolved(part),
);
}
function hasPendingPromptRequest(
request: ListedChatConversationRequest,
relatedMessages: ListedChatConversationMessage[],
promptSubmittedCount = 0,
) {
if (request.manualPromptCompletedAt) {
return false;
}
const unresolvedPromptCount = relatedMessages.reduce((count, message) => {
if (message.author !== 'codex' && message.author !== 'system') {
return count;
}
const promptParts = (message.parts ?? []).filter(
(
part: NonNullable<ListedChatConversationMessage['parts']>[number],
): part is Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }> => part.type === 'prompt',
);
return count + promptParts.filter(
(part: Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>) => !isPromptPartResolved(part),
).length;
}, 0);
if (unresolvedPromptCount === 0) {
return false;
}
return unresolvedPromptCount > Math.max(0, promptSubmittedCount);
}
function hasVerificationTargetRequest(
request: ListedChatConversationRequest,
relatedMessages: ListedChatConversationMessage[],
) {
return hasPendingAttentionVerificationRequest(request, relatedMessages);
}
function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) {
const nextMap = new Map<string, number>();
requests.forEach((request) => {
const parentRequestId = request.parentRequestId?.trim() || '';
if (!parentRequestId) {
return;
}
nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1);
});
return nextMap;
}
function buildPromptFollowupCountMap(requests: ListedChatConversationRequest[]) {
const nextMap = new Map<string, number>();
requests.forEach((request) => {
if (request.requestOrigin !== 'prompt') {
return;
}
const parentRequestId = request.parentRequestId?.trim() || '';
if (!parentRequestId) {
return;
}
nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1);
});
return nextMap;
}
function isPromptFollowupRoomRequest(request: ListedChatConversationRequest) {
return request.requestOrigin === 'prompt';
}
function isRequestInFlight(status: ListedChatConversationRequest['status']) {
return status === 'accepted' || status === 'queued' || status === 'started';
}
function isPendingCompletionRoomRequest(
request: ListedChatConversationRequest,
relatedMessages: ListedChatConversationMessage[],
childRequestCountByParentId?: Map<string, number>,
promptFollowupCountByParentId?: Map<string, number>,
) {
if (isPromptFollowupRoomRequest(request)) {
return false;
}
if (isRequestInFlight(request.status)) {
return true;
}
if (hasPendingPromptRequest(request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0)) {
return true;
}
if (request.manualPromptCompletedAt) {
return false;
}
if ((childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0) {
return false;
}
if (!hasVerificationTargetRequest(request, relatedMessages)) {
return false;
}
return !request.manualVerificationCompletedAt;
}
function buildRoomRequestCounts(
requests: ListedChatConversationRequest[],
messages: ListedChatConversationMessage[],
) {
const requestMessagesById = new Map<string, ListedChatConversationMessage[]>();
const childRequestCountByParentId = buildChildRequestCountMap(requests);
const promptFollowupCountByParentId = buildPromptFollowupCountMap(requests);
messages.forEach((message) => {
const requestId = message.clientRequestId?.trim() || '';
if (!requestId) {
return;
}
const current = requestMessagesById.get(requestId) ?? [];
current.push(message);
requestMessagesById.set(requestId, current);
});
const processingCount = requests.filter(
(request) => !isPromptFollowupRoomRequest(request) && isRequestInFlight(request.status),
).length;
const unansweredCount = requests.filter((request) =>
isPendingCompletionRoomRequest(
request,
requestMessagesById.get(request.requestId.trim()) ?? [],
childRequestCountByParentId,
promptFollowupCountByParentId,
),
).length;
return {
processingCount,
unansweredCount,
};
}
function buildManagedSharePlaceholderRequest(tokenPayload: ChatShareTokenPayload, conversation: Awaited<ReturnType<typeof getChatConversation>>) {
const normalizedSessionId = tokenPayload.sessionId.trim();
const normalizedRequestId = tokenPayload.requestId.trim();
if (
tokenPayload.kind !== 'request-bundle' ||
!normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX) ||
!normalizedRequestId
) {
return null;
}
const createdAt = conversation?.createdAt?.trim() || new Date().toISOString();
const updatedAt = conversation?.updatedAt?.trim() || createdAt;
const chatTypeLabel = conversation?.contextLabel?.trim() || '기본처리';
return {
sessionId: normalizedSessionId,
requestId: normalizedRequestId,
requesterClientId: conversation?.clientId?.trim() || null,
chatTypeId: conversation?.chatTypeId?.trim() || conversation?.lastChatTypeId?.trim() || null,
chatTypeLabel,
requestOrigin: 'composer',
sharedResourceTokenId: tokenPayload.managedResourceTokenId?.trim() || null,
parentRequestId: null,
promptContextRef: null,
status: 'completed',
statusMessage: '공유 채팅방 시작 요청을 복원했습니다.',
retryCount: 0,
userMessageId: null,
userText: '',
responseMessageId: null,
responseText: '',
usageSnapshot: null,
totalTokens: null,
hasResponse: false,
canDelete: false,
manualPromptCompletedAt: null,
manualVerificationCompletedAt: null,
createdAt,
updatedAt,
answeredAt: null,
terminalAt: createdAt,
} satisfies ListedChatConversationRequest;
}
async function buildChatShareSnapshot(
tokenPayload: ChatShareTokenPayload,
options?: {
sessionId?: string | null;
requestId?: string | null;
detailLevel?: 'full' | 'initial';
},
) {
const normalizedSessionId = options?.sessionId?.trim() || tokenPayload.sessionId.trim();
const detailLevel = options?.detailLevel === 'initial' ? 'initial' : 'full';
const conversation = await getChatConversation(normalizedSessionId, null);
if (!conversation) {
return null;
}
const isManagedShareRoomSession =
tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
const useInitialManagedShareRoomView = isManagedShareRoomSession && detailLevel === 'initial';
const detailPage = useInitialManagedShareRoomView
? await listChatConversationDetailPage(normalizedSessionId, {
limit: 12,
includeActivityLogs: false,
includePagination: false,
})
: null;
const [requests, messages] = detailPage
? [detailPage.requests, detailPage.messages]
: await Promise.all([
listChatConversationRequests(normalizedSessionId, 1000),
listChatConversationMessages(normalizedSessionId, { limit: 1000 }),
]);
const requestMap = new Map(requests.map((request) => [request.requestId.trim(), request] as const));
const targetRequestId = options?.requestId?.trim() || tokenPayload.requestId.trim();
const targetRequestFromStore = requestMap.get(targetRequestId) ?? await getChatConversationRequest(normalizedSessionId, targetRequestId);
const placeholderTargetRequest = targetRequestFromStore ? null : buildManagedSharePlaceholderRequest(tokenPayload, conversation);
const targetRequest = targetRequestFromStore ?? placeholderTargetRequest;
if (!targetRequest) {
return null;
}
const childRequestIdsByParentRequestId = buildChildRequestIdsByParentRequestId(requests);
const isManagedShareRoomPlaceholder = isManagedShareRoomSession && !targetRequestFromStore;
const rootRequestId = isManagedShareRoomPlaceholder
? targetRequestId
: resolveShareRootRequestId(targetRequestId, requestMap, tokenPayload.kind);
const scopeRequestIds = useInitialManagedShareRoomView
? requests.map((request) => request.requestId.trim()).filter(Boolean)
: isManagedShareRoomSession
? requests.map((request) => request.requestId.trim()).filter(Boolean)
: collectShareScopeRequestIds(rootRequestId, childRequestIdsByParentRequestId);
const scopeRequestIdSet = new Set(scopeRequestIds);
const scopedRequests = requests.filter((request) => scopeRequestIdSet.has(request.requestId.trim()));
const scopedMessages = messages.filter((message: ListedChatConversationMessage) => {
const linkedRequestId = message.clientRequestId?.trim() || '';
return linkedRequestId ? scopeRequestIdSet.has(linkedRequestId) : false;
});
const activityLogs = useInitialManagedShareRoomView
? []
: await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
const oldestLoadedMessageId = detailPage?.oldestLoadedMessageId ?? null;
const hasOlderMessages = detailPage?.hasOlderMessages ?? false;
if (tokenPayload.kind === 'prompt' && !promptTarget) {
return null;
}
return {
conversation,
rootRequestId,
targetRequest,
isRecoveredManagedShareRoom: isManagedShareRoomPlaceholder,
requests: scopedRequests,
messages: scopedMessages,
activityLogs,
roomRequestCounts,
oldestLoadedMessageId,
hasOlderMessages,
promptTarget,
detailLevel,
} satisfies {
conversation: Awaited<ReturnType<typeof getChatConversation>>;
rootRequestId: string;
targetRequest: ListedChatConversationRequest;
isRecoveredManagedShareRoom: boolean;
requests: ListedChatConversationRequest[];
messages: ListedChatConversationMessage[];
activityLogs: ListedChatConversationActivityLog[];
roomRequestCounts: {
processingCount: number;
unansweredCount: number;
} | undefined;
oldestLoadedMessageId: number | null;
hasOlderMessages: boolean;
promptTarget:
| {
sourceMessageId: number;
promptIndex: number;
prompt: Extract<ListedChatConversationMessage['parts'][number], { type: 'prompt' }>;
}
| null;
detailLevel: 'full' | 'initial';
};
}
function resolveRecoveredShareParentRequestId(
shareSnapshot: Awaited<ReturnType<typeof buildChatShareSnapshot>>,
requestedParentRequestId: string | null | undefined,
fallbackRequestIds: Array<string | null | undefined> = [],
) {
if (!shareSnapshot) {
return null;
}
const allowedRequestIds = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean));
const normalizedRequestedParentRequestId = requestedParentRequestId?.trim() || '';
if (normalizedRequestedParentRequestId && allowedRequestIds.has(normalizedRequestedParentRequestId)) {
return normalizedRequestedParentRequestId;
}
const normalizedFallbackRequestIds = fallbackRequestIds.map((value) => value?.trim() || '').filter(Boolean);
for (const fallbackRequestId of normalizedFallbackRequestIds) {
if (allowedRequestIds.has(fallbackRequestId)) {
return fallbackRequestId;
}
}
if (!shareSnapshot.isRecoveredManagedShareRoom) {
return normalizedRequestedParentRequestId || null;
}
return shareSnapshot.requests[shareSnapshot.requests.length - 1]?.requestId?.trim() || null;
}
function resolveRequestUsageAt(request: ListedChatConversationRequest) {
const candidates = [request.answeredAt, request.terminalAt, request.updatedAt, request.createdAt];
for (const candidate of candidates) {
const normalized = candidate?.trim();
if (!normalized) {
continue;
}
const parsed = Date.parse(normalized);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}
function resolveRequestUsageTokens(request: ListedChatConversationRequest) {
const total =
request.usageSnapshot?.tokenTotals?.total ??
request.usageSnapshot?.totalTokens ??
request.totalTokens ??
0;
return Math.max(0, Math.round(Number(total) || 0));
}
type ShareTokenLimitSetting = Pick<TokenSettingRecord, 'maxTokensPer7Days' | 'maxTokensPer5Hours'>;
function resolvePeriodTokenLimit(setting: ShareTokenLimitSetting, periodKey: '7d' | '5h') {
if (periodKey === '7d') {
return setting.maxTokensPer7Days;
}
return setting.maxTokensPer5Hours;
}
function resolveTokenUsageInWindow(requests: ListedChatConversationRequest[], nowMs: number, windowMs: number) {
const threshold = nowMs - windowMs;
return requests.reduce((sum, request) => {
if (resolveRequestUsageAt(request) < threshold) {
return sum;
}
return sum + resolveRequestUsageTokens(request);
}, 0);
}
function resolveShareBlockedReason(requests: ListedChatConversationRequest[], tokenSetting: ShareTokenLimitSetting) {
const nowMs = Date.now();
const periodWindows: Array<{ key: '7d' | '5h'; label: string; windowMs: number }> = [
{ key: '7d', label: '7일', windowMs: 7 * 24 * 60 * 60 * 1000 },
{ key: '5h', label: '5시간', windowMs: 5 * 60 * 60 * 1000 },
];
for (const period of periodWindows) {
const limit = resolvePeriodTokenLimit(tokenSetting, period.key);
if (limit <= 0) {
continue;
}
const used = resolveTokenUsageInWindow(requests, nowMs, period.windowMs);
if (used >= limit) {
return `${period.label} 사용 가능 토큰 한도(${limit.toLocaleString('ko-KR')})를 모두 사용했습니다.`;
}
}
return null;
}
async function resolveManagedChatShareContext(token: string) {
const sharePath = resolveChatSharePath(token);
const managedResource = await getSharedResourceTokenDetailBySharePath(sharePath);
return {
sharePath,
managedResource,
};
}
function buildManagedShareRoomRuntimeSnapshot(
snapshot: ReturnType<typeof chatRuntimeService.getSnapshot>,
sessionId: string,
) {
const normalizedSessionId = sessionId.trim();
const running = snapshot.running.filter((item) => item.sessionId === normalizedSessionId);
const queued = snapshot.queued.filter((item) => item.sessionId === normalizedSessionId);
const sessions = snapshot.sessions.filter((item) => item.sessionId === normalizedSessionId);
const recent = snapshot.recent.filter((item) => item.sessionId === normalizedSessionId);
return {
generatedAt: snapshot.generatedAt,
runningCount: running.length,
queuedCount: queued.length,
sessionCount: sessions.length,
running,
queued,
sessions,
recent,
};
}
function resolveChatSharePayloadFromManagedResource(
managedResource:
| Awaited<ReturnType<typeof getSharedResourceTokenDetailBySharePath>>
| null
| undefined,
) {
const resourceContext = managedResource?.token.resourceContext;
const sessionId = resourceContext?.sessionId?.trim() || '';
const requestId = resourceContext?.requestId?.trim() || '';
if (!resourceContext || !sessionId || !requestId) {
return null;
}
return {
version: CHAT_SHARE_TOKEN_VERSION,
kind: resourceContext.kind ?? 'request-bundle',
sessionId,
requestId,
tokenSettingId: managedResource?.token.tokenSettingSnapshot?.id ?? '',
tokenSettingName: managedResource?.token.tokenSettingSnapshot?.name ?? '',
tokenSettingDefaultExpiresInMinutes: managedResource?.token.tokenSettingSnapshot?.defaultExpiresInMinutes ?? 0,
tokenSettingAllowedAppIds: managedResource?.token.allowedAppIds ?? managedResource?.token.tokenSettingSnapshot?.allowedAppIds ?? [],
tokenSettingMaxTokensPer30Days: managedResource?.token.tokenSettingSnapshot?.maxTokensPer30Days ?? 0,
tokenSettingMaxTokensPer7Days: managedResource?.token.tokenSettingSnapshot?.maxTokensPer7Days ?? 0,
tokenSettingMaxTokensPer5Hours: managedResource?.token.tokenSettingSnapshot?.maxTokensPer5Hours ?? 0,
tokenSettingOneTimeTokenLimit: managedResource?.token.tokenSettingSnapshot?.oneTimeTokenLimit ?? 0,
managedResourceTokenId: managedResource?.token.id,
sourceMessageId: resourceContext.sourceMessageId ?? undefined,
promptIndex: resourceContext.promptIndex ?? undefined,
promptSignature: resourceContext.promptSignature ?? undefined,
} satisfies ChatShareTokenPayload;
}
function resolveManagedShareUnavailableMessage(
managedResource:
| {
token: {
enabled: boolean;
revokedAt: string | null;
expiresAt: string | null;
createdAt: string;
resourceType?: string | null;
tokenSettingSnapshot?: SharedResourceTokenSettingSnapshot | null;
linkedTokenSetting?: {
defaultExpiresInMinutes: number;
syncState: 'ok' | 'missing' | 'disabled' | 'chat-share-disallowed';
syncMessage: string | null;
} | null;
};
}
| null
| undefined,
) {
if (!managedResource) {
return null;
}
if (managedResource.token.revokedAt || managedResource.token.enabled === false) {
return '이 공유 링크는 현재 비활성 또는 회수 상태입니다.';
}
const effectiveExpiresAt = resolveSharedResourceTokenEffectiveExpiresAt(
managedResource.token,
managedResource.token.tokenSettingSnapshot,
);
if (effectiveExpiresAt && Date.parse(effectiveExpiresAt) <= Date.now()) {
return '이 공유 링크는 만료되었습니다.';
}
if (managedResource.token.resourceType === 'chat-share') {
return null;
}
return null;
}
function getRequestChatSharePin(request: {
headers: Record<string, string | string[] | undefined>;
query?: unknown;
}) {
const pinHeader = request.headers['x-chat-share-pin'];
const headerPin = Array.isArray(pinHeader) ? pinHeader[0] : pinHeader;
if (typeof headerPin === 'string' && headerPin.trim()) {
return headerPin.trim();
}
const queryRecord = request.query && typeof request.query === 'object' ? (request.query as Record<string, unknown>) : null;
const queryPin = queryRecord && typeof queryRecord.sharePin === 'string' ? queryRecord.sharePin : '';
return queryPin.trim();
}
function getRequestClientId(request: {
headers: Record<string, string | string[] | undefined>;
query?: unknown;
}) {
const headerClientId = getClientIdHeader(request as { headers: Record<string, unknown> });
if (headerClientId) {
return headerClientId;
}
const queryRecord = request.query && typeof request.query === 'object' ? (request.query as Record<string, unknown>) : null;
const queryClientId = queryRecord && typeof queryRecord.clientId === 'string' ? queryRecord.clientId : '';
return queryClientId.trim();
}
async function ensureManagedShareAccessPin(
request: { headers: Record<string, string | string[] | undefined>; query?: unknown },
reply: FastifyReply,
sharePath: string,
) {
const pinStatus = await validateSharedResourceAccessPinBySharePath(sharePath, getRequestChatSharePin(request), {
clientId: getRequestClientId(request),
});
if (pinStatus.status === 'ok' || pinStatus.status === 'not-configured') {
return pinStatus;
}
if (pinStatus.status === 'required') {
reply.code(401).send({
code: 'share_pin_required',
message: '이 공유 채팅방은 4자리 비밀번호 입력이 필요합니다.',
});
return null;
}
if (pinStatus.status === 'invalid') {
reply.code(401).send({
code: 'share_pin_invalid',
message: '공유 채팅방 비밀번호가 올바르지 않습니다.',
});
return null;
}
if (pinStatus.status === 'missing') {
reply.code(404).send({
message: '공유 링크를 찾을 수 없습니다.',
});
return null;
}
return null;
}
function hasManagedSharePermission(
managedResource:
| Awaited<ReturnType<typeof resolveManagedChatShareContext>>['managedResource']
| null
| undefined,
permission: 'manage',
) {
return managedResource?.token.permissions?.includes(permission) === true;
}
function hasManagedShareAllowedApp(
managedResource:
| Awaited<ReturnType<typeof resolveManagedChatShareContext>>['managedResource']
| null
| undefined,
appId: string,
) {
const normalizedAppId = appId.trim().toLowerCase();
const allowedAppIds = managedResource?.token.allowedAppIds;
return (allowedAppIds ?? []).some((item) => item.trim().toLowerCase() === normalizedAppId);
}
export async function registerChatRoutes(app: FastifyInstance) {
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (request, body, done) => {
done(null, body);
});
app.addHook('onSend', async (request, reply, payload) => {
if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) {
applyChatApiNoStoreHeaders(reply);
}
return payload;
});
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
});
app.get(`${CHAT_LEGACY_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
});
app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
});
app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/resources/*`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath);
if (!accessPinStatus) {
return;
}
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
const mappedSessionIds = managedContext.managedResource?.token.id
? await resolveChatShareTokenRoomSessionIds(managedContext.managedResource.token.id)
: [];
const allowedSessionIds = Array.from(new Set([tokenPayload.sessionId, ...mappedSessionIds].filter(Boolean)));
const isAllowedSessionResource = allowedSessionIds.some((sessionId) =>
requestedRelativePath.startsWith(path.posix.join('.codex_chat', sessionId, 'resource') + '/'),
);
const isAllowedManagedResource = requestedRelativePath.startsWith(RESOURCE_MANAGER_ROOT_PREFIX);
if (!requestedRelativePath || (!isAllowedSessionResource && !isAllowedManagedResource)) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 리소스입니다.',
});
}
return serveChatSharedResource(resolveChatAttachmentRepoPath(), requestedRelativePath, reply);
});
app.get('/api/chat/setup', async () => {
await ensureChatConversationTables();
return {
ok: true,
tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'],
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/rooms`, async (request, reply) => {
const parsedPayload = z.object({
tokenSettingId: z.string().trim().min(1).max(120),
chatTypeId: z.string().trim().min(1).max(120),
chatTypeLabel: z.string().trim().min(1).max(200),
name: z.string().trim().max(160).optional(),
tokenName: z.string().trim().max(160).optional(),
title: z.string().trim().max(200).optional(),
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
seedMessage: z.string().trim().min(1).max(20000),
allowManageAccess: z.boolean().optional(),
}).parse(request.body ?? {});
const payload = {
...parsedPayload,
name: parsedPayload.name || parsedPayload.title || parsedPayload.tokenName || '',
};
if (!payload.name) {
return reply.code(400).send({
message: '공유 이름을 입력해 주세요.',
});
}
const tokenSetting = await getTokenSettingById(payload.tokenSettingId);
if (!tokenSetting || !tokenSetting.enabled) {
return reply.code(404).send({
message: '공유에 사용할 토큰 설정을 찾을 수 없습니다.',
});
}
if (!tokenSetting.allowedAppIds.some((item) => item.trim().toLowerCase() === 'chat-live')) {
return reply.code(409).send({
message: '선택한 토큰 설정에는 공유 채팅방 권한이 없습니다.',
});
}
const clientId = getClientIdHeader(request);
const sessionId = createManagedChatShareSessionId();
const requestId = createManagedChatShareRequestId();
const { userMessageId } = createManagedChatShareMessageIds();
const createdAt = new Date().toISOString();
await createChatConversation({
sessionId,
clientId: clientId || null,
title: payload.name,
requestBadgeLabel: payload.requestBadgeLabel ?? null,
chatTypeId: payload.chatTypeId,
lastChatTypeId: payload.chatTypeId,
contextLabel: payload.chatTypeLabel,
contextDescription: null,
notifyOffline: true,
});
await appendChatConversationMessage(
{
sessionId,
clientId: clientId || null,
title: payload.name,
requestBadgeLabel: payload.requestBadgeLabel ?? null,
chatTypeId: payload.chatTypeId,
lastChatTypeId: payload.chatTypeId,
contextLabel: payload.chatTypeLabel,
contextDescription: null,
notifyOffline: true,
},
{
sessionId,
messageId: userMessageId,
author: 'user',
text: payload.seedMessage,
timestamp: createdAt,
clientRequestId: requestId,
parts: [],
},
);
await upsertChatConversationRequest(sessionId, {
requestId,
requesterClientId: clientId || null,
chatTypeId: payload.chatTypeId,
chatTypeLabel: payload.chatTypeLabel,
requestOrigin: 'composer',
status: 'completed',
statusMessage: '공유 채팅방 시작 요청을 준비했습니다.',
userMessageId,
userText: payload.seedMessage,
});
const managedResourceTokenId = createManagedChatShareTokenId();
const token = randomUUID().replace(/-/g, '').slice(0, 24);
const sharePath = resolveChatSharePath(token);
const permissions = payload.allowManageAccess === true
? (['view', 'comment', 'download', 'upload', 'manage'] as const)
: (['view', 'comment', 'download', 'upload'] as const);
await upsertSharedResourceToken({
id: managedResourceTokenId,
name: payload.name,
description: [tokenSetting.description, `공유 유형: room`, `채팅유형: ${payload.chatTypeLabel}`].filter(Boolean).join('\n'),
tokenSettingId: tokenSetting.id,
tokenSettingSnapshot: {
id: tokenSetting.id,
name: tokenSetting.name,
defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
maxTokensPer30Days: tokenSetting.maxTokensPer30Days,
maxTokensPer7Days: tokenSetting.maxTokensPer7Days,
maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
allowedAppIds: tokenSetting.allowedAppIds,
},
resourceContext: {
kind: 'request-bundle',
sessionId,
requestId,
},
resourceAllowedAppIds: tokenSetting.allowedAppIds,
resourceLabel: payload.name,
resourcePath: sharePath,
resourceType: 'chat-share',
sharePath,
permissions: [...permissions],
enabled: true,
expiresAt:
tokenSetting.defaultExpiresInMinutes > 0
? new Date(Date.now() + tokenSetting.defaultExpiresInMinutes * 60 * 1000).toISOString()
: null,
usageLimit: 0,
});
await assignSharedResourceTokenToRequests(sessionId, [requestId], managedResourceTokenId);
await upsertChatShareTokenRoomMap({
tokenId: managedResourceTokenId,
sessionId,
rootRequestId: requestId,
isDefault: true,
createdByClientId: clientId || null,
});
return {
ok: true,
sessionId,
requestId,
token,
sharePath,
managedResourceTokenId,
name: payload.name,
requestBadgeLabel: payload.requestBadgeLabel ?? null,
seedMessage: payload.seedMessage,
permissions,
hasAccessPin: false,
tokenSetting: {
id: tokenSetting.id,
name: tokenSetting.name,
defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
maxTokensPer30Days: tokenSetting.maxTokensPer30Days,
maxTokensPer7Days: tokenSetting.maxTokensPer7Days,
maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
allowedAppIds: tokenSetting.allowedAppIds,
},
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/rooms`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
chatTypeId: z.string().trim().min(1).max(120),
chatTypeLabel: z.string().trim().min(1).max(200),
title: z.string().trim().min(1).max(200),
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
seedMessage: z.string().trim().min(1).max(20000),
linkedSessionId: z.string().trim().min(1).max(120).optional().nullable(),
linkedRequestId: z.string().trim().min(1).max(120).optional().nullable(),
linkedTitle: z.string().trim().max(200).optional().nullable(),
linkedRequestPreview: z.string().trim().max(1000).optional().nullable(),
linkedChatTypeLabel: z.string().trim().max(200).optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath);
if (!accessPinStatus) {
return;
}
if (!managedContext.managedResource || managedContext.managedResource.token.resourceType !== 'chat-share') {
return reply.code(404).send({
message: '공유 채팅방 정보를 찾을 수 없습니다.',
});
}
if (!managedContext.managedResource.token.permissions.includes('manage')) {
return reply.code(403).send({
message: '이 공유 링크에는 새 채팅방을 추가할 권한이 없습니다.',
});
}
const currentToken = managedContext.managedResource.token;
const clientId = getRequestClientId(request);
const sessionId = createManagedChatShareSessionId();
const requestId = createManagedChatShareRequestId();
const { userMessageId } = createManagedChatShareMessageIds();
const createdAt = new Date().toISOString();
await createChatConversation({
sessionId,
clientId: clientId || null,
title: payload.title,
requestBadgeLabel: payload.requestBadgeLabel ?? null,
chatTypeId: payload.chatTypeId,
lastChatTypeId: payload.chatTypeId,
contextLabel: payload.chatTypeLabel,
contextDescription: null,
notifyOffline: true,
});
await appendChatConversationMessage(
{
sessionId,
clientId: clientId || null,
title: payload.title,
requestBadgeLabel: payload.requestBadgeLabel ?? null,
chatTypeId: payload.chatTypeId,
lastChatTypeId: payload.chatTypeId,
contextLabel: payload.chatTypeLabel,
contextDescription: null,
notifyOffline: true,
},
{
sessionId,
messageId: userMessageId,
author: 'user',
text: payload.seedMessage,
timestamp: createdAt,
clientRequestId: requestId,
parts: [],
},
);
await upsertChatConversationRequest(sessionId, {
requestId,
requesterClientId: clientId || null,
chatTypeId: payload.chatTypeId,
chatTypeLabel: payload.chatTypeLabel,
requestOrigin: 'composer',
sharedResourceTokenId: currentToken.id,
status: 'completed',
statusMessage: '공유 채팅방 시작 요청을 준비했습니다.',
userMessageId,
userText: payload.seedMessage,
});
await assignSharedResourceTokenToRequests(sessionId, [requestId], currentToken.id);
await upsertChatShareTokenRoomMap({
tokenId: currentToken.id,
sessionId,
rootRequestId: requestId,
isDefault: false,
createdByClientId: clientId || null,
linkContext: normalizeShareRoomLinkContext(payload),
});
const room = await getChatShareTokenRoomMap(currentToken.id, sessionId);
return {
ok: true,
room: room
? mapResolvedShareRoomItem(room)
: {
sessionId,
requestId,
isDefault: false,
sortOrder: 0,
title: payload.title,
requestBadgeLabel: payload.requestBadgeLabel ?? null,
chatTypeId: payload.chatTypeId,
lastChatTypeId: payload.chatTypeId,
contextLabel: payload.chatTypeLabel,
contextDescription: null,
notifyOffline: true,
linkContext: normalizeShareRoomLinkContext(payload),
createdAt,
updatedAt: createdAt,
},
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/room-settings`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(),
accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(),
chatTypeId: z.string().trim().min(1).max(120).optional().nullable(),
chatTypeLabel: z.string().trim().min(1).max(200).optional().nullable(),
title: z.string().trim().min(1).max(200).optional().nullable(),
notifyOffline: z.boolean().optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath);
if (!accessPinStatus) {
return;
}
if (!managedContext.managedResource || managedContext.managedResource.token.resourceType !== 'chat-share') {
return reply.code(404).send({
message: '공유 채팅방 설정 대상을 찾을 수 없습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const currentToken = managedContext.managedResource.token;
if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) {
return reply.code(403).send({
message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.',
});
}
const saved = await upsertSharedResourceToken({
id: currentToken.id,
name: currentToken.name,
description: currentToken.description,
tokenSettingId: currentToken.tokenSettingId,
tokenSettingSnapshot: currentToken.tokenSettingSnapshot,
resourceLabel: currentToken.resourceLabel,
resourcePath: currentToken.resourcePath,
resourceType: currentToken.resourceType,
shareToken: currentToken.shareToken,
sharePath: currentToken.sharePath,
resourceAllowedAppIds: currentToken.allowedAppIds,
resourceAllowedAppIdsOverrideEnabled: currentToken.resourceAllowedAppIdsOverrideEnabled,
permissions: currentToken.permissions,
enabled: currentToken.enabled,
expiresAt: currentToken.expiresAt,
usageLimit: currentToken.usageLimit,
accessPin: payload.accessPin,
accessPinPromptTtlMinutes: payload.accessPinPromptTtlMinutes,
allowAccessPinChangeWithoutManage: false,
});
if (!saved) {
return reply.code(404).send({
message: '공유 채팅방 설정 저장에 실패했습니다.',
});
}
let updatedConversation = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, getRequestClientId(request));
if (payload.chatTypeId || payload.title || payload.notifyOffline != null) {
updatedConversation = await updateChatConversationContext(resolvedRoomContext.activeRoom.sessionId, {
clientId: getRequestClientId(request),
chatTypeId: payload.chatTypeId?.trim() || undefined,
lastChatTypeId: payload.chatTypeId?.trim() || undefined,
contextLabel: payload.chatTypeLabel?.trim() || undefined,
contextDescription: payload.chatTypeId ? null : undefined,
title: payload.title?.trim() || undefined,
notifyOffline: payload.notifyOffline ?? undefined,
});
}
return {
ok: true,
hasAccessPin: saved.token.hasAccessPin,
accessPinPromptTtlMinutes: saved.token.accessPinPromptTtlMinutes,
conversation: updatedConversation
? {
sessionId: updatedConversation.sessionId,
title: updatedConversation.title,
requestBadgeLabel: updatedConversation.requestBadgeLabel ?? null,
chatTypeId: updatedConversation.chatTypeId ?? null,
lastChatTypeId: updatedConversation.lastChatTypeId ?? null,
contextLabel: updatedConversation.contextLabel ?? null,
contextDescription: updatedConversation.contextDescription ?? null,
notifyOffline: updatedConversation.notifyOffline,
}
: null,
};
});
app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/runtime`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const query = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
}).parse(request.query ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) {
return reply.code(403).send({
message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: query.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
return {
ok: true,
item: buildManagedShareRoomRuntimeSnapshot(chatRuntimeService.getSnapshot(), resolvedRoomContext.activeRoom.sessionId),
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/runtime-requests/:requestId/cancel`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (!managedContext.managedResource?.token.permissions?.includes('manage')) {
return reply.code(403).send({
message: '이 공유 링크에는 처리중 세션 관리 권한이 없습니다.',
});
}
if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) {
return reply.code(403).send({
message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
const detail = controller.getJobDetail(params.requestId);
if (!detail.item || detail.item.sessionId !== resolvedRoomContext.activeRoom.sessionId) {
return reply.code(404).send({
message: '이 채팅방에서 취소할 처리중 세션을 찾지 못했습니다.',
});
}
if (detail.availableActions.remove) {
const removed = await controller.removeQueuedJob(params.requestId);
if (!removed) {
return reply.code(409).send({
message: '대기 요청 취소 처리에 실패했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.',
});
}
return {
ok: true,
action: 'removed',
};
}
if (detail.availableActions.cancel) {
const cancelled = await controller.cancelJob(params.requestId);
if (!cancelled) {
return reply.code(409).send({
message: '실행 중 요청 취소 처리에 실패했습니다. 상태를 새로고침한 뒤 다시 시도해 주세요.',
});
}
return {
ok: true,
action: 'cancelled',
};
}
return reply.code(409).send({
message: '이미 종료된 요청이라 지금은 취소할 수 없습니다.',
});
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}`, async (request, reply) => {
const parsedPayload = z.object({
kind: z.enum(['request-bundle', 'inquiry-message', 'prompt']),
sessionId: z.string().trim().min(1).max(120),
requestId: z.string().trim().min(1).max(120),
tokenSettingId: z.string().trim().min(1).max(120),
name: z.string().trim().max(160).optional(),
tokenName: z.string().trim().max(160).optional(),
accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(),
sourceMessageId: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
promptIndex: z.coerce.number().int().min(0).max(99).optional(),
promptSignature: z.string().trim().min(1).max(8000).optional(),
}).parse(request.body ?? {});
const payload = {
...parsedPayload,
name: parsedPayload.name || parsedPayload.tokenName || '',
};
if (!payload.name) {
return reply.code(400).send({
message: '공유 이름을 입력해 주세요.',
});
}
const viewerClientId = getClientIdHeader(request);
const clientId = canViewAllConversations(request) ? null : viewerClientId;
const conversation = await getChatConversation(payload.sessionId, clientId || null);
if (!conversation) {
return reply.code(404).send({
message: '공유할 채팅방을 찾을 수 없습니다.',
});
}
const requestItem = await getChatConversationRequest(payload.sessionId, payload.requestId);
if (!requestItem) {
return reply.code(404).send({
message: '공유할 요청을 찾을 수 없습니다.',
});
}
const tokenSetting = await getTokenSettingById(payload.tokenSettingId);
if (!tokenSetting || !tokenSetting.enabled) {
return reply.code(404).send({
message: '공유에 사용할 토큰 설정을 찾을 수 없습니다.',
});
}
if (!tokenSetting.allowedAppIds.some((item) => item.trim().toLowerCase() === 'chat-live')) {
return reply.code(409).send({
message: '선택한 토큰 설정에는 공유 채팅방 권한이 없습니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유할 채팅 범위를 찾을 수 없습니다.',
});
}
if (payload.kind === 'prompt') {
if (payload.promptIndex == null || !payload.promptSignature) {
return reply.code(400).send({
message: 'prompt 공유에는 prompt 대상 정보가 필요합니다.',
});
}
if (!shareSnapshot.promptTarget) {
return reply.code(404).send({
message: '공유할 prompt 대상을 찾을 수 없습니다.',
});
}
}
const managedResourceTokenId = createManagedChatShareTokenId();
const token = randomUUID().replace(/-/g, '').slice(0, 24);
const sharePath = resolveChatSharePath(token);
const managedShareConversation = await materializeManagedShareConversation({
shareSnapshot,
managedResourceTokenId,
ownerClientId: clientId || null,
shareTitle: payload.name,
});
await upsertSharedResourceToken({
id: managedResourceTokenId,
name: payload.name,
description: [tokenSetting.description, `공유 유형: ${payload.kind}`].filter(Boolean).join('\n'),
tokenSettingId: tokenSetting.id,
tokenSettingSnapshot: {
id: tokenSetting.id,
name: tokenSetting.name,
defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
maxTokensPer30Days: tokenSetting.maxTokensPer30Days,
maxTokensPer7Days: tokenSetting.maxTokensPer7Days,
maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
allowedAppIds: tokenSetting.allowedAppIds,
},
resourceContext: {
kind: payload.kind,
sessionId: managedShareConversation.sessionId,
requestId: managedShareConversation.requestId,
sourceMessageId: payload.sourceMessageId ?? null,
promptIndex: payload.promptIndex ?? null,
promptSignature: payload.promptSignature ?? null,
},
resourceAllowedAppIds: tokenSetting.allowedAppIds,
resourceLabel: payload.name,
resourcePath: sharePath,
resourceType: 'chat-share',
sharePath,
permissions: payload.kind === 'prompt' ? ['view', 'comment', 'upload'] : ['view', 'comment', 'download', 'upload'],
enabled: true,
accessPin: payload.accessPin ?? null,
expiresAt:
tokenSetting.defaultExpiresInMinutes > 0
? new Date(Date.now() + tokenSetting.defaultExpiresInMinutes * 60 * 1000).toISOString()
: null,
usageLimit: 0,
});
return {
ok: true,
token,
sharePath,
name: payload.name,
tokenSetting: {
id: tokenSetting.id,
name: tokenSetting.name,
defaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
maxTokensPer30Days: tokenSetting.maxTokensPer30Days,
maxTokensPer7Days: tokenSetting.maxTokensPer7Days,
maxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
oneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
allowedAppIds: tokenSetting.allowedAppIds,
},
managedResourceTokenId,
hasAccessPin: Boolean(payload.accessPin),
};
});
app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const query = z.object({
sessionId: z.string().trim().min(1).max(120).optional(),
view: z.enum(['full', 'initial']).optional(),
}).parse(request.query ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
const accessPinStatus = await ensureManagedShareAccessPin(request, reply, managedContext.sharePath);
if (!accessPinStatus) {
return;
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: query.sessionId ?? null,
});
const activeRoom = resolvedRoomContext.activeRoom;
if (!activeRoom) {
return reply.code(404).send({
message: '공유 채팅방 목록을 찾을 수 없습니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: activeRoom.sessionId,
requestId: activeRoom.requestId,
detailLevel: query.view === 'initial' ? 'initial' : 'full',
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
if (managedContext.managedResource) {
void recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅 링크를 열었습니다.',
detail: managedContext.managedResource.token.resourceLabel,
}).catch((error) => {
request.log.warn(
{
err: error,
managedResourceTokenId: managedContext.managedResource?.token.id,
},
'Failed to record shared chat view usage',
);
});
}
const effectiveTokenSetting = managedContext.managedResource?.token.tokenSettingSnapshot ?? resolveChatShareTokenSettingSnapshot(tokenPayload);
const effectiveExpiresAt = managedContext.managedResource
? resolveSharedResourceTokenEffectiveExpiresAt(managedContext.managedResource.token, effectiveTokenSetting)
: null;
const blockedReason = resolveShareBlockedReason(shareSnapshot.requests, effectiveTokenSetting);
return {
ok: true,
share: {
kind: tokenPayload.kind,
sessionId: activeRoom.sessionId,
requestId: activeRoom.requestId,
sharePath: resolveChatSharePath(params.token),
createdAt: managedContext.managedResource?.token.createdAt ?? null,
expiresAt: effectiveExpiresAt,
tokenSetting: {
id: effectiveTokenSetting.id,
name: effectiveTokenSetting.name,
defaultExpiresInMinutes: effectiveTokenSetting.defaultExpiresInMinutes,
maxTokensPer30Days: effectiveTokenSetting.maxTokensPer30Days,
maxTokensPer7Days: effectiveTokenSetting.maxTokensPer7Days,
maxTokensPer5Hours: effectiveTokenSetting.maxTokensPer5Hours,
oneTimeTokenLimit: effectiveTokenSetting.oneTimeTokenLimit,
allowedAppIds: managedContext.managedResource?.token.allowedAppIds ?? effectiveTokenSetting.allowedAppIds,
},
managedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
permissions: managedContext.managedResource?.token.permissions ?? [],
hasAccessPin: managedContext.managedResource?.token.hasAccessPin ?? false,
accessPinPromptTtlMinutes: managedContext.managedResource?.token.accessPinPromptTtlMinutes ?? null,
accessPinSessionExpiresAt: accessPinStatus.status === 'ok' ? accessPinStatus.sessionExpiresAt ?? null : null,
canSendMessage: !blockedReason,
blockedReason,
},
conversation: {
sessionId: shareSnapshot.conversation?.sessionId ?? activeRoom.sessionId,
title: shareSnapshot.conversation?.title ?? activeRoom.title ?? '공유 채팅',
requestBadgeLabel: shareSnapshot.conversation?.requestBadgeLabel ?? null,
chatTypeId: shareSnapshot.conversation?.chatTypeId ?? null,
lastChatTypeId: shareSnapshot.conversation?.lastChatTypeId ?? null,
contextLabel: shareSnapshot.conversation?.contextLabel ?? null,
contextDescription: shareSnapshot.conversation?.contextDescription ?? null,
notifyOffline: shareSnapshot.conversation?.notifyOffline === true,
},
rootRequestId: shareSnapshot.rootRequestId,
targetRequest: shareSnapshot.targetRequest,
requests: shareSnapshot.requests,
messages: shareSnapshot.messages,
activityLogs: shareSnapshot.activityLogs,
roomRequestCounts: shareSnapshot.roomRequestCounts,
oldestLoadedMessageId: shareSnapshot.oldestLoadedMessageId,
hasOlderMessages: shareSnapshot.hasOlderMessages,
rooms: resolvedRoomContext.rooms,
activeSessionId: activeRoom.sessionId,
promptTarget: shareSnapshot.promptTarget,
detailLevel: shareSnapshot.detailLevel,
refreshedAt: new Date().toISOString(),
};
});
app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/conversations/:sessionId`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const query = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
beforeMessageId: z.coerce.number().int().positive().optional(),
}).parse(request.query ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: params.sessionId,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const item = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, null);
if (!item) {
return reply.code(404).send({
message: '채팅방을 찾을 수 없습니다.',
});
}
const detailPage = await listChatConversationDetailPage(resolvedRoomContext.activeRoom.sessionId, {
limit: query.limit ?? 40,
beforeMessageId: query.beforeMessageId ?? null,
});
return {
ok: true,
item,
messages: detailPage.messages,
requests: detailPage.requests,
activityLogs: detailPage.activityLogs,
oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
hasOlderMessages: detailPage.hasOlderMessages,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/clear`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
if (tokenPayload.kind === 'prompt') {
return reply.code(400).send({
message: 'prompt 공유 링크에서는 채팅방 기록을 비울 수 없습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions
&& !managedContext.managedResource.token.permissions.includes('comment')
&& !managedContext.managedResource.token.permissions.includes('upload')) {
return reply.code(403).send({
message: '이 공유 링크에는 채팅 기록을 비울 권한이 없습니다.',
});
}
getActiveChatService()?.resetSessionData(resolvedRoomContext.activeRoom.sessionId);
chatRuntimeService.clearSession(resolvedRoomContext.activeRoom.sessionId);
const item = await clearChatConversationData(resolvedRoomContext.activeRoom.sessionId, null);
if (!item) {
return reply.code(404).send({
message: '초기화할 채팅방을 찾을 수 없습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅에서 채팅방 기록을 초기화했습니다.',
detail: resolvedRoomContext.activeRoom.sessionId,
});
}
return {
ok: true,
item,
};
});
app.delete(`${CHAT_SHARE_ROUTE_PREFIX}/:token/rooms/:sessionId`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
if (tokenPayload.kind === 'prompt') {
return reply.code(400).send({
message: 'prompt 공유 링크에서는 채팅방을 삭제할 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (!managedContext.managedResource || managedContext.managedResource.token.resourceType !== 'chat-share') {
return reply.code(404).send({
message: '공유 채팅방 정보를 찾을 수 없습니다.',
});
}
if (!managedContext.managedResource.token.permissions.includes('manage')) {
return reply.code(403).send({
message: '이 공유 링크에는 채팅방을 삭제할 권한이 없습니다.',
});
}
if (!hasManagedShareAllowedApp(managedContext.managedResource, 'chat-room-settings')) {
return reply.code(403).send({
message: '이 공유 링크에는 채팅방 설정 앱 권한이 없습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: params.sessionId,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
if (resolvedRoomContext.rooms.length <= 1) {
return reply.code(400).send({
message: '마지막 채팅방은 삭제할 수 없습니다.',
});
}
getActiveChatService()?.forgetSession(params.sessionId);
chatRuntimeService.clearSession(params.sessionId);
const deleted = await deleteChatConversation(params.sessionId);
const archived = await archiveChatShareTokenRoomMap(managedContext.managedResource.token.id, params.sessionId);
if (!deleted || !archived.archived) {
return reply.code(404).send({
message: '삭제할 채팅방을 찾을 수 없습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-manager',
summary: '공유 채팅방을 삭제했습니다.',
detail: params.sessionId,
});
}
return {
ok: true,
deleted: true,
deletedSessionId: params.sessionId,
nextRoomSessionId: archived.nextDefaultRoom?.sessionId ?? null,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/messages`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
text: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
parentRequestId: z.string().trim().min(1).max(120).optional().nullable(),
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
codexModel: z.string().trim().max(120).optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
if (tokenPayload.kind === 'prompt') {
return reply.code(400).send({
message: 'prompt 공유 링크에서는 일반 메시지를 전송할 수 없습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions
&& !managedContext.managedResource.token.permissions.includes('comment')
&& !managedContext.managedResource.token.permissions.includes('upload')) {
return reply.code(403).send({
message: '이 공유 링크에는 답변 전송 권한이 없습니다.',
});
}
const blockedReason = resolveShareBlockedReason(
shareSnapshot.requests,
resolveChatShareTokenSettingSnapshot(tokenPayload),
);
if (blockedReason) {
return reply.code(409).send({
message: blockedReason,
});
}
const requestedParentRequestId = payload.parentRequestId?.trim() || '';
const resolvedParentRequestId = requestedParentRequestId
? resolveRecoveredShareParentRequestId(
shareSnapshot,
requestedParentRequestId,
[shareSnapshot.targetRequest.requestId],
)
: null;
if (
resolvedParentRequestId
&& !shareSnapshot.requests.some((request) => request.requestId.trim() === resolvedParentRequestId)
) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 답변 참조입니다.',
});
}
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(
resolvedRoomContext.activeRoom.sessionId,
payload.text,
{
mode: payload.mode === 'direct' ? 'direct' : 'queue',
requestOrigin: 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: resolvedParentRequestId,
contextOverride: Object.prototype.hasOwnProperty.call(payload, 'codexModel')
? { codexModel: payload.codexModel ?? null }
: undefined,
clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
},
);
if (!queuedRequestId) {
return reply.code(503).send({
message: '공유 요청을 즉시 전송하지 못했습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅에서 후속 메시지를 전송했습니다.',
detail: resolvedParentRequestId,
});
}
return {
ok: true,
queuedRequestId,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/origin-reply`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
sourceSessionId: z.string().trim().min(1).max(120),
sourceRequestId: z.string().trim().min(1).max(120),
text: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 원 세션 답변 전송 권한이 없습니다.',
});
}
const normalizedSourceSessionId = payload.sourceSessionId.trim();
const normalizedSourceRequestId = payload.sourceRequestId.trim();
const allowedLinkTargets = resolvedRoomContext.rooms
.map((room) => room.linkContext)
.filter((item): item is ChatShareRoomLinkContext => item?.kind === 'linked-session');
const matchedLinkTarget = allowedLinkTargets.find((item) =>
item.sourceSessionId === normalizedSourceSessionId && item.sourceRequestId === normalizedSourceRequestId,
);
if (!matchedLinkTarget) {
return reply.code(403).send({
message: '현재 공유채팅에 연결된 원 세션만 답변 전송할 수 있습니다.',
});
}
const targetRequest = await getChatConversationRequest(normalizedSourceSessionId, normalizedSourceRequestId);
if (!targetRequest) {
return reply.code(404).send({
message: '원 세션 요청을 찾지 못했습니다.',
});
}
const targetConversation = await getChatConversation(normalizedSourceSessionId, null);
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(
normalizedSourceSessionId,
payload.text,
{
mode: payload.mode === 'direct' ? 'direct' : 'queue',
requestOrigin: 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: normalizedSourceRequestId,
clientId: targetRequest.requesterClientId ?? targetConversation?.clientId ?? null,
},
);
if (!queuedRequestId) {
return reply.code(503).send({
message: '원 세션 답변 전송을 시작하지 못했습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유채팅에서 원 세션으로 답변을 전송했습니다.',
detail: `${normalizedSourceSessionId}:${normalizedSourceRequestId}`,
});
}
return {
ok: true,
queuedRequestId,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/attachments`, { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const isBinaryRequest = isOctetStreamRequest(request.headers['content-type']);
const payload = isBinaryRequest
? parseChatAttachmentBinaryHeaders(request.headers, { allowOptionalSessionId: true })
: chatAttachmentShareJsonBodySchema.parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions
&& !managedContext.managedResource.token.permissions.includes('comment')
&& !managedContext.managedResource.token.permissions.includes('upload')) {
return reply.code(403).send({
message: '이 공유 링크에는 첨부 업로드 권한이 없습니다.',
});
}
const blockedReason = resolveShareBlockedReason(
shareSnapshot.requests,
resolveChatShareTokenSettingSnapshot(tokenPayload),
);
if (blockedReason) {
return reply.code(409).send({
message: blockedReason,
});
}
const saved = isBinaryRequest
? await saveChatAttachmentBuffer({
sessionId: resolvedRoomContext.activeRoom.sessionId,
fileName: payload.fileName,
mimeType: payload.mimeType,
buffer: Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0),
})
: await saveChatAttachmentFile({
sessionId: resolvedRoomContext.activeRoom.sessionId,
fileName: payload.fileName,
mimeType: payload.mimeType,
contentBase64: chatAttachmentShareJsonBodySchema.parse(request.body ?? {}).contentBase64,
});
if (!saved.ok) {
return reply.code(saved.statusCode).send({
message: saved.message,
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅에 첨부 파일을 업로드했습니다.',
detail: shareSnapshot.targetRequest.requestId,
});
}
return {
ok: true,
item: saved.item,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/prompt-submit`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
parentRequestId: z.string().trim().min(1).max(120),
promptIndex: z.number().int().min(0).max(99),
promptTitle: z.string().trim().min(1).max(500),
promptSignature: z.string().trim().min(1).max(8000),
sourceMessageId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
selectedValues: z.array(z.string().trim().min(1).max(500)).max(50),
freeText: z.string().max(10000).optional().nullable(),
stepSelections: z.array(
z.object({
stepKey: z.string().trim().min(1).max(120),
stepTitle: z.string().trim().max(500).optional().nullable(),
selectedValues: z.array(z.string().trim().min(1).max(500)).max(50),
freeText: z.string().max(10000),
skipped: z.boolean().optional(),
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
followupText: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
contextRef: chatPromptContextRefSchema,
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 prompt 응답 권한이 없습니다.',
});
}
const blockedReason = resolveShareBlockedReason(
shareSnapshot.requests,
resolveChatShareTokenSettingSnapshot(tokenPayload),
);
if (blockedReason) {
return reply.code(409).send({
message: blockedReason,
});
}
const sourceMessageParentRequestId = shareSnapshot.messages
.find((message: { id: number; clientRequestId?: string | null }) => message.id === payload.sourceMessageId)
?.clientRequestId?.trim() || '';
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[sourceMessageParentRequestId, shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 prompt 입니다.',
});
}
if (
tokenPayload.kind === 'prompt' &&
(normalizedParentRequestId !== tokenPayload.requestId ||
payload.promptIndex !== tokenPayload.promptIndex ||
payload.sourceMessageId !== tokenPayload.sourceMessageId ||
payload.promptSignature !== tokenPayload.promptSignature)
) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 prompt 입니다.',
});
}
const existingPromptRequest = await findExistingActivePromptFollowupRequest(
resolvedRoomContext.activeRoom.sessionId,
normalizedParentRequestId,
payload.followupText,
);
const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(
resolvedRoomContext.activeRoom.sessionId,
payload.followupText,
{
mode: resolvePromptFollowupMode(payload.mode),
requestOrigin: 'prompt',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: normalizedParentRequestId,
promptContextRef: payload.contextRef ?? null,
clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
},
);
if (!queuedRequestId) {
return reply.code(503).send({
message: 'prompt 선택 전송을 시작하지 못했습니다.',
});
}
const persisted = await persistChatConversationPromptSelection(
resolvedRoomContext.activeRoom.sessionId,
normalizedParentRequestId,
payload,
);
if (!persisted) {
return reply.code(404).send({
message: '즉시 전송은 접수됐지만 prompt 선택 상태를 저장하지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, persisted.request);
getActiveChatService()?.broadcastMessageUpdate(resolvedRoomContext.activeRoom.sessionId, persisted.message);
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 prompt 응답을 제출했습니다.',
detail: normalizedParentRequestId,
});
}
return {
ok: true,
item: persisted.request,
message: persisted.message,
queuedRequestId,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/manual-completion`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
parentRequestId: z.string().trim().min(1).max(120),
type: z.enum(['prompt', 'verification']),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 완료 처리 권한이 없습니다.',
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
let item = null;
try {
item = await markChatConversationRequestManualCompletion(
resolvedRoomContext.activeRoom.sessionId,
normalizedParentRequestId,
payload.type,
);
} catch (error) {
if (error instanceof ChatConversationManualCompletionBlockedError) {
return reply.code(409).send({
message: error.message,
});
}
throw error;
}
if (!item) {
return reply.code(404).send({
message: '완료 처리할 요청을 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, item);
return {
ok: true,
item,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-cancel`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
parentRequestId: z.string().trim().min(1).max(120),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 요청 취소 처리 권한이 없습니다.',
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
if (
!targetRequest
|| targetRequest.hasResponse
|| targetRequest.status !== 'failed'
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
) {
return reply.code(409).send({
message: '지금은 이 요청을 취소 처리할 수 없습니다.',
});
}
const result = await cancelUnansweredChatConversationRequest(
resolvedRoomContext.activeRoom.sessionId,
normalizedParentRequestId,
'사용자 요청으로 중단된 요청을 취소 처리했습니다.',
);
if (!result.cancelled || !result.item) {
if (result.reason === 'answered') {
return reply.code(409).send({
message: '이미 답변이 연결된 요청은 취소 처리할 수 없습니다.',
});
}
if (result.reason === 'active') {
return reply.code(409).send({
message: '현재 처리 중인 요청은 여기서 취소 처리할 수 없습니다.',
});
}
if (result.reason === 'already_terminal') {
return reply.code(409).send({
message: '이미 취소 처리된 요청입니다.',
});
}
return reply.code(404).send({
message: '취소 처리할 요청을 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(resolvedRoomContext.activeRoom.sessionId, result.item);
return {
ok: true,
item: result.item,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-retry`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).optional().nullable(),
parentRequestId: z.string().trim().min(1).max(120),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const resolvedRoomContext = await resolveActiveManagedShareRoom({
managedResource: managedContext.managedResource,
tokenPayload,
requestedSessionId: payload.sessionId ?? null,
});
if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) {
return reply.code(403).send({
message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: resolvedRoomContext.activeRoom.sessionId,
requestId: resolvedRoomContext.activeRoom.requestId,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 요청 재처리 권한이 없습니다.',
});
}
const blockedReason = resolveShareBlockedReason(
shareSnapshot.requests,
resolveChatShareTokenSettingSnapshot(tokenPayload),
);
if (blockedReason) {
return reply.code(409).send({
message: blockedReason,
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
if (
!targetRequest
|| targetRequest.hasResponse
|| targetRequest.status !== 'failed'
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
) {
return reply.code(409).send({
message: '지금은 이 요청을 재처리할 수 없습니다.',
});
}
const normalizedUserText = targetRequest.userText.trim();
if (!normalizedUserText) {
return reply.code(409).send({
message: '재처리할 요청 본문이 없습니다.',
});
}
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(
resolvedRoomContext.activeRoom.sessionId,
normalizedUserText,
{
requestId: normalizedParentRequestId,
mode: 'direct',
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null,
clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
},
);
if (!queuedRequestId) {
return reply.code(503).send({
message: '중단된 요청 재처리를 시작하지 못했습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅에서 중단 요청 재처리를 시작했습니다.',
detail: normalizedParentRequestId,
});
}
return {
ok: true,
queuedRequestId,
};
});
app.get('/api/chat/conversations', async (request) => {
const query = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
}).parse(request.query ?? {});
const viewerClientId = getClientIdHeader(request);
const clientId = canViewAllConversations(request) ? null : viewerClientId;
const items = await listChatConversations(clientId, query.limit ?? 200, viewerClientId || null);
return {
ok: true,
items,
};
});
app.get('/api/chat/source-changes', async (request) => {
const query = z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
}).parse(request.query ?? {});
const viewerClientId = getClientIdHeader(request);
const clientId = canViewAllConversations(request) ? null : viewerClientId;
const items = await listChatSourceChangeSnapshots(clientId, query.limit ?? 300);
return {
ok: true,
items,
};
});
app.get('/api/chat/runtime', async () => {
return {
ok: true,
item: chatRuntimeService.getSnapshot(),
};
});
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
const isBinaryRequest = isOctetStreamRequest(request.headers['content-type']);
const saved = isBinaryRequest
? await (() => {
const binaryPayload = parseChatAttachmentBinaryHeaders(request.headers);
return saveChatAttachmentBuffer({
sessionId: binaryPayload.sessionId ?? '',
fileName: binaryPayload.fileName,
mimeType: binaryPayload.mimeType,
buffer: Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0),
});
})()
: await saveChatAttachmentFile(chatAttachmentJsonBodySchema.parse(request.body ?? {}));
if (!saved.ok) {
return reply.code(saved.statusCode).send({
message: saved.message,
});
}
return {
ok: true,
item: saved.item,
};
});
app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
return {
ok: true,
item: controller.getJobDetail(params.requestId),
};
});
app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
const cancelled = await controller.cancelJob(params.requestId);
if (!cancelled) {
return reply.code(404).send({
message: '취소할 실행 중 요청을 찾지 못했습니다.',
});
}
return {
ok: true,
cancelled: true,
};
});
app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
const removed = await controller.removeQueuedJob(params.requestId);
if (!removed) {
return reply.code(404).send({
message: '제거할 대기 요청을 찾지 못했습니다.',
});
}
return {
ok: true,
removed: true,
};
});
app.post('/api/chat/runtime/jobs/:requestId/rollback', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z
.object({
sessionId: z.string().trim().min(1).max(120).optional(),
})
.parse(request.body ?? {});
chatRuntimeService.appendLog(params.requestId, '사용자 요청으로 최근 실행 롤백을 시작합니다.');
try {
const result = await rollbackChatRuntimeRequest({
requestId: params.requestId,
sessionId: payload.sessionId,
});
await upsertChatConversationRequest(result.sessionId, {
requestId: result.requestId,
status: 'cancelled',
statusMessage: '사용자 요청으로 최근 실행 변경을 롤백했습니다.',
});
chatRuntimeService.setArchivedJobTerminalStatus(
params.requestId,
'cancelled',
'최근 실행이 롤백되어 상태를 취소로 변경했습니다.',
);
chatRuntimeService.appendLog(params.requestId, '최근 실행 롤백이 완료되었습니다.');
return {
ok: true,
...result,
};
} catch (error) {
const message = error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.';
chatRuntimeService.appendLog(params.requestId, `최근 실행 롤백 실패: ${message}`);
return reply.code(409).send({
message,
});
}
});
app.post('/api/chat/conversations', async (request) => {
const payload = z.object({
sessionId: z.string().trim().min(1).max(120),
title: z.string().trim().max(200).optional(),
draftText: z.string().max(200000).optional().nullable(),
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
codexModel: z.string().trim().max(120).optional().nullable(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
generalSectionName: z.string().trim().max(120).optional().nullable(),
contextLabel: z.string().trim().max(200).optional(),
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional(),
notifyOffline: z.boolean().optional(),
}).parse(request.body ?? {});
const clientId = getClientIdHeader(request);
const item = await createChatConversation({
sessionId: payload.sessionId,
clientId: clientId || null,
title: payload.title ?? '새 대화',
draftText: payload.draftText ?? '',
requestBadgeLabel: payload.requestBadgeLabel ?? null,
codexModel: payload.codexModel ?? null,
chatTypeId: payload.chatTypeId ?? null,
lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null,
generalSectionName: payload.generalSectionName ?? null,
contextLabel: payload.contextLabel ?? null,
contextDescription: payload.contextDescription ?? null,
notifyOffline: payload.notifyOffline ?? true,
});
return {
ok: true,
item,
};
});
app.get('/api/chat/conversations/:sessionId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const query = z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
beforeMessageId: z.coerce.number().int().positive().optional(),
}).parse(request.query ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const item = await getChatConversation(params.sessionId, clientId || null);
if (!item) {
return reply.code(404).send({
message: '채팅방을 찾을 수 없습니다.',
});
}
const messageLimit = query.limit ?? 10;
const detailPage = await listChatConversationDetailPage(params.sessionId, {
limit: messageLimit,
beforeMessageId: query.beforeMessageId ?? null,
});
return {
ok: true,
item,
messages: detailPage.messages,
requests: detailPage.requests,
activityLogs: detailPage.activityLogs,
oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
hasOlderMessages: detailPage.hasOlderMessages,
};
});
app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const clientId = getClientIdHeader(request);
if (!clientId) {
return reply.code(400).send({
message: '읽음 처리를 위한 clientId가 필요합니다.',
});
}
const result = await markChatConversationResponsesRead(params.sessionId, clientId);
if (!result) {
return reply.code(404).send({
message: '읽음 처리할 채팅방을 찾지 못했습니다.',
});
}
return {
ok: true,
...result,
};
});
app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId);
if (!result.deleted) {
if (result.reason === 'not_found') {
return reply.code(404).send({
message: '삭제할 요청을 찾지 못했습니다.',
});
}
if (result.reason === 'answered') {
return reply.code(409).send({
message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.',
});
}
return reply.code(409).send({
message: '현재 처리 중인 요청은 삭제할 수 없습니다.',
});
}
return {
ok: true,
deleted: true,
sessionId: params.sessionId,
requestId: params.requestId,
};
});
app.post('/api/chat/conversations/:sessionId/requests/:requestId/manual-completion', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z.object({
type: z.enum(['prompt', 'verification']),
}).parse(request.body ?? {});
let item = null;
try {
item = await markChatConversationRequestManualCompletion(
params.sessionId,
params.requestId,
payload.type,
);
} catch (error) {
if (error instanceof ChatConversationManualCompletionBlockedError) {
return reply.code(409).send({
message: error.message,
});
}
throw error;
}
if (!item) {
return reply.code(404).send({
message: '완료 처리할 요청을 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(params.sessionId, item);
return {
ok: true,
item,
};
});
app.post('/api/chat/conversations/:sessionId/requests/:requestId/prompt-selection', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z.object({
parentRequestId: z.string().trim().min(1).max(120),
promptIndex: z.number().int().min(0).max(99),
promptTitle: z.string().trim().min(1).max(500),
promptSignature: z.string().trim().min(1).max(8000),
sourceMessageId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
selectedValues: z.array(z.string().trim().min(1).max(500)).max(50),
freeText: z.string().max(10000).optional().nullable(),
stepSelections: z.array(
z.object({
stepKey: z.string().trim().min(1).max(120),
stepTitle: z.string().trim().max(500).optional().nullable(),
selectedValues: z.array(z.string().trim().min(1).max(500)).max(50),
freeText: z.string().max(10000),
skipped: z.boolean().optional(),
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
}).parse(request.body ?? {});
if (params.requestId !== payload.parentRequestId) {
return reply.code(400).send({
message: 'prompt 선택 저장 대상 요청이 올바르지 않습니다.',
});
}
const persisted = await persistChatConversationPromptSelection(
params.sessionId,
params.requestId,
payload,
);
if (!persisted) {
return reply.code(404).send({
message: 'prompt 선택 상태를 저장할 응답 메시지를 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(params.sessionId, persisted.request);
getActiveChatService()?.broadcastMessageUpdate(params.sessionId, persisted.message);
return {
ok: true,
item: persisted.request,
message: persisted.message,
};
});
app.post('/api/chat/conversations/:sessionId/requests/:requestId/prompt-selection/submit', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z.object({
parentRequestId: z.string().trim().min(1).max(120),
promptIndex: z.number().int().min(0).max(99),
promptTitle: z.string().trim().min(1).max(500),
promptSignature: z.string().trim().min(1).max(8000),
sourceMessageId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
selectedValues: z.array(z.string().trim().min(1).max(500)).max(50),
freeText: z.string().max(10000).optional().nullable(),
stepSelections: z.array(
z.object({
stepKey: z.string().trim().min(1).max(120),
stepTitle: z.string().trim().max(500).optional().nullable(),
selectedValues: z.array(z.string().trim().min(1).max(500)).max(50),
freeText: z.string().max(10000),
skipped: z.boolean().optional(),
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
followupText: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
contextRef: chatPromptContextRefSchema,
}).parse(request.body ?? {});
if (params.requestId !== payload.parentRequestId) {
return reply.code(400).send({
message: 'prompt 선택 저장 대상 요청이 올바르지 않습니다.',
});
}
const existingPromptRequest = await findExistingActivePromptFollowupRequest(
params.sessionId,
params.requestId,
payload.followupText,
);
const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(params.sessionId, payload.followupText, {
mode: resolvePromptFollowupMode(payload.mode),
requestOrigin: 'prompt',
parentRequestId: params.requestId,
promptContextRef: payload.contextRef ?? null,
});
if (!queuedRequestId) {
return reply.code(503).send({
message: 'prompt 선택 후속 Codex 실행을 시작하지 못했습니다.',
});
}
const persisted = await persistChatConversationPromptSelection(
params.sessionId,
params.requestId,
payload,
);
if (!persisted) {
return reply.code(404).send({
message: '후속 Codex 실행은 접수됐지만 prompt 선택 상태를 저장할 응답 메시지를 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(params.sessionId, persisted.request);
getActiveChatService()?.broadcastMessageUpdate(params.sessionId, persisted.message);
return {
ok: true,
item: persisted.request,
message: persisted.message,
queuedRequestId,
};
});
app.patch('/api/chat/conversations/:sessionId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z.object({
title: z.string().trim().max(200).optional().nullable(),
draftText: z.string().max(200000).optional().nullable(),
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
codexModel: z.string().trim().max(120).optional().nullable(),
chatTypeId: z.string().trim().max(120).optional().nullable(),
lastChatTypeId: z.string().trim().max(120).optional().nullable(),
generalSectionName: z.string().trim().max(120).optional().nullable(),
contextLabel: z.string().trim().max(200).optional().nullable(),
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional().nullable(),
notifyOffline: z.boolean().optional(),
}).parse(request.body ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const current = await getChatConversation(params.sessionId, clientId || null);
if (!current) {
return reply.code(404).send({
message: '수정할 채팅방을 찾을 수 없습니다.',
});
}
const item = await updateChatConversationContext(params.sessionId, {
title: Object.prototype.hasOwnProperty.call(payload, 'title') ? payload.title ?? null : undefined,
draftText: Object.prototype.hasOwnProperty.call(payload, 'draftText') ? payload.draftText ?? '' : undefined,
requestBadgeLabel:
Object.prototype.hasOwnProperty.call(payload, 'requestBadgeLabel') ? payload.requestBadgeLabel ?? null : undefined,
codexModel: Object.prototype.hasOwnProperty.call(payload, 'codexModel') ? payload.codexModel ?? null : undefined,
clientId: current.clientId,
chatTypeId: Object.prototype.hasOwnProperty.call(payload, 'chatTypeId') ? payload.chatTypeId ?? null : undefined,
lastChatTypeId:
Object.prototype.hasOwnProperty.call(payload, 'lastChatTypeId') ? payload.lastChatTypeId ?? null : undefined,
generalSectionName:
Object.prototype.hasOwnProperty.call(payload, 'generalSectionName')
? payload.generalSectionName ?? null
: undefined,
contextLabel:
Object.prototype.hasOwnProperty.call(payload, 'contextLabel') ? payload.contextLabel ?? null : undefined,
contextDescription:
Object.prototype.hasOwnProperty.call(payload, 'contextDescription')
? payload.contextDescription ?? null
: undefined,
notifyOffline: Object.prototype.hasOwnProperty.call(payload, 'notifyOffline') ? payload.notifyOffline : undefined,
});
return {
ok: true,
item,
};
});
app.delete('/api/chat/conversations/:sessionId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const current = await getChatConversation(params.sessionId, clientId || null);
if (!current) {
return reply.code(404).send({
message: '삭제할 채팅방을 찾을 수 없습니다.',
});
}
await getActiveChatService()?.forgetSession(params.sessionId);
const deleted = await deleteChatConversation(params.sessionId);
return {
ok: true,
deleted,
sessionId: params.sessionId,
};
});
app.post('/api/chat/conversations/:sessionId/clear', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const current = await getChatConversation(params.sessionId, clientId || null);
if (!current) {
return reply.code(404).send({
message: '초기화할 채팅방을 찾을 수 없습니다.',
});
}
getActiveChatService()?.resetSessionData(params.sessionId);
chatRuntimeService.clearSession(params.sessionId);
const item = await clearChatConversationData(params.sessionId, clientId || null);
if (!item) {
return reply.code(404).send({
message: '채팅방 데이터 초기화 후 다시 불러오지 못했습니다.',
});
}
return {
ok: true,
item,
};
});
}