- Add env.LINODE_API_URL and env.VULTR_API_URL to all ProvisioningService constructor calls in provision.ts - Fixes delete and other operations using wrong API endpoint (defaulting to api.linode.com instead of configured emulator URL) - Affected handlers: handleGetOrders, handleGetOrder, handleDeleteOrder, handleGetBalance, handleGetOsImages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
|
|
// 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<string, string>
|
|
): Promise<Response> {
|
|
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<string, unknown> = {
|
|
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<string, unknown>).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<string, string>
|
|
): Promise<Response> {
|
|
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<string, string>
|
|
): Promise<Response> {
|
|
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<string, string>
|
|
): Promise<Response> {
|
|
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<string, string>
|
|
): Promise<Response> {
|
|
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<string, string>
|
|
): Promise<Response> {
|
|
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;
|
|
}
|
|
}
|