/** * Server Provisioning Handler * POST /api/provision - Create a new server * GET /api/provision/orders - Get user's orders * GET /api/provision/orders/:id - Get specific order * DELETE /api/provision/orders/:id - Delete/terminate server * GET /api/provision/balance - Get user's balance */ import type { Env, ProvisionRequest } from '../types'; import { ProvisioningService } from '../services/provisioning-service'; import { createErrorResponse, createSuccessResponse } from '../utils/http'; import { LIMITS } from '../config'; /** * Validate provision request body * Note: user_id in API is the Telegram ID */ function validateProvisionRequest(body: unknown): { valid: true; data: ProvisionRequest; } | { valid: false; error: string; } { if (!body || typeof body !== 'object') { return { valid: false, error: 'Request body must be a JSON object' }; } const data = body as Record; // Required fields - user_id is the Telegram ID if (!data.user_id || typeof data.user_id !== 'string') { return { valid: false, error: 'user_id is required and must be a string (Telegram ID)' }; } if (!data.pricing_id || typeof data.pricing_id !== 'number' || data.pricing_id <= 0) { return { valid: false, error: 'pricing_id is required and must be a positive number' }; } // Optional fields validation if (data.label !== undefined && typeof data.label !== 'string') { return { valid: false, error: 'label must be a string' }; } if (data.label && (data.label as string).length > 64) { return { valid: false, error: 'label must be 64 characters or less' }; } // Basic type validation for image - actual validation against DB done in ProvisioningService if (data.image !== undefined && typeof data.image !== 'string') { return { valid: false, error: 'image must be a string' }; } if (data.image && (data.image as string).length > 50) { return { valid: false, error: 'image key must be 50 characters or less' }; } if (data.dry_run !== undefined && typeof data.dry_run !== 'boolean') { return { valid: false, error: 'dry_run must be a boolean' }; } return { valid: true, data: { telegram_id: data.user_id as string, // API uses user_id, internally it's telegram_id pricing_id: data.pricing_id as number, label: data.label as string | undefined, image: data.image as string | undefined, dry_run: data.dry_run as boolean | undefined, }, }; } /** * POST /api/provision * Create a new server */ export async function handleProvision( request: Request, env: Env, corsHeaders: Record ): Promise { try { // Check content length const contentLength = request.headers.get('content-length'); if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) { return createErrorResponse('Request body too large', 413, undefined, corsHeaders); } // Parse request body let body: unknown; try { body = await request.json(); } catch { return createErrorResponse('Invalid JSON in request body', 400, undefined, corsHeaders); } // Validate request const validation = validateProvisionRequest(body); if (!validation.valid) { return createErrorResponse(validation.error, 400, 'VALIDATION_ERROR', corsHeaders); } // Check API keys (skip for dry_run) if (!validation.data.dry_run && !env.LINODE_API_KEY && !env.VULTR_API_KEY) { return createErrorResponse( 'No VPS provider API keys configured', 503, 'SERVICE_UNAVAILABLE', corsHeaders ); } // Create provisioning service with both DBs const provisioningService = new ProvisioningService( env, // Full env for exchange rate env.DB, // cloud-instances-db env.USER_DB, // telegram-conversations env.LINODE_API_KEY, env.VULTR_API_KEY, env.LINODE_API_URL, env.VULTR_API_URL ); // Provision server const result = await provisioningService.provisionServer(validation.data); if (!result.success) { // Map error codes to HTTP status codes const statusCode = getStatusCodeForError(result.error!.code); return createErrorResponse( result.error!.message, statusCode, result.error!.code, corsHeaders ); } // Return success response (hide root password in response) const sanitizedOrder = { ...result.order!, root_password: '*** Use GET /api/provision/orders/:id to retrieve once ***', }; // Include dry_run_info if present const response: Record = { message: validation.data.dry_run ? 'Dry run successful. Validation passed.' : 'Server provisioned successfully', order: sanitizedOrder, }; // Add dry_run details from service if ('dry_run_info' in result) { response.dry_run_info = (result as Record).dry_run_info; } return createSuccessResponse(response, validation.data.dry_run ? 200 : 201, corsHeaders); } catch (error) { console.error('[handleProvision] Error:', error); return createErrorResponse( 'Internal server error during provisioning', 500, 'INTERNAL_ERROR', corsHeaders ); } } /** * GET /api/provision/orders * Get user's orders */ export async function handleGetOrders( request: Request, env: Env, corsHeaders: Record ): Promise { try { const url = new URL(request.url); const userId = url.searchParams.get('user_id'); // This is telegram_id if (!userId) { return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); } const provisioningService = new ProvisioningService( env, env.DB, env.USER_DB, env.LINODE_API_KEY, env.VULTR_API_KEY, env.LINODE_API_URL, env.VULTR_API_URL ); const orders = await provisioningService.getUserOrders(userId); // Sanitize root passwords const sanitizedOrders = orders.map((order) => ({ ...order, root_password: order.root_password ? '***REDACTED***' : null, })); return createSuccessResponse({ orders: sanitizedOrders }, 200, corsHeaders); } catch (error) { console.error('[handleGetOrders] Error:', error); return createErrorResponse('Internal server error', 500, undefined, corsHeaders); } } /** * GET /api/provision/orders/:id * Get specific order with root password (one-time view concept) */ export async function handleGetOrder( request: Request, env: Env, orderId: string, corsHeaders: Record ): Promise { try { const url = new URL(request.url); const userId = url.searchParams.get('user_id'); // This is telegram_id if (!userId) { return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); } const provisioningService = new ProvisioningService( env, env.DB, env.USER_DB, env.LINODE_API_KEY, env.VULTR_API_KEY, env.LINODE_API_URL, env.VULTR_API_URL ); const order = await provisioningService.getOrder(orderId); if (!order) { return createErrorResponse('Order not found', 404, 'NOT_FOUND', corsHeaders); } // Get user to verify ownership const balance = await provisioningService.getUserBalance(userId); if (!balance || order.user_id !== balance.user_id) { return createErrorResponse('Unauthorized', 403, 'UNAUTHORIZED', corsHeaders); } // Include root password for order owner return createSuccessResponse({ order }, 200, corsHeaders); } catch (error) { console.error('[handleGetOrder] Error:', error); return createErrorResponse('Internal server error', 500, undefined, corsHeaders); } } /** * DELETE /api/provision/orders/:id * Delete/terminate a server */ export async function handleDeleteOrder( request: Request, env: Env, orderId: string, corsHeaders: Record ): Promise { try { const url = new URL(request.url); const userId = url.searchParams.get('user_id'); // This is telegram_id if (!userId) { return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); } const provisioningService = new ProvisioningService( env, env.DB, env.USER_DB, env.LINODE_API_KEY, env.VULTR_API_KEY, env.LINODE_API_URL, env.VULTR_API_URL ); // Verify user exists first (same pattern as handleGetOrder) const balance = await provisioningService.getUserBalance(userId); if (!balance) { return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders); } const result = await provisioningService.deleteServer(orderId, userId); if (!result.success) { // Map specific errors to appropriate status codes const statusCode = getDeleteErrorStatusCode(result.error!); return createErrorResponse(result.error!, statusCode, 'DELETE_FAILED', corsHeaders); } return createSuccessResponse({ message: 'Server terminated successfully' }, 200, corsHeaders); } catch (error) { console.error('[handleDeleteOrder] Error:', error); return createErrorResponse('Internal server error', 500, undefined, corsHeaders); } } /** * Map delete error messages to HTTP status codes */ function getDeleteErrorStatusCode(error: string): number { if (error === 'Order not found') return 404; if (error === 'Unauthorized') return 403; if (error === 'User not found') return 404; return 400; // Default for validation/business logic errors } /** * GET /api/provision/balance * Get user's balance (in KRW) */ export async function handleGetBalance( request: Request, env: Env, corsHeaders: Record ): Promise { try { const url = new URL(request.url); const userId = url.searchParams.get('user_id'); // This is telegram_id if (!userId) { return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); } const provisioningService = new ProvisioningService( env, env.DB, env.USER_DB, env.LINODE_API_KEY, env.VULTR_API_KEY, env.LINODE_API_URL, env.VULTR_API_URL ); const balance = await provisioningService.getUserBalance(userId); if (!balance) { return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders); } return createSuccessResponse( { balance_krw: balance.balance_krw, currency: 'KRW', }, 200, corsHeaders ); } catch (error) { console.error('[handleGetBalance] Error:', error); return createErrorResponse('Internal server error', 500, undefined, corsHeaders); } } /** * GET /api/provision/images * Get available OS images */ export async function handleGetOsImages( env: Env, corsHeaders: Record ): Promise { try { const provisioningService = new ProvisioningService( env, env.DB, env.USER_DB, env.LINODE_API_KEY, env.VULTR_API_KEY, env.LINODE_API_URL, env.VULTR_API_URL ); const images = await provisioningService.getOsImages(); // Return simplified image list for API consumers const response = images.map((img) => ({ key: img.key, name: img.name, family: img.family, is_default: img.is_default === 1, })); return createSuccessResponse({ images: response }, 200, corsHeaders); } catch (error) { console.error('[handleGetOsImages] Error:', error); return createErrorResponse('Internal server error', 500, undefined, corsHeaders); } } /** * Map error codes to HTTP status codes */ function getStatusCodeForError(code: string): number { switch (code) { case 'USER_NOT_FOUND': case 'PRICING_NOT_FOUND': return 404; case 'INSUFFICIENT_BALANCE': return 402; // Payment Required case 'PROVIDER_NOT_CONFIGURED': return 503; case 'VALIDATION_ERROR': return 400; case 'UNAUTHORIZED': return 403; default: return 500; } }