import { useEffect, useRef, useState } from 'react'; import { appendClientIdHeader } from './clientIdentity'; import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess'; const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER; export type TokenSettingRecord = { id: string; name: string; description: string; defaultExpiresInMinutes: number; maxExpiresInMinutes: number; maxTokensPer30Days: number; maxTokensPer7Days: number; maxTokensPer5Hours: number; oneTimeTokenLimit: number; allowedAppIds: string[]; enabled: boolean; updatedAt: string; }; export type TokenSettingActivityRecord = { id: number; settingId: string; activityType: 'created' | 'updated' | 'deleted'; actorLabel: string | null; summary: string; detail: string | null; clientIp: string | null; externalIp: string | null; forwardedFor: string | null; realIp: string | null; host: string | null; origin: string | null; referer: string | null; userAgent: string | null; clientId: string | null; createdAt: string; }; export type TokenSettingInput = { originalId?: string; id?: string; name: string; description?: string; defaultExpiresInMinutes?: number; maxExpiresInMinutes?: number; maxTokensPer30Days?: number; maxTokensPer7Days?: number; maxTokensPer5Hours?: number; oneTimeTokenLimit?: number; allowedAppIds?: string[]; enabled?: boolean; }; const TOKEN_SETTINGS_API_PATH = '/token-settings'; const TOKEN_SETTINGS_SYNC_EVENT = 'work-app:token-settings-changed'; const TOKEN_SETTINGS_REQUEST_TIMEOUT_MS = 8000; type TokenSettingsRequestOptions = { shareToken?: string | null; path?: string | null; }; class TokenSettingApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = 'TokenSettingApiError'; this.status = status; } } function normalizeText(value: string | null | undefined) { return value?.trim() ?? ''; } function normalizeSettingId(value: string | null | undefined) { return normalizeText(value) .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9._-]/g, ''); } function normalizePositiveInteger(value: number | undefined, fallback: number, min: number, max: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(max, Math.max(min, Math.round(value))); } function normalizeAllowedAppIds(value: string[] | null | undefined) { return Array.from( new Set( (value ?? []) .map((item) => normalizeText(item)) .filter(Boolean), ), ).sort((left, right) => left.localeCompare(right, 'en')); } function normalizeTokenSetting(record: Partial): TokenSettingRecord | null { const id = normalizeSettingId(record.id); const name = normalizeText(record.name); if (!id || !name) { return null; } const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT); const resolvedMaxExpiresInMinutes = normalizePositiveInteger( record.maxExpiresInMinutes, defaultExpiresInMinutes <= 0 ? 0 : 10_080, 0, UNBOUNDED_NUMERIC_LIMIT, ); const maxExpiresInMinutes = defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0 ? 0 : Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes); const legacyMaxTotalTokens = 'maxTotalTokens' in record ? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT) : 100_000; return { id, name, description: normalizeText(record.description), defaultExpiresInMinutes, maxExpiresInMinutes, maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT), maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT), maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT), oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT), allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds), enabled: record.enabled !== false, updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), }; } export function sanitizeTokenSettings(items: Partial[] | null | undefined) { const byId = new Map(); for (const item of items ?? []) { const normalized = normalizeTokenSetting(item); if (!normalized) { continue; } const current = byId.get(normalized.id); if (!current || Date.parse(current.updatedAt) <= Date.parse(normalized.updatedAt)) { byId.set(normalized.id, normalized); } } return Array.from(byId.values()).sort((left, right) => { const nameCompare = left.name.localeCompare(right.name, 'ko-KR'); if (nameCompare !== 0) { return nameCompare; } return left.id.localeCompare(right.id, 'en'); }); } function emitTokenSettingsChange() { if (typeof window === 'undefined') { return; } window.dispatchEvent(new Event(TOKEN_SETTINGS_SYNC_EVENT)); } function resolveApiBaseUrl() { if (import.meta.env.VITE_WORK_SERVER_URL) { return import.meta.env.VITE_WORK_SERVER_URL; } return '/api'; } function resolveFallbackBaseUrl() { if (typeof window === 'undefined') { return null; } const hostname = window.location.hostname; if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) { return null; } const fallbackUrl = new URL(window.location.origin); fallbackUrl.port = '3100'; fallbackUrl.pathname = '/api'; fallbackUrl.search = ''; fallbackUrl.hash = ''; return fallbackUrl.toString().replace(/\/+$/, ''); } const API_BASE_URL = resolveApiBaseUrl(); const FALLBACK_BASE_URL = resolveFallbackBaseUrl(); async function requestOnce(baseUrl: string, init?: RequestInit, options?: TokenSettingsRequestOptions): Promise { const headers = appendClientIdHeader(init?.headers); const token = getRegisteredAccessToken(); const hasBody = init?.body !== undefined && init?.body !== null; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), TOKEN_SETTINGS_REQUEST_TIMEOUT_MS); const shareToken = options?.shareToken?.trim() ?? ''; if (!shareToken && !isAllowedRegistrationToken(token)) { throw new TokenSettingApiError('권한 토큰 등록 후에만 토큰 설정을 관리할 수 있습니다.', 403); } if (hasBody && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (token && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', token); } if (shareToken && !headers.has('X-Chat-Share-Token')) { headers.set('X-Chat-Share-Token', shareToken); } try { const response = await fetch(`${baseUrl}${options?.path?.trim() || TOKEN_SETTINGS_API_PATH}`, { ...init, headers, signal: controller.signal, cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined), }); if (!response.ok) { const text = await response.text(); try { const payload = JSON.parse(text) as { message?: string }; throw new TokenSettingApiError(payload.message || '토큰 설정 요청에 실패했습니다.', response.status); } catch { throw new TokenSettingApiError(text || '토큰 설정 요청에 실패했습니다.', response.status); } } const text = await response.text(); if (!text.trim()) { throw new TokenSettingApiError('토큰 설정 응답이 비어 있습니다.', 502); } try { return JSON.parse(text) as T; } catch { throw new TokenSettingApiError('토큰 설정 응답을 해석하지 못했습니다.', 502); } } finally { window.clearTimeout(timeoutId); } } async function requestTokenSettings(init?: RequestInit, options?: TokenSettingsRequestOptions) { try { return await requestOnce(API_BASE_URL, init, options); } catch (error) { const shouldRetryWithFallback = FALLBACK_BASE_URL && FALLBACK_BASE_URL !== API_BASE_URL && (error instanceof TokenSettingApiError ? error.status === 404 || error.status === 408 || error.status === 502 : error instanceof Error && /404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message)); if (!shouldRetryWithFallback) { throw error; } return requestOnce(FALLBACK_BASE_URL, init, options); } } function normalizeActivityRecord(record: Partial): TokenSettingActivityRecord { return { id: Number(record.id ?? 0), settingId: normalizeSettingId(record.settingId), activityType: record.activityType === 'created' || record.activityType === 'deleted' ? record.activityType : 'updated', actorLabel: normalizeText(record.actorLabel) || null, summary: normalizeText(record.summary), detail: normalizeText(record.detail) || null, clientIp: normalizeText(record.clientIp) || null, externalIp: normalizeText(record.externalIp) || null, forwardedFor: normalizeText(record.forwardedFor) || null, realIp: normalizeText(record.realIp) || null, host: normalizeText(record.host) || null, origin: normalizeText(record.origin) || null, referer: normalizeText(record.referer) || null, userAgent: normalizeText(record.userAgent) || null, clientId: normalizeText(record.clientId) || null, createdAt: normalizeText(record.createdAt) || new Date().toISOString(), }; } async function loadTokenSettingsFromServer(options?: TokenSettingsRequestOptions) { const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial[] | null }>({ method: 'GET', }, options); return sanitizeTokenSettings(response.tokenSettings); } async function saveTokenSettingsToServer(items: TokenSettingRecord[], options?: TokenSettingsRequestOptions) { const resolved = sanitizeTokenSettings(items); const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial[] }>({ method: 'PUT', body: JSON.stringify({ tokenSettings: resolved }), }, options); return sanitizeTokenSettings(response.tokenSettings); } export async function fetchTokenSettingActivities(settingId: string, options?: TokenSettingsRequestOptions) { const normalizedSettingId = normalizeSettingId(settingId); if (!normalizedSettingId) { return []; } const response = await requestTokenSettings<{ ok: boolean; activities: Partial[] | null }>( { method: 'GET' }, { shareToken: options?.shareToken, path: `${TOKEN_SETTINGS_API_PATH}/${encodeURIComponent(normalizedSettingId)}/activities` }, ); return Array.isArray(response.activities) ? response.activities.map((item) => normalizeActivityRecord(item)) : []; } export function upsertTokenSetting(items: TokenSettingRecord[], input: TokenSettingInput) { const nextItem = normalizeTokenSetting({ ...input, id: input.id, }); if (!nextItem) { return sanitizeTokenSettings(items); } const originalId = normalizeSettingId(input.originalId); const nextItems = items.filter((item) => item.id !== nextItem.id && item.id !== originalId); nextItems.push(nextItem); return sanitizeTokenSettings(nextItems); } export function deleteTokenSetting(items: TokenSettingRecord[], tokenSettingId: string) { return sanitizeTokenSettings(items.filter((item) => item.id !== tokenSettingId)); } export function useTokenSettingRegistry(enabled = true, options?: TokenSettingsRequestOptions) { const [tokenSettings, setTokenSettingsState] = useState([]); const [isLoading, setIsLoading] = useState(enabled); const [errorMessage, setErrorMessage] = useState(''); const loadVersionRef = useRef(0); const shareToken = options?.shareToken?.trim() ?? ''; useEffect(() => { if (!enabled) { setTokenSettingsState([]); setIsLoading(false); setErrorMessage(''); return undefined; } let mounted = true; const load = async () => { const loadVersion = ++loadVersionRef.current; setIsLoading(true); setErrorMessage(''); try { const nextItems = await loadTokenSettingsFromServer({ shareToken }); if (!mounted || loadVersion !== loadVersionRef.current) { return; } setTokenSettingsState(nextItems); } catch (error) { if (!mounted || loadVersion !== loadVersionRef.current) { return; } setErrorMessage(error instanceof Error ? error.message : '토큰 설정을 불러오지 못했습니다.'); } finally { if (mounted && loadVersion === loadVersionRef.current) { setIsLoading(false); } } }; void load(); const handleSync = () => { void load(); }; window.addEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync); return () => { mounted = false; window.removeEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync); }; }, [enabled, shareToken]); const setTokenSettings = async (items: TokenSettingRecord[]) => { const savedItems = await saveTokenSettingsToServer(items, { shareToken }); setTokenSettingsState(savedItems); emitTokenSettingsChange(); return savedItems; }; return { tokenSettings, setTokenSettings, isLoading, errorMessage, }; }