import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { deleteSharedResourceTokens, deleteSharedResourceToken, getSharedResourceTokenDetail, getSharedResourceTokenDetailBySharePath, listSharedResourceTokens, recordSharedResourceTokenUsage, restoreSharedResourceToken, revokeSharedResourceToken, revokeSharedResourceTokens, sharedResourceTokenSchema, upsertSharedResourceToken, } from '../services/shared-resource-token-service.js'; function getRequestAccessToken(request: { headers: Record }) { const tokenHeader = request.headers['x-access-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); } function getRequestChatShareToken(request: { headers: Record }) { const tokenHeader = request.headers['x-chat-share-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); } function resolveChatSharePath(token: string) { return `/chat/share/${encodeURIComponent(token)}`; } type SharedResourceTokenAccessContext = | { scope: 'full' } | { scope: 'shared'; tokenId: string }; async function resolveSharedResourceTokenAccessContext( request: { headers: Record }, ) { if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) { return { scope: 'full' } satisfies SharedResourceTokenAccessContext; } const shareToken = getRequestChatShareToken(request); if (!shareToken) { return null; } const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken)); if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) { return null; } const allowedAppIds = managedResource.token.allowedAppIds ?? []; const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean)); if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) { return null; } return { scope: 'shared', tokenId: managedResource.token.id, } satisfies SharedResourceTokenAccessContext; } function sendAccessDenied( reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } }, ) { reply.code(403).send({ message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.', }); } function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) { return accessContext.scope === 'full' || accessContext.tokenId === tokenId; } export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { app.get('/api/shared-resource-tokens', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const sharedTokenDetail = accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null; const items = accessContext.scope === 'full' ? await listSharedResourceTokens() : sharedTokenDetail?.token ? [sharedTokenDetail.token] : []; return { ok: true, items, }; }); app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId); if (!isAllowedSharedTokenTarget(accessContext, tokenId)) { return reply.code(403).send({ message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.', }); } const item = await getSharedResourceTokenDetail(tokenId); if (!item) { return reply.code(404).send({ message: '공유 리소스 토큰을 찾을 수 없습니다.', }); } return { ok: true, ...item, }; }); app.put('/api/shared-resource-tokens', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } try { const payload = sharedResourceTokenSchema.parse(request.body ?? {}); if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.', }); } const saved = await upsertSharedResourceToken(payload); return { ok: true, ...saved, }; } catch (error) { return reply.code(409).send({ message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.', }); } }); app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const payload = z .object({ tokenIds: z.array(z.string().trim().min(1)).min(1).max(500), reason: z.string().trim().max(500).optional().nullable(), }) .parse(request.body ?? {}); if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.', }); } const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason); return { ok: true, ...result, }; }); app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId); if (!isAllowedSharedTokenTarget(accessContext, tokenId)) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.', }); } const payload = z .object({ reason: z.string().trim().max(500).optional().nullable(), }) .parse(request.body ?? {}); const saved = await revokeSharedResourceToken(tokenId, payload.reason); if (!saved) { return reply.code(404).send({ message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.', }); } return { ok: true, ...saved, }; }); app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId); if (!isAllowedSharedTokenTarget(accessContext, tokenId)) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.', }); } const saved = await restoreSharedResourceToken(tokenId); if (!saved) { return reply.code(404).send({ message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.', }); } return { ok: true, ...saved, }; }); app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId); if (!isAllowedSharedTokenTarget(accessContext, tokenId)) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.', }); } const payload = z .object({ actorLabel: z.string().trim().max(120).optional().nullable(), summary: z.string().trim().max(400).optional().nullable(), detail: z.string().trim().max(2000).optional().nullable(), usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(), }) .parse(request.body ?? {}); const saved = await recordSharedResourceTokenUsage(tokenId, payload); if (!saved) { return reply.code(404).send({ message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.', }); } return { ok: true, ...saved, }; }); app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const payload = z .object({ tokenIds: z.array(z.string().trim().min(1)).min(1).max(500), }) .parse(request.body ?? {}); if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.', }); } const result = await deleteSharedResourceTokens(payload.tokenIds); return { ok: true, ...result, }; }); app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => { const accessContext = await resolveSharedResourceTokenAccessContext(request); if (!accessContext) { sendAccessDenied(reply); return; } const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId); if (!isAllowedSharedTokenTarget(accessContext, tokenId)) { return reply.code(403).send({ message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.', }); } const deleted = await deleteSharedResourceToken(tokenId); if (!deleted) { return reply.code(404).send({ message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.', }); } return { ok: true, deleted: true, tokenId, }; }); }