diff --git a/src/constants/index.ts b/src/constants/index.ts index fd5954e..894ff4b 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -48,12 +48,17 @@ export const KEYBOARD_TYPES = { * * Format: prefix:action:params * Examples: - * - domain_reg:confirm:example.com:15000 - * - server_order:confirm:order_123 + * - domain_reg:example.com:15000 + * - server_order:userId:index + * - server_cancel:userId */ export const CALLBACK_PREFIXES = { - DOMAIN_REGISTER_CONFIRM: 'confirm_domain_register', - DOMAIN_REGISTER_CANCEL: 'cancel_domain_register', + DOMAIN_REGISTER: 'domain_reg', + DOMAIN_CANCEL: 'domain_cancel', + SERVER_ORDER: 'server_order', + SERVER_CANCEL: 'server_cancel', + CONFIRM_DOMAIN_REGISTER: 'confirm_domain_register', + CANCEL_DOMAIN_REGISTER: 'cancel_domain_register', SERVER_ORDER_CONFIRM: 'confirm_server_order', SERVER_ORDER_CANCEL: 'cancel_server_order', DELETE_CONFIRM: 'confirm_delete', diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts index 5058db2..d4fa982 100644 --- a/src/deposit-agent.ts +++ b/src/deposit-agent.ts @@ -14,6 +14,7 @@ import { createLogger } from './utils/logger'; import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; +import { TRANSACTION_STATUS, TRANSACTION_TYPE } from './constants'; import type { ManageDepositArgs, DepositFunctionResult } from './types'; const logger = createLogger('deposit-agent'); @@ -108,8 +109,8 @@ export async function executeDepositFunction( // 1. Insert transaction record const result = await db.prepare( `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description, confirmed_at) - VALUES (?, 'deposit', ?, 'confirmed', ?, ?, '입금 확인', CURRENT_TIMESTAMP)` - ).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run(); + VALUES (?, ?, ?, ?, ?, ?, '입금 확인', CURRENT_TIMESTAMP)` + ).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.CONFIRMED, depositor_name, depositor_name.slice(0, 7)).run(); const txId = result.meta.last_row_id; @@ -178,8 +179,8 @@ export async function executeDepositFunction( // 은행 알림이 없으면 pending 거래 생성 const result = await db.prepare( `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description) - VALUES (?, 'deposit', ?, 'pending', ?, ?, '입금 대기')` - ).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run(); + VALUES (?, ?, ?, ?, ?, ?, '입금 대기')` + ).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.PENDING, depositor_name, depositor_name.slice(0, 7)).run(); return { success: true, @@ -187,7 +188,7 @@ export async function executeDepositFunction( transaction_id: result.meta.last_row_id, amount: amount, depositor_name: depositor_name, - status: 'pending', + status: TRANSACTION_STATUS.PENDING, message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.', account_info: { bank: context.env?.DEPOSIT_BANK_NAME || '하나은행', @@ -253,13 +254,13 @@ export async function executeDepositFunction( if (tx.user_id !== userId && !isAdmin) { return { error: '본인의 거래만 취소할 수 있습니다.' }; } - if (tx.status !== 'pending') { + if (tx.status !== TRANSACTION_STATUS.PENDING) { return { error: '대기 중인 거래만 취소할 수 있습니다.' }; } await db.prepare( - "UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?" - ).bind(transaction_id).run(); + "UPDATE deposit_transactions SET status = ? WHERE id = ?" + ).bind(TRANSACTION_STATUS.CANCELLED, transaction_id).run(); return { success: true, @@ -278,9 +279,9 @@ export async function executeDepositFunction( `SELECT dt.id, dt.amount, dt.depositor_name, dt.created_at, u.telegram_id, u.username FROM deposit_transactions dt JOIN users u ON dt.user_id = u.id - WHERE dt.status = 'pending' AND dt.type = 'deposit' + WHERE dt.status = ? AND dt.type = ? ORDER BY dt.created_at ASC` - ).all<{ + ).bind(TRANSACTION_STATUS.PENDING, TRANSACTION_TYPE.DEPOSIT).all<{ id: number; amount: number; depositor_name: string; @@ -321,7 +322,7 @@ export async function executeDepositFunction( if (!tx) { return { error: '거래를 찾을 수 없습니다.' }; } - if (tx.status !== 'pending') { + if (tx.status !== TRANSACTION_STATUS.PENDING) { return { error: '대기 중인 거래만 확인할 수 있습니다.' }; } @@ -330,8 +331,8 @@ export async function executeDepositFunction( await executeWithOptimisticLock(db, async (attempt) => { // 1. Update transaction status const txUpdate = await db.prepare( - "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" - ).bind(transaction_id).run(); + "UPDATE deposit_transactions SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = ?" + ).bind(TRANSACTION_STATUS.CONFIRMED, transaction_id, TRANSACTION_STATUS.PENDING).run(); if (!txUpdate.success || txUpdate.meta.changes === 0) { throw new Error('Transaction already processed or not found'); @@ -406,13 +407,13 @@ export async function executeDepositFunction( if (!tx) { return { error: '거래를 찾을 수 없습니다.' }; } - if (tx.status !== 'pending') { + if (tx.status !== TRANSACTION_STATUS.PENDING) { return { error: '대기 중인 거래만 거절할 수 있습니다.' }; } await db.prepare( - "UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?" - ).bind(transaction_id).run(); + "UPDATE deposit_transactions SET status = ? WHERE id = ?" + ).bind(TRANSACTION_STATUS.REJECTED, transaction_id).run(); return { success: true, diff --git a/src/index.ts b/src/index.ts index 007df53..95f2bf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,49 @@ import { handleProvisionQueue, handleProvisionDLQ } from './server-provision'; import { timingSafeEqual } from './security'; import { createLogger } from './utils/logger'; import { notifyAdmin } from './services/notification'; +import { validateEnv } from './utils/env-validation'; import { Hono } from 'hono'; const logger = createLogger('worker'); +// Environment validation flag (checked once per instance) +let envValidated = false; + // Hono app with Env type const app = new Hono<{ Bindings: Env }>(); +// Environment validation middleware (runs once per worker instance) +app.use('*', async (c, next) => { + if (!envValidated) { + // Cast to Record for validation + const result = validateEnv(c.env as unknown as Record); + if (!result.success) { + logger.error('Environment validation failed on startup', new Error('Invalid configuration'), { + errors: result.errors, + }); + return c.json({ + error: 'Configuration error', + message: 'The worker is not properly configured. Please check environment variables.', + details: result.errors, + }, 500); + } + + // Log warnings but continue + if (result.warnings.length > 0) { + logger.warn('Environment configuration warnings', { warnings: result.warnings }); + } + + logger.info('Environment validation passed', { + environment: c.env.ENVIRONMENT || 'production', + warnings: result.warnings.length, + }); + + envValidated = true; + } + + return await next(); +}); + // Health check (public - minimal info only) app.get('/health', () => handleHealthCheck()); diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts index b4e6b1d..6b71301 100644 --- a/src/routes/handlers/callback-handler.ts +++ b/src/routes/handlers/callback-handler.ts @@ -2,6 +2,7 @@ import { answerCallbackQuery, editMessageText, sendMessage } from '../../telegra import { UserService } from '../../services/user-service'; import { executeDomainRegister } from '../../domain-register'; import { createLogger } from '../../utils/logger'; +import { CALLBACK_PREFIXES } from '../../constants'; import type { Env, TelegramUpdate } from '../../types'; const logger = createLogger('callback-handler'); @@ -43,7 +44,7 @@ export async function handleCallbackQuery( } // 도메인 등록 처리 - if (data.startsWith('domain_reg:')) { + if (data.startsWith(`${CALLBACK_PREFIXES.DOMAIN_REGISTER}:`)) { const parts = data.split(':'); if (parts.length !== 3) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); @@ -145,7 +146,7 @@ ${result.error} } // 도메인 등록 취소 - if (data === 'domain_cancel') { + if (data === CALLBACK_PREFIXES.DOMAIN_CANCEL) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); await editMessageText( env.BOT_TOKEN, @@ -157,7 +158,7 @@ ${result.error} } // 서버 주문 확인 - if (data.startsWith('server_order:')) { + if (data.startsWith(`${CALLBACK_PREFIXES.SERVER_ORDER}:`)) { const parts = data.split(':'); if (parts.length !== 3) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); @@ -325,7 +326,7 @@ ${result.error} } // 서버 주문 취소 - if (data.startsWith('server_cancel:')) { + if (data.startsWith(`${CALLBACK_PREFIXES.SERVER_CANCEL}:`)) { const parts = data.split(':'); if (parts.length !== 2) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); diff --git a/src/security.ts b/src/security.ts index 3fecae1..2c71f18 100644 --- a/src/security.ts +++ b/src/security.ts @@ -138,12 +138,8 @@ export async function validateWebhookRequest( } // Rate Limiting (Cloudflare KV 기반) -// NOTE: Future migration to KVCache abstraction layer (kv-cache.ts) planned -// Current implementation kept for backward compatibility -interface RateLimitData { - count: number; - resetAt: number; -} +// Migrated to use KVCache abstraction layer (kv-cache.ts) +import { createRateLimitCache, checkRateLimitWithCache } from './services/kv-cache'; export async function checkRateLimit( kv: KVNamespace, @@ -151,61 +147,12 @@ export async function checkRateLimit( maxRequests: number = 30, windowMs: number = 60000 ): Promise { - const key = `ratelimit:${userId}`; - const now = Date.now(); - const logger = createLogger('rate-limit'); + // Convert windowMs (milliseconds) to windowSeconds (seconds) + const windowSeconds = Math.ceil(windowMs / 1000); - try { - // KV에서 기존 데이터 조회 - const dataStr = await kv.get(key); - const data: RateLimitData | null = dataStr ? JSON.parse(dataStr) : null; + // Create KVCache instance with 'rate:' prefix + const cache = createRateLimitCache(kv); - // 윈도우 만료 또는 첫 요청 - if (!data || now > data.resetAt) { - const newData: RateLimitData = { - count: 1, - resetAt: now + windowMs, - }; - await kv.put(key, JSON.stringify(newData), { - expirationTtl: Math.ceil(windowMs / 1000), // 초 단위 - }); - logger.info('Rate limit 윈도우 시작', { - userId, - resetAt: new Date(newData.resetAt).toISOString(), - maxRequests, - }); - return true; - } - - // Rate limit 초과 - if (data.count >= maxRequests) { - const resetInSeconds = Math.ceil((data.resetAt - now) / 1000); - logger.warn('Rate limit 초과', { - userId, - currentCount: data.count, - maxRequests, - resetInSeconds, - resetAt: new Date(data.resetAt).toISOString(), - }); - return false; - } - - // 카운트 증가 - const updatedData: RateLimitData = { - count: data.count + 1, - resetAt: data.resetAt, - }; - const remainingTtl = Math.ceil((data.resetAt - now) / 1000); - await kv.put(key, JSON.stringify(updatedData), { - expirationTtl: Math.max(remainingTtl, 1), // 최소 1초 - }); - return true; - } catch (error) { - // KV 오류 시 요청 허용 (fail-open) - // Rate limiting은 abuse 방지 목적이므로 가용성 우선 - // 심각한 abuse는 Cloudflare WAF/Firewall Rules로 별도 대응 - logger.warn('KV 오류 - 요청 허용 (fail-open)', { userId, error: (error as Error).message }); - - return true; - } + // Delegate to unified KV cache implementation + return checkRateLimitWithCache(cache, userId, maxRequests, windowSeconds); } diff --git a/src/tools/domain-tool.ts b/src/tools/domain-tool.ts index 25076cd..73784bd 100644 --- a/src/tools/domain-tool.ts +++ b/src/tools/domain-tool.ts @@ -9,6 +9,7 @@ import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger, maskUserId } from '../utils/logger'; import { getOpenAIUrl } from '../utils/api-urls'; import { ERROR_MESSAGES } from '../constants/messages'; +import { MESSAGE_MARKERS } from '../constants'; const logger = createLogger('domain-tool'); @@ -796,7 +797,7 @@ async function executeDomainAction( domain: domain, price: price }); - return `__KEYBOARD__${keyboardData}__END__ + return `${MESSAGE_MARKERS.KEYBOARD}${keyboardData}${MESSAGE_MARKERS.KEYBOARD_END} 📋 도메인 등록 확인 • 도메인: ${domain} diff --git a/src/utils/optimistic-lock.ts b/src/utils/optimistic-lock.ts index bb36bbb..ce6548f 100644 --- a/src/utils/optimistic-lock.ts +++ b/src/utils/optimistic-lock.ts @@ -32,6 +32,13 @@ import { createLogger } from './logger'; const logger = createLogger('optimistic-lock'); +/** + * Type guard for Error with captureStackTrace method + */ +interface ErrorWithCapture { + captureStackTrace?: (target: object, constructor?: Function) => void; +} + /** * Custom error for optimistic lock failures */ @@ -40,10 +47,9 @@ export class OptimisticLockError extends Error { super(message); this.name = 'OptimisticLockError'; // Maintain proper stack trace for debugging (Node.js only, not available in Workers) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (typeof (Error as any).captureStackTrace === 'function') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (Error as any).captureStackTrace(this, OptimisticLockError); + const ErrorConstructor = Error as unknown as ErrorWithCapture; + if (typeof ErrorConstructor.captureStackTrace === 'function') { + ErrorConstructor.captureStackTrace(this, OptimisticLockError); } } }