Files
ai-code-app/etc/servers/work-server/src/routes/shared-resource-token.ts

338 lines
11 KiB
TypeScript

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<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
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<string, string | string[] | undefined> },
) {
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,
};
});
}