chore: test deploy snapshot

This commit is contained in:
2026-05-28 12:45:36 +09:00
parent 983887dc05
commit 82c46f4be4
21 changed files with 4163 additions and 449 deletions

View File

@@ -55,6 +55,7 @@ import {
getChatShareTokenRoomMap,
resolveChatShareTokenRoomSessionIds,
upsertChatShareTokenRoomMap,
type ChatShareRoomLinkContext,
type ChatShareTokenRoomMapItem,
} from '../services/chat-share-room-map-service.js';
import { chatRuntimeService } from '../services/chat-runtime-service.js';
@@ -62,8 +63,8 @@ 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_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
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_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
const CHAT_SHARE_ROUTE_PREFIX = '/api/chat/shares';
@@ -248,6 +249,7 @@ type ChatShareResolvedRoom = {
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
linkContext: ChatShareRoomLinkContext | null;
createdAt: string | null;
updatedAt: string | null;
};
@@ -265,6 +267,7 @@ function mapResolvedShareRoomItem(room: ChatShareTokenRoomMapItem): ChatShareRes
contextLabel: room.contextLabel,
contextDescription: room.contextDescription,
notifyOffline: room.notifyOffline,
linkContext: room.linkContext,
createdAt: room.createdAt,
updatedAt: room.conversationUpdatedAt ?? room.updatedAt,
};
@@ -292,6 +295,7 @@ async function resolveManagedShareRooms(args: {
contextLabel: null,
contextDescription: null,
notifyOffline: false,
linkContext: null,
createdAt: null,
updatedAt: null,
} satisfies ChatShareResolvedRoom,
@@ -319,6 +323,7 @@ async function resolveManagedShareRooms(args: {
contextLabel: null,
contextDescription: null,
notifyOffline: false,
linkContext: null,
createdAt: null,
updatedAt: null,
} satisfies ChatShareResolvedRoom,
@@ -370,6 +375,31 @@ function createManagedChatShareMessageIds() {
};
}
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) {
@@ -700,6 +730,21 @@ async function saveChatAttachmentFile(args: {
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 {
@@ -713,7 +758,7 @@ async function saveChatAttachmentFile(args: {
return {
ok: false as const,
statusCode: 413,
message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.',
message: '첨부 파일은 300MB 이하만 업로드할 수 있습니다.',
};
}
@@ -746,6 +791,62 @@ async function saveChatAttachmentFile(args: {
};
}
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();
@@ -1119,22 +1220,32 @@ async function buildChatShareSnapshot(
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 [requests, messages] = await Promise.all([
listChatConversationRequests(normalizedSessionId, 1000),
listChatConversationMessages(normalizedSessionId, { limit: 1000 }),
]);
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 })
: 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) ?? null;
const targetRequestFromStore = requestMap.get(targetRequestId) ?? await getChatConversationRequest(normalizedSessionId, targetRequestId);
const placeholderTargetRequest = targetRequestFromStore ? null : buildManagedSharePlaceholderRequest(tokenPayload, conversation);
const targetRequest = targetRequestFromStore ?? placeholderTargetRequest;
@@ -1143,13 +1254,13 @@ async function buildChatShareSnapshot(
}
const childRequestIdsByParentRequestId = buildChildRequestIdsByParentRequestId(requests);
const isManagedShareRoomSession =
tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
const isManagedShareRoomPlaceholder = isManagedShareRoomSession && !targetRequestFromStore;
const rootRequestId = isManagedShareRoomPlaceholder
? targetRequestId
: resolveShareRootRequestId(targetRequestId, requestMap, tokenPayload.kind);
const scopeRequestIds = isManagedShareRoomSession
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);
@@ -1158,9 +1269,11 @@ async function buildChatShareSnapshot(
const linkedRequestId = message.clientRequestId?.trim() || '';
return linkedRequestId ? scopeRequestIdSet.has(linkedRequestId) : false;
});
const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
const activityLogs = useInitialManagedShareRoomView
? []
: await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
const roomRequestCounts = buildRoomRequestCounts(requests, messages);
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
if (tokenPayload.kind === 'prompt' && !promptTarget) {
return null;
@@ -1176,6 +1289,7 @@ async function buildChatShareSnapshot(
activityLogs,
roomRequestCounts,
promptTarget,
detailLevel,
} satisfies {
conversation: Awaited<ReturnType<typeof getChatConversation>>;
rootRequestId: string;
@@ -1187,7 +1301,7 @@ async function buildChatShareSnapshot(
roomRequestCounts: {
processingCount: number;
unansweredCount: number;
};
} | undefined;
promptTarget:
| {
sourceMessageId: number;
@@ -1195,6 +1309,7 @@ async function buildChatShareSnapshot(
prompt: Extract<ListedChatConversationMessage['parts'][number], { type: 'prompt' }>;
}
| null;
detailLevel: 'full' | 'initial';
};
}
@@ -1509,6 +1624,10 @@ function hasManagedShareAllowedApp(
}
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);
@@ -1756,6 +1875,11 @@ export async function registerChatRoutes(app: FastifyInstance) {
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);
@@ -1852,6 +1976,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
rootRequestId: requestId,
isDefault: false,
createdByClientId: clientId || null,
linkContext: normalizeShareRoomLinkContext(payload),
});
const room = await getChatShareTokenRoomMap(currentToken.id, sessionId);
@@ -1872,6 +1997,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
contextLabel: payload.chatTypeLabel,
contextDescription: null,
notifyOffline: true,
linkContext: normalizeShareRoomLinkContext(payload),
createdAt,
updatedAt: createdAt,
},
@@ -2322,6 +2448,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}).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);
@@ -2361,6 +2488,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const shareSnapshot = await buildChatShareSnapshot(tokenPayload, {
sessionId: activeRoom.sessionId,
requestId: activeRoom.requestId,
detailLevel: query.view === 'initial' ? 'initial' : 'full',
});
if (!shareSnapshot) {
@@ -2433,6 +2561,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
rooms: resolvedRoomContext.rooms,
activeSessionId: activeRoom.sessionId,
promptTarget: shareSnapshot.promptTarget,
detailLevel: shareSnapshot.detailLevel,
refreshedAt: new Date().toISOString(),
};
});
@@ -2750,15 +2879,16 @@ export async function registerChatRoutes(app: FastifyInstance) {
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/attachments`, { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
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(),
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),
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);
@@ -2781,6 +2911,109 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
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,
@@ -2823,12 +3056,19 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const saved = await saveChatAttachmentFile({
sessionId: resolvedRoomContext.activeRoom.sessionId,
fileName: payload.fileName,
mimeType: payload.mimeType,
contentBase64: payload.contentBase64,
});
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({
@@ -3437,13 +3677,18 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
const payload = 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),
}).parse(request.body ?? {});
const saved = await saveChatAttachmentFile(payload);
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({