diff --git a/src/routes/api.ts b/src/routes/api.ts index bb96d18..2f17e53 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,6 +9,7 @@ import { import { handleCommand } from '../commands'; import { openaiCircuitBreaker } from '../openai-service'; import { createLogger } from '../utils/logger'; +import { toError } from '../utils/error'; const logger = createLogger('api'); @@ -32,6 +33,31 @@ const ContactFormBodySchema = z.object({ name: z.string().optional(), }); +/** + * API Key 인증 검증 + * @returns 인증 실패 시 Response, 성공 시 null + */ +function requireApiKey(request: Request, env: Env): Response | null { + const apiSecret = env.DEPOSIT_API_SECRET; + const authHeader = request.headers.get('X-API-Key'); + if (!apiSecret || authHeader !== apiSecret) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + return null; +} + +/** + * CORS 헤더 생성 + */ +function getCorsHeaders(env: Env): Record { + const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; + return { + 'Access-Control-Allow-Origin': allowedOrigin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; +} + // 사용자 조회/생성 async function getOrCreateUser( db: D1Database, @@ -72,12 +98,9 @@ async function getOrCreateUser( */ async function handleDepositBalance(request: Request, env: Env, url: URL): Promise { try { - const apiSecret = env.DEPOSIT_API_SECRET; - const authHeader = request.headers.get('X-API-Key'); - - if (!apiSecret || authHeader !== apiSecret) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } + // API Key 인증 + const authError = requireApiKey(request, env); + if (authError) return authError; const telegramId = url.searchParams.get('telegram_id'); if (!telegramId) { @@ -103,7 +126,7 @@ async function handleDepositBalance(request: Request, env: Env, url: URL): Promi balance: deposit?.balance || 0, }); } catch (error) { - logger.error('Deposit balance error', error as Error); + logger.error('Deposit balance error', toError(error)); return Response.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -117,14 +140,18 @@ async function handleDepositBalance(request: Request, env: Env, url: URL): Promi */ async function handleDepositDeduct(request: Request, env: Env): Promise { try { - const apiSecret = env.DEPOSIT_API_SECRET; - const authHeader = request.headers.get('X-API-Key'); + // API Key 인증 + const authError = requireApiKey(request, env); + if (authError) return authError; - if (!apiSecret || authHeader !== apiSecret) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); } - const jsonData = await request.json(); const parseResult = DepositDeductBodySchema.safeParse(jsonData); if (!parseResult.success) { @@ -203,7 +230,7 @@ async function handleDepositDeduct(request: Request, env: Env): Promise { try { - const jsonData = await request.json(); + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); + } + const parseResult = TestApiBodySchema.safeParse(jsonData); if (!parseResult.success) { @@ -309,7 +343,7 @@ async function handleTestApi(request: Request, env: Env): Promise { user_id: telegramUserId, }); } catch (error) { - logger.error('Test API error', error as Error); + logger.error('Test API error', toError(error)); return Response.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -322,13 +356,9 @@ async function handleTestApi(request: Request, env: Env): Promise { * @returns JSON response with success status */ async function handleContactForm(request: Request, env: Env): Promise { - // CORS: hosting.anvil.it.com만 허용 + // CORS 헤더 생성 + const corsHeaders = getCorsHeaders(env); const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; - const corsHeaders = { - 'Access-Control-Allow-Origin': allowedOrigin, - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }; // Origin 헤더 검증 (curl 우회 방지) const origin = request.headers.get('Origin'); @@ -342,7 +372,17 @@ async function handleContactForm(request: Request, env: Env): Promise } try { - const jsonData = await request.json(); + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json( + { error: 'Invalid JSON format' }, + { status: 400, headers: corsHeaders } + ); + } + const parseResult = ContactFormBodySchema.safeParse(jsonData); if (!parseResult.success) { @@ -387,7 +427,7 @@ async function handleContactForm(request: Request, env: Env): Promise { headers: corsHeaders } ); } catch (error) { - logger.error('Contact form error', error as Error); + logger.error('Contact form error', toError(error)); return Response.json( { error: '문의 전송 중 오류가 발생했습니다.' }, { status: 500, headers: corsHeaders } @@ -402,13 +442,8 @@ async function handleContactForm(request: Request, env: Env): Promise * @returns Response with CORS headers */ async function handleContactPreflight(env: Env): Promise { - const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; return new Response(null, { - headers: { - 'Access-Control-Allow-Origin': allowedOrigin, - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, + headers: getCorsHeaders(env), }); } @@ -468,7 +503,7 @@ async function handleMetrics(request: Request, env: Env): Promise { return Response.json(metrics); } catch (error) { - logger.error('Metrics API error', error as Error); + logger.error('Metrics API error', toError(error)); return Response.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..c2b3a36 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,52 @@ +/** + * Error handling utilities for type-safe error conversion + * + * @module error + * @example + * ```typescript + * import { toError } from './utils/error'; + * + * try { + * // ... + * } catch (error) { + * logger.error('Operation failed', toError(error)); + * } + * ``` + */ + +/** + * unknown 타입을 Error로 안전하게 변환 + * + * catch 블록에서 unknown 타입의 error를 Error 객체로 변환합니다. + * TypeScript strict mode에서 `error as Error` 타입 단언을 피하기 위해 사용합니다. + * + * @param error - catch 블록에서 받은 unknown 타입 에러 + * @returns Error 객체 + * + * @example + * ```typescript + * try { + * await riskyOperation(); + * } catch (error) { + * // ✅ Type-safe + * logger.error('Operation failed', toError(error)); + * + * // ❌ Type assertion (avoid) + * // logger.error('Operation failed', error as Error); + * } + * ``` + */ +export function toError(error: unknown): Error { + // Already an Error object - return as-is + if (error instanceof Error) { + return error; + } + + // String error - wrap in Error object + if (typeof error === 'string') { + return new Error(error); + } + + // Other types - convert to string and wrap + return new Error(String(error)); +}