Files
cloud-orchestrator/src/handlers/provision.ts
kappa d41f1ee841 fix: pass API URL params to ProvisioningService in all handlers
- 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>
2026-01-28 11:38:54 +09:00

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;
}
}