refactor: comprehensive code review fixes and security hardening

Security:
- Add CSP headers for HTML reports (style-src 'unsafe-inline')
- Restrict origin validation to specific .kappa-d8e.workers.dev domain
- Add base64 size limit (100KB) for report data parameter
- Implement rejection sampling for unbiased password generation
- Add SQL LIKE pattern escaping for tech specs query
- Add security warning for plaintext password storage (TODO: encrypt)

Performance:
- Add Telegram API timeout (10s) with AbortController
- Fix rate limiter sorting by resetTime for proper cleanup
- Use centralized TIMEOUTS config for VPS provider APIs

Features:
- Add admin SSH key support for server recovery access
  - ADMIN_SSH_PUBLIC_KEY for Linode (public key string)
  - ADMIN_SSH_KEY_ID_VULTR for Vultr (pre-registered key ID)
- Add origin validation middleware
- Add idempotency key migration

Code Quality:
- Return 404 status when no servers found
- Consolidate error logging to single JSON.stringify call
- Import TECH_CATEGORY_WEIGHTS from config.ts
- Add escapeLikePattern utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 11:36:08 +09:00
parent d41f1ee841
commit 5319bf3e4c
27 changed files with 965 additions and 530 deletions

View File

@@ -0,0 +1,17 @@
-- Migration: Add idempotency_key column to server_orders table
-- Purpose: Prevent duplicate order creation on Queue retry
-- Date: 2026-01-28
-- Note: This migration should be run on telegram-conversations database (USER_DB)
-- SQLite doesn't allow adding UNIQUE column directly, so we add column + UNIQUE INDEX
-- Step 1: Add idempotency_key column (without UNIQUE constraint)
ALTER TABLE server_orders ADD COLUMN idempotency_key TEXT;
-- Step 2: Create UNIQUE index (this enforces uniqueness for non-NULL values)
CREATE UNIQUE INDEX IF NOT EXISTS idx_server_orders_idempotency_unique
ON server_orders(idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- Verification query (run after migration):
-- SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='server_orders' AND name LIKE '%idempotency%';

10
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"hono": "^4.11.7",
"openai": "^6.16.0"
},
"devDependencies": {
@@ -1825,6 +1826,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hono": {
"version": "4.11.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",

View File

@@ -20,6 +20,7 @@
"wrangler": "^4.60.0"
},
"dependencies": {
"hono": "^4.11.7",
"openai": "^6.16.0"
}
}

View File

@@ -17,6 +17,32 @@ export const LIMITS = {
MAX_TECH_STACK: 20,
MAX_USE_CASE_LENGTH: 500,
MAX_REGION_PREFERENCE: 10,
MAX_IN_MEMORY_RATE_LIMIT_ENTRIES: 10000,
} as const;
/**
* Timeout configurations (in milliseconds)
*/
export const TIMEOUTS = {
AI_REQUEST_MS: 30000, // 30 seconds
SERVER_PROVISIONING_MS: 120000, // 2 minutes
EXCHANGE_RATE_API_MS: 5000, // 5 seconds
VPS_PROVIDER_API_MS: 30000, // 30 seconds
} as const;
/**
* Tech category weights for vCPU calculation
* Different categories have different bottleneck characteristics
*/
export const TECH_CATEGORY_WEIGHTS: Record<string, number> = {
'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources
'runtime': 1.0, // nodejs, php, python: actual computation
'database': 1.0, // mysql, postgresql, mongodb: major bottleneck
'cache': 0.5, // redis, memcached: supporting role
'search': 0.8, // elasticsearch: CPU-intensive but not always primary
'container': 0.3, // docker: orchestration overhead
'messaging': 0.5, // rabbitmq, kafka: I/O bound
'default': 0.7, // unknown categories
} as const;
export const USE_CASE_CONFIGS: UseCaseConfig[] = [

View File

@@ -2,19 +2,17 @@
* Health check endpoint handler
*/
import { jsonResponse } from '../utils';
import type { Context } from 'hono';
import type { Env } from '../types';
/**
* Health check endpoint
*/
export function handleHealth(corsHeaders: Record<string, string>): Response {
return jsonResponse(
{
status: 'ok',
timestamp: new Date().toISOString(),
service: 'server-recommend',
},
200,
corsHeaders
);
export function handleHealth(c: Context<{ Bindings: Env }>) {
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'server-recommend',
request_id: c.get('requestId'),
});
}

View File

@@ -5,11 +5,12 @@
* GET /api/provision/orders/:id - Get specific order
* DELETE /api/provision/orders/:id - Delete/terminate server
* GET /api/provision/balance - Get user's balance
* GET /api/provision/images - Get available OS images
*/
import type { Context } from 'hono';
import type { Env, ProvisionRequest } from '../types';
import { ProvisioningService } from '../services/provisioning-service';
import { createErrorResponse, createSuccessResponse } from '../utils/http';
import { LIMITS } from '../config';
/**
@@ -72,68 +73,63 @@ function validateProvisionRequest(body: unknown): {
};
}
/**
* Create ProvisioningService instance
*/
function createProvisioningService(env: Env) {
return new ProvisioningService(
env,
env.DB,
env.USER_DB,
env.LINODE_API_KEY,
env.VULTR_API_KEY,
env.LINODE_API_URL,
env.VULTR_API_URL
);
}
/**
* POST /api/provision
* Create a new server
*/
export async function handleProvision(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
export async function handleProvision(c: Context<{ Bindings: Env }>) {
try {
// Check content length
const contentLength = request.headers.get('content-length');
const contentLength = c.req.header('content-length');
if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) {
return createErrorResponse('Request body too large', 413, undefined, corsHeaders);
return c.json({ error: 'Request body too large', request_id: c.get('requestId') }, 413);
}
// Parse request body
let body: unknown;
try {
body = await request.json();
body = await c.req.json();
} catch {
return createErrorResponse('Invalid JSON in request body', 400, undefined, corsHeaders);
return c.json({ error: 'Invalid JSON in request body', request_id: c.get('requestId') }, 400);
}
// Validate request
const validation = validateProvisionRequest(body);
if (!validation.valid) {
return createErrorResponse(validation.error, 400, 'VALIDATION_ERROR', corsHeaders);
return c.json({ error: validation.error, code: 'VALIDATION_ERROR', request_id: c.get('requestId') }, 400);
}
// 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
if (!validation.data.dry_run && !c.env.LINODE_API_KEY && !c.env.VULTR_API_KEY) {
return c.json(
{ error: 'No VPS provider API keys configured', code: 'SERVICE_UNAVAILABLE', request_id: c.get('requestId') },
503
);
}
// 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 provisioningService = createProvisioningService(c.env);
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 c.json(
{ error: result.error!.message, code: result.error!.code, request_id: c.get('requestId') },
statusCode
);
}
@@ -143,8 +139,8 @@ export async function handleProvision(
root_password: '*** Use GET /api/provision/orders/:id to retrieve once ***',
};
// Include dry_run_info if present
const response: Record<string, unknown> = {
success: true,
message: validation.data.dry_run
? 'Dry run successful. Validation passed.'
: 'Server provisioned successfully',
@@ -156,14 +152,12 @@ export async function handleProvision(
response.dry_run_info = (result as Record<string, unknown>).dry_run_info;
}
return createSuccessResponse(response, validation.data.dry_run ? 200 : 201, corsHeaders);
return c.json(response, validation.data.dry_run ? 200 : 201);
} catch (error) {
console.error('[handleProvision] Error:', error);
return createErrorResponse(
'Internal server error during provisioning',
500,
'INTERNAL_ERROR',
corsHeaders
console.error('[handleProvision] Error:', error, 'request_id:', c.get('requestId'));
return c.json(
{ error: 'Internal server error during provisioning', code: 'INTERNAL_ERROR', request_id: c.get('requestId') },
500
);
}
}
@@ -172,29 +166,11 @@ export async function handleProvision(
* GET /api/provision/orders
* Get user's orders
*/
export async function handleGetOrders(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
export async function handleGetOrders(c: Context<{ Bindings: Env }>) {
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 userId = c.get('userId'); // Set by validateUserId middleware
const provisioningService = createProvisioningService(c.env);
const orders = await provisioningService.getUserOrders(userId);
// Sanitize root passwords
@@ -203,10 +179,10 @@ export async function handleGetOrders(
root_password: order.root_password ? '***REDACTED***' : null,
}));
return createSuccessResponse({ orders: sanitizedOrders }, 200, corsHeaders);
return c.json({ orders: sanitizedOrders });
} catch (error) {
console.error('[handleGetOrders] Error:', error);
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
console.error('[handleGetOrders] Error:', error, 'request_id:', c.get('requestId'));
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
}
}
@@ -214,47 +190,29 @@ export async function handleGetOrders(
* 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> {
export async function handleGetOrder(c: Context<{ Bindings: Env }>) {
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 orderId = c.req.param('id');
const userId = c.get('userId'); // Set by validateUserId middleware
const provisioningService = createProvisioningService(c.env);
const order = await provisioningService.getOrder(orderId);
if (!order) {
return createErrorResponse('Order not found', 404, 'NOT_FOUND', corsHeaders);
return c.json({ error: 'Order not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404);
}
// 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);
return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED', request_id: c.get('requestId') }, 403);
}
// Include root password for order owner
return createSuccessResponse({ order }, 200, corsHeaders);
return c.json({ order });
} catch (error) {
console.error('[handleGetOrder] Error:', error);
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
console.error('[handleGetOrder] Error:', error, 'request_id:', c.get('requestId'));
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
}
}
@@ -262,55 +220,37 @@ export async function handleGetOrder(
* 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> {
export async function handleDeleteOrder(c: Context<{ Bindings: Env }>) {
try {
const url = new URL(request.url);
const userId = url.searchParams.get('user_id'); // This is telegram_id
const orderId = c.req.param('id');
const userId = c.get('userId'); // Set by validateUserId middleware
if (!userId) {
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
}
const provisioningService = createProvisioningService(c.env);
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)
// Verify user exists first
const balance = await provisioningService.getUserBalance(userId);
if (!balance) {
return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders);
return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404);
}
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 c.json({ error: result.error!, code: 'DELETE_FAILED', request_id: c.get('requestId') }, statusCode);
}
return createSuccessResponse({ message: 'Server terminated successfully' }, 200, corsHeaders);
return c.json({ message: 'Server terminated successfully' });
} catch (error) {
console.error('[handleDeleteOrder] Error:', error);
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
console.error('[handleDeleteOrder] Error:', error, 'request_id:', c.get('requestId'));
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
}
}
/**
* Map delete error messages to HTTP status codes
*/
function getDeleteErrorStatusCode(error: string): number {
function getDeleteErrorStatusCode(error: string): 400 | 403 | 404 {
if (error === 'Order not found') return 404;
if (error === 'Unauthorized') return 403;
if (error === 'User not found') return 404;
@@ -321,46 +261,24 @@ function getDeleteErrorStatusCode(error: string): number {
* GET /api/provision/balance
* Get user's balance (in KRW)
*/
export async function handleGetBalance(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
export async function handleGetBalance(c: Context<{ Bindings: Env }>) {
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 userId = c.get('userId'); // Set by validateUserId middleware
const provisioningService = createProvisioningService(c.env);
const balance = await provisioningService.getUserBalance(userId);
if (!balance) {
return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders);
return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404);
}
return createSuccessResponse(
{
balance_krw: balance.balance_krw,
currency: 'KRW',
},
200,
corsHeaders
);
return c.json({
balance_krw: balance.balance_krw,
currency: 'KRW',
});
} catch (error) {
console.error('[handleGetBalance] Error:', error);
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
console.error('[handleGetBalance] Error:', error, 'request_id:', c.get('requestId'));
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
}
}
@@ -368,21 +286,9 @@ export async function handleGetBalance(
* GET /api/provision/images
* Get available OS images
*/
export async function handleGetOsImages(
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
export async function handleGetOsImages(c: Context<{ Bindings: Env }>) {
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 provisioningService = createProvisioningService(c.env);
const images = await provisioningService.getOsImages();
// Return simplified image list for API consumers
@@ -393,17 +299,17 @@ export async function handleGetOsImages(
is_default: img.is_default === 1,
}));
return createSuccessResponse({ images: response }, 200, corsHeaders);
return c.json({ images: response });
} catch (error) {
console.error('[handleGetOsImages] Error:', error);
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
console.error('[handleGetOsImages] Error:', error, 'request_id:', c.get('requestId'));
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
}
}
/**
* Map error codes to HTTP status codes
*/
function getStatusCodeForError(code: string): number {
function getStatusCodeForError(code: string): 400 | 402 | 403 | 404 | 500 | 503 {
switch (code) {
case 'USER_NOT_FOUND':
case 'PRICING_NOT_FOUND':

View File

@@ -25,16 +25,23 @@ export async function handleProvisionQueue(
);
for (const message of batch.messages) {
const { order_id, user_id, pricing_id } = message.body;
const logContext = { order_id, user_id, pricing_id, message_id: message.id };
try {
console.log(`[Queue] Processing message for order ${message.body.order_id}`);
console.log('[Queue] Processing message:', JSON.stringify(logContext));
await provisioningService.processQueueMessage(message.body);
// Acknowledge successful processing
message.ack();
console.log(`[Queue] Order ${message.body.order_id} processed successfully`);
console.log('[Queue] Message processed successfully:', JSON.stringify(logContext));
} catch (error) {
console.error(`[Queue] Error processing order ${message.body.order_id}:`, error);
console.error('[Queue] Error processing message:', JSON.stringify({
...logContext,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}));
// Retry the message (will go to DLQ after max_retries)
message.retry();

View File

@@ -2,6 +2,7 @@
* POST /api/recommend - AI-powered server recommendation handler
*/
import type { Context } from 'hono';
import type {
Env,
RecommendRequest,
@@ -14,9 +15,8 @@ import type {
BenchmarkReference,
AvailableRegion
} from '../types';
import { LIMITS } from '../config';
import { LIMITS, TECH_CATEGORY_WEIGHTS } from '../config';
import {
jsonResponse,
validateRecommendRequest,
generateCacheKey,
estimateBandwidth,
@@ -32,33 +32,27 @@ import {
generateRuleBasedRecommendations,
} from '../services/ai-service';
export async function handleRecommend(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
const requestId = crypto.randomUUID();
export async function handleRecommend(c: Context<{ Bindings: Env }>) {
const requestId = c.get('requestId');
try {
// Check request body size to prevent large payload attacks
const contentLength = request.headers.get('Content-Length');
const contentLength = c.req.header('Content-Length');
if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) {
return jsonResponse(
{ error: 'Request body too large', max_size: '10KB' },
413,
corsHeaders
return c.json(
{ error: 'Request body too large', max_size: '10KB', request_id: requestId },
413
);
}
// Parse and validate request with actual body size check
const bodyText = await request.text();
const bodyText = await c.req.text();
const actualBodySize = new TextEncoder().encode(bodyText).length;
if (actualBodySize > LIMITS.MAX_REQUEST_BODY_BYTES) {
return jsonResponse(
{ error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize },
413,
corsHeaders
return c.json(
{ error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize, request_id: requestId },
413
);
}
@@ -68,24 +62,24 @@ export async function handleRecommend(
body = JSON.parse(bodyText) as RecommendRequest;
} catch (parseError) {
console.error('[Recommend] JSON parse error:', parseError instanceof Error ? parseError.message : 'Unknown');
return jsonResponse({
return c.json({
error: 'Invalid JSON format',
request_id: requestId,
}, 400, corsHeaders);
}, 400);
}
// Validate body is an object before proceeding
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return jsonResponse({
return c.json({
error: body && 'lang' in body && body.lang === 'ko' ? '요청 본문은 객체여야 합니다' : 'Request body must be an object',
request_id: requestId,
}, 400, corsHeaders);
}, 400);
}
const lang = body.lang || 'en';
const validationError = validateRecommendRequest(body, lang);
if (validationError) {
return jsonResponse(validationError, 400, corsHeaders);
return c.json(validationError, 400);
}
console.log('[Recommend] Request summary:', {
@@ -102,23 +96,23 @@ export async function handleRecommend(
console.log('[Recommend] Cache key:', cacheKey);
// Check cache (optional - may not be configured)
if (env.CACHE) {
const cached = await env.CACHE.get(cacheKey);
if (c.env.CACHE) {
const cached = await c.env.CACHE.get(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
// Validate required fields exist
if (parsed && Array.isArray(parsed.recommendations)) {
console.log('[Recommend] Cache hit');
return jsonResponse(
{ ...parsed, cached: true },
200,
corsHeaders
);
return c.json({ ...parsed, cached: true });
}
console.warn('[Recommend] Invalid cached data structure, ignoring');
} catch (parseError) {
console.warn('[Recommend] Cache parse error, ignoring cached data');
// Invalid cache structure, delete and continue
console.warn('[Recommend] Invalid cached data structure, deleting:', cacheKey);
await c.env.CACHE.delete(cacheKey);
} catch {
// Corrupted cache, delete and continue
console.warn('[Recommend] Corrupted cache, deleting:', cacheKey);
await c.env.CACHE.delete(cacheKey);
}
}
}
@@ -127,8 +121,8 @@ export async function handleRecommend(
// Phase 1: Execute independent queries in parallel
const [techSpecs, benchmarkDataAll] = await Promise.all([
queryTechSpecs(env.DB, body.tech_stack),
queryBenchmarkData(env.DB, body.tech_stack).catch(err => {
queryTechSpecs(c.env.DB, body.tech_stack),
queryBenchmarkData(c.env.DB, body.tech_stack).catch(err => {
console.warn('[Recommend] Benchmark data unavailable:', err.message);
return [] as BenchmarkData[];
}),
@@ -191,24 +185,12 @@ export async function handleRecommend(
console.log(`[Recommend] DB workload inferred from use_case: ${dbWorkload.type} (multiplier: ${dbWorkload.multiplier})`);
if (techSpecs.length > 0) {
// Group specs by category
const categoryWeights: Record<string, number> = {
'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources
'runtime': 1.0, // nodejs, php, python: actual computation
'database': 1.0, // mysql, postgresql, mongodb: major bottleneck
'cache': 0.5, // redis, memcached: supporting role
'search': 0.8, // elasticsearch: CPU-intensive but not always primary
'container': 0.3, // docker: orchestration overhead
'messaging': 0.5, // rabbitmq, kafka: I/O bound
'default': 0.7 // unknown categories
};
// Calculate weighted vCPU requirements per category
const categoryRequirements = new Map<string, number>();
for (const spec of techSpecs) {
const category = spec.category || 'default';
const weight = categoryWeights[category] || categoryWeights['default'];
const weight = TECH_CATEGORY_WEIGHTS[category] || TECH_CATEGORY_WEIGHTS['default'];
// Apply DB workload multiplier for database category
// Lower multiplier = heavier workload = higher resource needs (lower vcpu_per_users)
@@ -260,10 +242,10 @@ export async function handleRecommend(
const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr'];
// Phase 2: Parallel queries including exchange rate for Korean users
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(env) : Promise.resolve(1);
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(c.env) : Promise.resolve(1);
// Use repository to fetch candidate servers
const repository = new AnvilServerRepository(env.DB);
const repository = new AnvilServerRepository(c.env.DB);
const [candidates, vpsBenchmarks, exchangeRate] = await Promise.all([
repository.findServers({
@@ -273,7 +255,7 @@ export async function handleRecommend(
budgetLimit: body.budget_limit,
limit: LIMITS.MAX_AI_CANDIDATES * 3, // Fetch more to allow for filtering
}),
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
queryVPSBenchmarksBatch(c.env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.warn('[Recommend] VPS benchmarks unavailable:', message);
return [] as VPSBenchmark[];
@@ -284,9 +266,9 @@ export async function handleRecommend(
// Apply exchange rate to candidates if needed (Korean users)
// 서버 가격: 500원 단위 반올림
if (lang === 'ko' && exchangeRate !== 1) {
candidates.forEach(c => {
c.monthly_price = Math.round((c.monthly_price * exchangeRate) / 500) * 500;
c.currency = 'KRW';
candidates.forEach(candidate => {
candidate.monthly_price = Math.round((candidate.monthly_price * exchangeRate) / 500) * 500;
candidate.currency = 'KRW';
});
}
@@ -294,15 +276,11 @@ export async function handleRecommend(
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
if (candidates.length === 0) {
return jsonResponse(
{
error: 'No servers found matching your requirements',
recommendations: [],
request_id: requestId,
},
200,
corsHeaders
);
return c.json({
error: 'No servers found matching your requirements',
recommendations: [],
request_id: requestId,
}, 404);
}
// Bandwidth-based filtering: prioritize servers with adequate transfer allowance
@@ -353,8 +331,8 @@ export async function handleRecommend(
let aiResult: { recommendations: RecommendationResult[]; infrastructure_tips?: string[] };
try {
aiResult = await getAIRecommendations(
env,
env.OPENAI_API_KEY,
c.env,
c.env.OPENAI_API_KEY,
body,
filteredCandidates, // Use bandwidth-filtered candidates
benchmarkData,
@@ -421,24 +399,25 @@ export async function handleRecommend(
};
// Cache result only if we have recommendations (don't cache empty/failed results)
if (env.CACHE && response.recommendations && response.recommendations.length > 0) {
await env.CACHE.put(cacheKey, JSON.stringify(response), {
if (c.env.CACHE && response.recommendations && response.recommendations.length > 0) {
await c.env.CACHE.put(cacheKey, JSON.stringify(response), {
expirationTtl: 300, // 5 minutes (reduced from 1 hour for faster iteration)
});
}
return jsonResponse(response, 200, corsHeaders);
return c.json(response);
} catch (error) {
console.error('[Recommend] Error:', error);
console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack');
console.error('[Recommend] Error details:', error instanceof Error ? error.message : 'Unknown error');
return jsonResponse(
console.error('[Recommend] Error:', JSON.stringify({
request_id: requestId,
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
}));
return c.json(
{
error: 'Failed to generate recommendations',
request_id: requestId,
},
500,
corsHeaders
500
);
}
}
@@ -646,7 +625,9 @@ async function queryTechSpecs(
for (const tech of normalizedStack) {
conditions.push(`(LOWER(name) = ? OR LOWER(aliases) LIKE ?)`);
params.push(tech, `%"${tech}"%`);
// Escape special LIKE characters (%, _, \) in tech name
const escapedTech = escapeLikePattern(tech);
params.push(tech, `%"${escapedTech}"%`);
}
const query = `

View File

@@ -6,8 +6,9 @@
* - lang: Language (en, ko, ja, zh) - default: en
*/
import type { Context } from 'hono';
import type { Env, RecommendationResult, BandwidthEstimate } from '../types';
import { jsonResponse, escapeHtml } from '../utils';
import { escapeHtml } from '../utils';
interface ReportData {
recommendations: RecommendationResult[];
@@ -128,21 +129,25 @@ function getTierColor(index: number): string {
return colors[index] || '#6b7280';
}
export async function handleReport(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
export async function handleReport(c: Context<{ Bindings: Env }>) {
const requestId = c.get('requestId');
try {
const url = new URL(request.url);
const dataParam = url.searchParams.get('data');
const lang = url.searchParams.get('lang') || 'en';
const dataParam = c.req.query('data');
const lang = c.req.query('lang') || 'en';
if (!dataParam) {
return jsonResponse(
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.' },
400,
corsHeaders
return c.json(
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.', request_id: requestId },
400
);
}
// Validate Base64 data size (max ~75KB decoded)
if (dataParam.length > 100000) {
return c.json(
{ error: 'Data parameter too large', request_id: requestId },
413
);
}
@@ -152,37 +157,35 @@ export async function handleReport(
const decoded = atob(dataParam);
reportData = JSON.parse(decoded) as ReportData;
} catch {
return jsonResponse(
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.' },
400,
corsHeaders
return c.json(
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.', request_id: requestId },
400
);
}
if (!reportData.recommendations || reportData.recommendations.length === 0) {
return jsonResponse(
{ error: 'No recommendations in data.' },
400,
corsHeaders
return c.json(
{ error: 'No recommendations in data.', request_id: requestId },
400
);
}
const labels = getLabels(lang);
const html = generateReportHTML(reportData, labels, lang);
// Return HTML with CSP that allows inline styles (required for printable report)
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
...corsHeaders,
'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
console.error('[Report] Error:', error);
return jsonResponse(
{ error: 'Failed to generate report' },
500,
corsHeaders
console.error('[Report] Error:', error, 'request_id:', requestId);
return c.json(
{ error: 'Failed to generate report', request_id: requestId },
500
);
}
}

View File

@@ -2,40 +2,46 @@
* GET /api/servers - Server list with filtering handler
*/
import type { Context } from 'hono';
import type { Env } from '../types';
import { jsonResponse } from '../utils';
import { AnvilServerRepository } from '../repositories/AnvilServerRepository';
/**
* GET /api/servers - Server list with filtering
* Uses anvil_* tables for pricing data
*/
export async function handleGetServers(
request: Request,
env: Env,
corsHeaders: Record<string, string>
): Promise<Response> {
try {
const url = new URL(request.url);
const minCpu = url.searchParams.get('minCpu');
const minMemory = url.searchParams.get('minMemory');
const region = url.searchParams.get('region');
export async function handleGetServers(c: Context<{ Bindings: Env }>) {
const requestId = c.get('requestId');
console.log('[GetServers] Query params:', {
minCpu,
minMemory,
region,
});
try {
const minCpu = c.req.query('minCpu');
const minMemory = c.req.query('minMemory');
const region = c.req.query('region');
console.log('[GetServers] Query params:', { minCpu, minMemory, region, request_id: requestId });
// Generate cache key from query parameters
const cacheKey = `servers:${url.search || 'all'}`;
const cacheKey = `servers:${new URL(c.req.url).search || 'all'}`;
// Check cache first
if (env.CACHE) {
const cached = await env.CACHE.get(cacheKey);
if (c.env.CACHE) {
const cached = await c.env.CACHE.get(cacheKey);
if (cached) {
console.log('[GetServers] Cache hit for:', cacheKey);
return jsonResponse({ ...JSON.parse(cached), cached: true }, 200, corsHeaders);
try {
const parsed = JSON.parse(cached);
// Validate cache structure
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.servers)) {
console.log('[GetServers] Cache hit for:', cacheKey);
return c.json({ ...parsed, cached: true });
}
// Invalid cache structure, delete and continue
console.warn('[GetServers] Invalid cache structure, deleting:', cacheKey, 'request_id:', requestId);
await c.env.CACHE.delete(cacheKey);
} catch {
// Corrupted cache, delete and continue
console.warn('[GetServers] Corrupted cache, deleting:', cacheKey, 'request_id:', requestId);
await c.env.CACHE.delete(cacheKey);
}
}
}
@@ -46,19 +52,19 @@ export async function handleGetServers(
if (minCpu) {
parsedCpu = parseInt(minCpu, 10);
if (isNaN(parsedCpu)) {
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
return c.json({ error: 'Invalid minCpu parameter' }, 400);
}
}
if (minMemory) {
parsedMemory = parseInt(minMemory, 10);
if (isNaN(parsedMemory)) {
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
return c.json({ error: 'Invalid minMemory parameter' }, 400);
}
}
// Use repository to fetch servers
const repository = new AnvilServerRepository(env.DB);
const repository = new AnvilServerRepository(c.env.DB);
const servers = await repository.findServers({
minCpu: parsedCpu,
minMemoryGb: parsedMemory,
@@ -66,7 +72,7 @@ export async function handleGetServers(
limit: 100,
});
console.log('[GetServers] Found servers:', servers.length);
console.log('[GetServers] Found servers:', servers.length, 'request_id:', requestId);
const responseData = {
servers,
@@ -75,23 +81,18 @@ export async function handleGetServers(
};
// Cache successful results (only if we have servers)
if (env.CACHE && servers.length > 0) {
await env.CACHE.put(cacheKey, JSON.stringify(responseData), {
if (c.env.CACHE && servers.length > 0) {
await c.env.CACHE.put(cacheKey, JSON.stringify(responseData), {
expirationTtl: 300, // 5 minutes
});
}
return jsonResponse(responseData, 200, corsHeaders);
return c.json(responseData);
} catch (error) {
console.error('[GetServers] Error:', error);
const requestId = crypto.randomUUID();
return jsonResponse(
{
error: 'Failed to retrieve servers',
request_id: requestId,
},
500,
corsHeaders
console.error('[GetServers] Error:', error, 'request_id:', c.get('requestId'));
return c.json(
{ error: 'Failed to retrieve servers', request_id: c.get('requestId') },
500
);
}
}

View File

@@ -2,10 +2,13 @@
* Cloudflare Worker - Server Recommendation System Entry Point
*
* AI-powered server recommendation service using Workers AI, D1, and KV.
* Built with Hono framework.
*/
import type { Env } from './types';
import { getAllowedOrigin, checkRateLimit, jsonResponse, isAllowedProvisionOrigin } from './utils';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Env, ProvisionQueueMessage } from './types';
import { rateLimiter, provisionAuth, validateOrigin, validateUserId, securityHeaders, requestId } from './middleware';
import { handleHealth } from './handlers/health';
import { handleGetServers } from './handlers/servers';
import { handleRecommend } from './handlers/recommend';
@@ -19,139 +22,69 @@ import {
handleGetOsImages,
} from './handlers/provision';
import { handleProvisionQueue } from './handlers/queue';
import type { ProvisionQueueMessage } from './types';
/**
* Main request handler
*/
// Create Hono app with typed bindings
const app = new Hono<{ Bindings: Env }>();
// Request ID middleware (first - for tracking)
app.use('*', requestId);
// CORS middleware - uses shared origin validation
app.use('*', cors({
origin: (origin) => validateOrigin(origin),
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'X-API-Key', 'X-Request-ID'],
}));
// Security headers
app.use('*', securityHeaders);
// Rate limiting middleware
app.use('*', rateLimiter);
// Health check
app.get('/api/health', handleHealth);
// Server listing
app.get('/api/servers', handleGetServers);
// Recommendations
app.post('/api/recommend', handleRecommend);
app.get('/api/recommend/report', handleReport);
// Provisioning endpoints (with auth + user_id validation middleware)
const provision = new Hono<{ Bindings: Env }>();
provision.use('*', provisionAuth);
provision.use('*', validateUserId);
provision.post('/', handleProvision);
provision.get('/orders', handleGetOrders);
provision.get('/orders/:id', handleGetOrder);
provision.delete('/orders/:id', handleDeleteOrder);
provision.get('/balance', handleGetBalance);
provision.get('/images', handleGetOsImages);
app.route('/api/provision', provision);
// 404 handler
app.notFound((c) => {
return c.json(
{ error: 'Not found', request_id: c.get('requestId') },
404
);
});
// Error handler
app.onError((err, c) => {
console.error('[Worker] Unhandled error:', err, 'request_id:', c.get('requestId'));
return c.json(
{ error: 'Internal server error', request_id: c.get('requestId') },
500
);
});
// Export for Cloudflare Workers
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const requestId = crypto.randomUUID();
try {
const url = new URL(request.url);
const path = url.pathname;
// Rate limiting (except for health checks)
if (path !== '/api/health') {
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateCheck = await checkRateLimit(clientIP, env);
if (!rateCheck.allowed) {
const origin = getAllowedOrigin(request);
return jsonResponse(
{ error: 'Too many requests', request_id: rateCheck.requestId },
429,
{
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin',
}
);
}
}
// CORS headers for all responses
const origin = getAllowedOrigin(request);
const corsHeaders = {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Vary': 'Origin',
};
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Route handling
if (path === '/api/health') {
return handleHealth(corsHeaders);
}
if (path === '/api/servers' && request.method === 'GET') {
return handleGetServers(request, env, corsHeaders);
}
if (path === '/api/recommend' && request.method === 'POST') {
return handleRecommend(request, env, corsHeaders);
}
if (path === '/api/recommend/report' && request.method === 'GET') {
return handleReport(request, env, corsHeaders);
}
// Provisioning endpoints (restricted to *.kappa-d8e.workers.dev + API key)
if (path.startsWith('/api/provision')) {
// Check API key (required for all provision requests)
const apiKey = request.headers.get('X-API-Key');
if (!env.PROVISION_API_KEY || apiKey !== env.PROVISION_API_KEY) {
return jsonResponse(
{ error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED' },
401,
corsHeaders
);
}
// Check origin for browser requests
if (!isAllowedProvisionOrigin(request)) {
return jsonResponse(
{ error: 'Forbidden: Invalid origin', code: 'FORBIDDEN' },
403,
corsHeaders
);
}
if (path === '/api/provision' && request.method === 'POST') {
return handleProvision(request, env, corsHeaders);
}
if (path === '/api/provision/orders' && request.method === 'GET') {
return handleGetOrders(request, env, corsHeaders);
}
if (path === '/api/provision/balance' && request.method === 'GET') {
return handleGetBalance(request, env, corsHeaders);
}
if (path === '/api/provision/images' && request.method === 'GET') {
return handleGetOsImages(env, corsHeaders);
}
// Dynamic route: /api/provision/orders/:id
const orderMatch = path.match(/^\/api\/provision\/orders\/([a-zA-Z0-9-]+)$/);
if (orderMatch) {
const orderId = orderMatch[1];
if (request.method === 'GET') {
return handleGetOrder(request, env, orderId, corsHeaders);
}
if (request.method === 'DELETE') {
return handleDeleteOrder(request, env, orderId, corsHeaders);
}
}
}
return jsonResponse(
{ error: 'Not found', request_id: requestId },
404,
corsHeaders
);
} catch (error) {
console.error('[Worker] Unhandled error:', error);
const origin = getAllowedOrigin(request);
return jsonResponse(
{
error: 'Internal server error',
request_id: requestId,
},
500,
{
'Access-Control-Allow-Origin': origin,
'Vary': 'Origin',
}
);
}
},
fetch: app.fetch,
/**
* Queue handler for async server provisioning

32
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* Authentication middleware for provisioning endpoints
*/
import { Context, Next } from 'hono';
import type { Env } from '../types';
import { isAllowedOrigin } from './origin';
/**
* Middleware to check API key for provisioning endpoints
*/
export async function provisionAuth(c: Context<{ Bindings: Env }>, next: Next) {
const apiKey = c.req.header('X-API-Key');
if (!c.env.PROVISION_API_KEY || apiKey !== c.env.PROVISION_API_KEY) {
return c.json(
{ error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED', request_id: c.get('requestId') },
401
);
}
// Check origin for browser requests
const origin = c.req.header('Origin');
if (!isAllowedOrigin(origin)) {
return c.json(
{ error: 'Forbidden: Invalid origin', code: 'FORBIDDEN', request_id: c.get('requestId') },
403
);
}
return next();
}

10
src/middleware/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Middleware exports
*/
export { provisionAuth } from './auth';
export { rateLimiter } from './rate-limit';
export { validateOrigin, isAllowedOrigin } from './origin';
export { validateUserId } from './user-id';
export { securityHeaders } from './security';
export { requestId } from './request-id';

33
src/middleware/origin.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Shared origin validation utilities
*/
const DEFAULT_ORIGIN = 'https://cloud-orchestrator.kappa-d8e.workers.dev';
/**
* Check if origin is allowed
* Returns the allowed origin string or null if rejected
*/
export function validateOrigin(origin: string | null | undefined): string | null {
// No origin = non-browser request (curl, server) - allowed with default
if (!origin) return DEFAULT_ORIGIN;
// Allow only our Cloudflare Workers account subdomain
if (origin.endsWith('.kappa-d8e.workers.dev')) return origin;
// Allow localhost for development
if (origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:')) {
return origin;
}
// Reject other origins
return null;
}
/**
* Check if request origin is allowed (boolean version)
*/
export function isAllowedOrigin(origin: string | null | undefined): boolean {
return validateOrigin(origin) !== null;
}

View File

@@ -0,0 +1,28 @@
/**
* Rate limiting middleware
*/
import { Context, Next } from 'hono';
import type { Env } from '../types';
import { checkRateLimit } from '../utils/cache';
/**
* Rate limiting middleware
* Skips health check endpoint
*/
export async function rateLimiter(c: Context<{ Bindings: Env }>, next: Next) {
// Skip rate limiting for health checks
if (c.req.path === '/api/health') return next();
const clientIP = c.req.header('CF-Connecting-IP') || 'unknown';
const rateCheck = await checkRateLimit(clientIP, c.env);
if (!rateCheck.allowed) {
return c.json(
{ error: 'Too many requests', request_id: c.get('requestId') },
429
);
}
return next();
}

View File

@@ -0,0 +1,29 @@
/**
* Request ID middleware for request tracking
*/
import { Context, Next } from 'hono';
// Extend Hono context to include requestId
declare module 'hono' {
interface ContextVariableMap {
requestId: string;
}
}
/**
* Middleware to generate and track request IDs
* Adds X-Request-ID header to all responses
*/
export async function requestId(c: Context, next: Next) {
// Use existing request ID from header or generate new one
const id = c.req.header('X-Request-ID') || crypto.randomUUID();
// Store in context for handlers to use
c.set('requestId', id);
// Add to response header
c.header('X-Request-ID', id);
return next();
}

View File

@@ -0,0 +1,30 @@
/**
* Security headers middleware
*/
import { Context, Next } from 'hono';
/**
* Add security headers to all responses
*/
export async function securityHeaders(c: Context, next: Next) {
await next();
// Prevent MIME type sniffing
c.header('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
c.header('X-Frame-Options', 'DENY');
// XSS protection (legacy browsers)
c.header('X-XSS-Protection', '1; mode=block');
// Referrer policy
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy for API responses
c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
// HSTS - force HTTPS (1 year)
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}

55
src/middleware/user-id.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* User ID validation middleware for provision endpoints
*/
import { Context, Next } from 'hono';
import type { Env } from '../types';
// Extend Hono context to include userId
declare module 'hono' {
interface ContextVariableMap {
userId: string;
}
}
/**
* Endpoints that don't require user_id validation
* Full paths for exact matching (safer than endsWith)
*/
const SKIP_USER_ID_PATHS = new Set([
'/api/provision', // POST /api/provision (body has user_id)
'/api/provision/images', // GET /api/provision/images (public endpoint)
]);
/**
* Middleware to validate and extract user_id from query parameter
* Skips validation for endpoints that don't require user_id
*/
export async function validateUserId(c: Context<{ Bindings: Env }>, next: Next) {
// Skip for endpoints that don't require user_id (exact match)
if (SKIP_USER_ID_PATHS.has(c.req.path)) {
return next();
}
const userId = c.req.query('user_id');
if (!userId) {
return c.json(
{ error: 'user_id query parameter is required', code: 'VALIDATION_ERROR', request_id: c.get('requestId') },
400
);
}
// Validate user_id format (basic sanitization)
if (userId.length > 50 || !/^[\w-]+$/.test(userId)) {
return c.json(
{ error: 'Invalid user_id format', code: 'VALIDATION_ERROR', request_id: c.get('requestId') },
400
);
}
// Store in context for handlers to use
c.set('userId', userId);
return next();
}

View File

@@ -87,20 +87,22 @@ export class ProvisioningRepository {
async createServerOrder(
userId: number,
telegramUserId: string,
specId: number,
region: string,
pricePaid: number,
label: string | null,
image: string | null
image: string | null,
idempotencyKey: string | null
): Promise<ServerOrder> {
const result = await this.userDb
.prepare(
`INSERT INTO server_orders
(user_id, spec_id, status, region, price_paid, label, image, billing_type)
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly')
(user_id, telegram_user_id, spec_id, status, region, price_paid, label, image, billing_type, idempotency_key, created_at, expires_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, 'monthly', ?, CURRENT_TIMESTAMP, datetime(CURRENT_TIMESTAMP, '+720 hours'))
RETURNING *`
)
.bind(userId, specId, region, pricePaid, label, image)
.bind(userId, telegramUserId, specId, region, pricePaid, label, image, idempotencyKey)
.first();
return result as unknown as ServerOrder;
@@ -132,7 +134,16 @@ export class ProvisioningRepository {
}
}
/**
* Update order with root password
*
* SECURITY WARNING: Password is currently stored in plaintext.
* TODO: Implement encryption using WebCrypto API (AES-GCM) before production use.
* The encryption key should be stored in env.ENCRYPTION_KEY secret.
*/
async updateOrderRootPassword(orderId: number, rootPassword: string): Promise<void> {
// TODO: Encrypt password before storage
// const encryptedPassword = await this.encryptPassword(rootPassword, env.ENCRYPTION_KEY);
await this.userDb
.prepare(
`UPDATE server_orders
@@ -176,7 +187,9 @@ export class ProvisioningRepository {
async getOrdersByUserId(userId: number, limit: number = 20): Promise<ServerOrder[]> {
const result = await this.userDb
.prepare(
'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'
`SELECT * FROM server_orders
WHERE user_id = ? AND status NOT IN ('terminated', 'cancelled')
ORDER BY created_at DESC LIMIT ?`
)
.bind(userId, limit)
.all();
@@ -184,6 +197,18 @@ export class ProvisioningRepository {
return result.results as unknown as ServerOrder[];
}
/**
* Find order by idempotency key (for duplicate prevention)
*/
async findOrderByIdempotencyKey(idempotencyKey: string): Promise<ServerOrder | null> {
const result = await this.userDb
.prepare('SELECT * FROM server_orders WHERE idempotency_key = ?')
.bind(idempotencyKey)
.first();
return result as unknown as ServerOrder | null;
}
// ============================================
// Pricing Lookup (cloud-instances-db)
// Uses anvil_pricing, anvil_instances, anvil_regions
@@ -233,11 +258,13 @@ export class ProvisioningRepository {
*/
async createOrderWithPayment(
userId: number,
telegramUserId: string,
specId: number,
region: string,
priceKrw: number,
label: string | null,
image: string | null
image: string | null,
idempotencyKey: string | null
): Promise<{ orderId: number | null; error?: string }> {
try {
// Step 1: Check and deduct balance
@@ -249,11 +276,13 @@ export class ProvisioningRepository {
// Step 2: Create order
const order = await this.createServerOrder(
userId,
telegramUserId,
specId,
region,
priceKrw,
label,
image
image,
idempotencyKey
);
return { orderId: order.id };

View File

@@ -15,7 +15,7 @@ import type {
AIRecommendationResponse,
BenchmarkReference,
} from '../types';
import { i18n, LIMITS } from '../config';
import { i18n, LIMITS, TIMEOUTS } from '../config';
import {
sanitizeForAIPrompt,
isValidAIRecommendation,
@@ -56,16 +56,15 @@ export async function getAIRecommendations(
const languageInstruction = i18n[validLang].aiLanguageInstruction;
// Build system prompt with benchmark awareness
const systemPrompt = `You are a cloud infrastructure expert focused on COST-EFFECTIVE solutions. Your goal is to recommend the SMALLEST and CHEAPEST server that can handle the user's requirements.
const systemPrompt = `You are a cloud infrastructure expert who recommends the RIGHT server by understanding user needs - whether they prioritize cost savings or performance.
CRITICAL RULES:
1. NEVER over-provision. Recommend the minimum specs needed.
2. Cost efficiency is the PRIMARY factor - cheaper is better if it meets requirements.
3. A 1-2 vCPU server can handle 100-500 concurrent users for most web workloads.
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
6. NEVER recommend the same server twice. Each recommendation MUST have a DIFFERENT server_id.
7. If only 2 suitable servers exist, recommend only 2. Do NOT duplicate.
1. UNDERSTAND THE CONTEXT: Budget-conscious users and minimal workloads need different recommendations than high-performance scenarios.
2. BUDGET SCENARIOS (<50 concurrent users, personal blogs, portfolios): Basic 1GB/2GB are valid top recommendations. Cost is the PRIMARY factor.
3. HIGH-DEMAND SCENARIOS (500+ concurrent users, gaming, databases): Capacity is PRIMARY. Never undersized servers.
4. Provide 3 options with meaningful spec progression: Budget (cheapest adequate) → Best Fit (balanced) → Premium (growth ready).
5. NEVER recommend the same server twice. Each recommendation MUST have a DIFFERENT server_id.
6. If only 2 suitable servers exist, recommend only 2. Do NOT duplicate.
BANDWIDTH CONSIDERATIONS:
- Estimated monthly bandwidth is provided based on concurrent users and use case.
@@ -204,16 +203,34 @@ Return ONLY a valid JSON object (no markdown, no code blocks) with this exact st
}
Provide exactly 3 recommendations:
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load
2. BALANCED option: Some headroom for traffic spikes
3. PREMIUM option: Ready for 2-3x growth
1. BEST FIT option: Best match for requirements (balance capacity and cost)
2. BUDGET option: Cheapest adequate option (MUST meet minimum requirements)
3. PREMIUM option: Higher-spec with room for 2-3x growth
SCORING (100 points total):
- Total Cost Efficiency (40%): Base price + estimated bandwidth overage. Lower total = higher score.
- Capacity Fit (30%): Can it handle the concurrent users and bandwidth?
- Scalability (30%): Room for growth in CPU, memory, AND bandwidth allowance.
SCORING RULES (100 points total):
Base scoring:
- Capacity Fit (50%): Can it handle concurrent users and bandwidth?
- Total Cost Efficiency (30%): Lower cost = higher score among adequate servers
- Scalability (20%): Room for growth
The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have the HIGHEST score.`;
BUDGET-CONSCIOUS SCENARIOS:
When user explicitly requests "cheapest", "저렴한", "최소", "가장 싼" OR has minimal requirements (<50 concurrent users, personal blog, portfolio):
- Basic 1GB or Basic 2GB servers MUST be considered as top recommendations
- The cheapest server that meets minimum requirements should score 85-95 points
- Cost efficiency becomes PRIMARY factor (50%) while capacity becomes SECONDARY (30%)
- For personal blogs (<100 daily visitors): Basic 1GB is often sufficient and should be #1 recommendation
- For small sites (100-500 daily visitors): Basic 2GB should be considered for #1 or #2
HIGH-DEMAND SCENARIOS:
For high-concurrency workloads (500+ concurrent users, high-traffic apps, gaming servers, databases):
- Capacity MUST be sufficient first - undersized servers should never score above 60
- A 1GB server CANNOT handle 1000+ concurrent users - score it accordingly
- Standard 4GB or higher should be prioritized for demanding workloads
CRITICAL VALIDATION:
- Never recommend the same server twice (different server_id required)
- Budget option should be 40-60% cheaper than Premium option when possible
- Ensure meaningful spec progression: Budget < Best Fit < Premium`;
// Use AI Gateway if configured (bypasses regional restrictions like HKG)
// AI Gateway URL format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai
@@ -227,9 +244,9 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
console.log('[AI] Using Cloudflare AI Gateway to bypass regional restrictions');
}
// Create AbortController with 30 second timeout
// Create AbortController with configurable timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.AI_REQUEST_MS);
try {
const openaiResponse = await fetch(apiEndpoint, {

View File

@@ -5,6 +5,7 @@
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
import { TIMEOUTS } from '../config';
interface LinodeInstance {
id: number;
@@ -28,7 +29,7 @@ interface LinodeError {
export class LinodeProvider extends VPSProviderBase {
static readonly DEFAULT_BASE_URL = 'https://api.linode.com/v4';
constructor(apiKey: string, baseUrl?: string, timeout: number = 30000) {
constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) {
super({
apiKey,
baseUrl: baseUrl || LinodeProvider.DEFAULT_BASE_URL,

View File

@@ -8,6 +8,9 @@ import { ProvisioningRepository } from '../repositories/ProvisioningRepository';
import { LinodeProvider } from './linode-provider';
import { VultrProvider } from './vultr-provider';
import { getExchangeRate } from '../utils/exchange-rate';
import { TIMEOUTS } from '../config';
const TELEGRAM_TIMEOUT_MS = 10000; // 10 seconds for Telegram API calls
export class ProvisioningService {
private repo: ProvisioningRepository;
@@ -40,7 +43,28 @@ export class ProvisioningService {
* 6. Return immediately with order info
*/
async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> {
const { telegram_id, pricing_id, label, image, dry_run } = request;
const { telegram_id, pricing_id, label, image, dry_run, idempotency_key } = request;
// Step 0: Check idempotency - if key exists, handle based on order status
let existingPendingOrder: ServerOrder | null = null;
if (idempotency_key) {
const existingOrder = await this.repo.findOrderByIdempotencyKey(idempotency_key);
if (existingOrder) {
// If order is already active, return existing result (true idempotency)
if (existingOrder.status === 'active') {
console.log(`[ProvisioningService] Idempotent request: order ${existingOrder.id} already active, returning existing`);
return {
success: true,
order: existingOrder,
};
}
// If order is pending/provisioning, store it to use instead of creating new
// This handles retry scenarios where previous attempt failed
existingPendingOrder = existingOrder;
console.log(`[ProvisioningService] Found pending order ${existingOrder.id} with idempotency_key, will use existing instead of creating new`);
}
}
// Step 1: Validate OS image from DB (or get default)
let osImage = image ? await this.repo.getOsImageByKey(image) : await this.repo.getDefaultOsImage();
@@ -102,6 +126,7 @@ export class ProvisioningService {
order: {
id: 0,
user_id: user.id,
telegram_user_id: telegram_id,
spec_id: pricing_id,
status: 'pending',
region: pricing.region_code,
@@ -117,6 +142,7 @@ export class ProvisioningService {
label: label || null,
image: osImageKey,
billing_type: 'monthly',
idempotency_key: idempotency_key || null,
},
dry_run_info: {
message: 'Dry run successful. No server created, no balance deducted.',
@@ -132,36 +158,54 @@ export class ProvisioningService {
} as ProvisionResponse;
}
// Step 5: Generate root password before creating order
const rootPassword = this.generateSecurePassword();
// Step 5 & 6: Use existing pending order OR create new order with payment
let orderId: number;
// Step 6: Create order with payment (atomic balance deduction + order creation)
const orderResult = await this.repo.createOrderWithPayment(
user.id,
pricing_id,
pricing.region_code,
priceKrw,
label || null,
osImageKey
);
if (existingPendingOrder) {
// Use existing pending order - no need to create new or deduct balance again
orderId = existingPendingOrder.id;
console.log(`[ProvisioningService] Using existing order ${orderId} (idempotency)`);
if (!orderResult.orderId) {
return {
success: false,
error: {
code: orderResult.error || 'ORDER_CREATION_FAILED',
message: orderResult.error === 'INSUFFICIENT_BALANCE'
? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}`
: 'Failed to create order',
},
};
// Ensure root_password is set (telegram-bot-workers doesn't set it)
if (!existingPendingOrder.root_password) {
const rootPassword = this.generateSecurePassword();
await this.repo.updateOrderRootPassword(orderId, rootPassword);
console.log(`[ProvisioningService] Generated root_password for existing order ${orderId}`);
}
} else {
// Step 5: Generate root password before creating order
const rootPassword = this.generateSecurePassword();
// Step 6: Create order with payment (atomic balance deduction + order creation)
const orderResult = await this.repo.createOrderWithPayment(
user.id,
telegram_id,
pricing_id,
pricing.region_code,
priceKrw,
label || null,
osImageKey,
idempotency_key || null
);
if (!orderResult.orderId) {
return {
success: false,
error: {
code: orderResult.error || 'ORDER_CREATION_FAILED',
message: orderResult.error === 'INSUFFICIENT_BALANCE'
? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}`
: 'Failed to create order',
},
};
}
orderId = orderResult.orderId;
// Step 7: Store root password in order (encrypted in production)
await this.repo.updateOrderRootPassword(orderId, rootPassword);
}
const orderId = orderResult.orderId;
// Step 7: Store root password in order (encrypted in production)
await this.repo.updateOrderRootPassword(orderId, rootPassword);
// Step 8: Update order status to 'queued' and send to Queue
await this.repo.updateOrderStatus(orderId, 'provisioning');
@@ -211,7 +255,9 @@ export class ProvisioningService {
const pricing = await this.repo.getPricingWithProvider(pricing_id);
if (!pricing) {
console.error(`[ProvisioningService] Pricing not found for order ${order_id}`);
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Pricing not found');
const errorMsg = 'Pricing not found';
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
return;
}
@@ -219,7 +265,9 @@ export class ProvisioningService {
const provider = this.getProvider(pricing.source_provider);
if (!provider) {
console.error(`[ProvisioningService] Provider ${pricing.source_provider} not configured for order ${order_id}`);
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `Provider ${pricing.source_provider} not configured`);
const errorMsg = `Provider ${pricing.source_provider} not configured`;
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
return;
}
@@ -227,7 +275,9 @@ export class ProvisioningService {
const osImage = await this.repo.getOsImageByKey(image);
if (!osImage) {
console.error(`[ProvisioningService] OS image '${image}' not found for order ${order_id}`);
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image '${image}' not found`);
const errorMsg = `OS image '${image}' not found`;
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
return;
}
@@ -238,10 +288,24 @@ export class ProvisioningService {
if (!osImageId) {
console.error(`[ProvisioningService] OS image '${image}' not available for ${pricing.source_provider}`);
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image not available for ${pricing.source_provider}`);
const errorMsg = `OS image not available for ${pricing.source_provider}`;
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
return;
}
// Prepare SSH keys (admin key for recovery access)
// Linode: uses public key string directly
// Vultr: uses pre-registered SSH key ID
let sshKeys: string[] | undefined;
if (pricing.provider_name.toLowerCase() === 'linode' && this.env.ADMIN_SSH_PUBLIC_KEY) {
sshKeys = [this.env.ADMIN_SSH_PUBLIC_KEY];
console.log(`[ProvisioningService] Admin SSH public key will be added to order ${order_id} (Linode)`);
} else if (pricing.provider_name.toLowerCase() === 'vultr' && this.env.ADMIN_SSH_KEY_ID_VULTR) {
sshKeys = [this.env.ADMIN_SSH_KEY_ID_VULTR];
console.log(`[ProvisioningService] Admin SSH key ID will be added to order ${order_id} (Vultr)`);
}
// Call provider API (use source_region_code for actual provider region)
const createResult = await provider.createServer({
plan: pricing.instance_id,
@@ -249,19 +313,17 @@ export class ProvisioningService {
osImage: osImageId,
label: label || `order-${order_id}`,
rootPassword: root_password,
sshKeys,
tags: [`user:${user_id}`, `order:${order_id}`],
});
if (!createResult.success) {
console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error);
await this.repo.rollbackOrder(
order_id,
user_id,
order.price_paid,
createResult.error?.message || 'Provider API error'
);
const errorMsg = createResult.error?.message || 'Provider API error';
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
// Throw error to trigger retry
throw new Error(createResult.error?.message || 'Provider API error');
throw new Error(errorMsg);
}
// Wait for IP assignment if not immediately available
@@ -284,6 +346,164 @@ export class ProvisioningService {
);
console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`);
// Send Telegram notification to user (now that we have real IP and password)
await this.sendProvisioningSuccessNotification(
order_id,
order.telegram_user_id,
ipv4 || null,
order.label,
pricing.region_name,
root_password
);
}
/**
* Send Telegram notification for provisioning failure
*/
private async sendProvisioningFailureNotification(
orderId: number,
telegramUserId: string,
errorMessage: string,
refundAmount: number
): Promise<void> {
// Check if BOT_TOKEN is available
if (!this.env.BOT_TOKEN) {
console.warn(`[ProvisioningService] BOT_TOKEN not configured - skipping failure notification for order ${orderId}`);
return;
}
try {
const botToken = this.env.BOT_TOKEN;
const chatId = parseInt(telegramUserId, 10);
// Validate chatId
if (isNaN(chatId) || chatId <= 0) {
console.error(`[ProvisioningService] Invalid Telegram user ID: ${telegramUserId} for order ${orderId}`);
return;
}
const failureMessage = `❌ 서버 프로비저닝 실패
주문 #${orderId}
사유: ${errorMessage}
잔액 환불: ${refundAmount.toLocaleString()}
다시 시도하시거나 관리자에게 문의해주세요.`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TELEGRAM_TIMEOUT_MS);
try {
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: failureMessage,
}),
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
console.log(`[ProvisioningService] Sent failure notification for order ${orderId}`);
} catch (error) {
console.error(`[ProvisioningService] Failed to send failure notification for order ${orderId}:`, error);
// Don't throw - notification failure shouldn't affect the rollback
}
}
/**
* Send Telegram notification after successful provisioning
*/
private async sendProvisioningSuccessNotification(
orderId: number,
telegramUserId: string,
ipAddress: string | null,
label: string | null,
region: string,
rootPassword: string
): Promise<void> {
// Check if BOT_TOKEN is available
if (!this.env.BOT_TOKEN) {
console.warn(`[ProvisioningService] BOT_TOKEN not configured - skipping Telegram notification for order ${orderId}`);
return;
}
try {
const botToken = this.env.BOT_TOKEN;
const chatId = parseInt(telegramUserId, 10);
// Validate chatId
if (isNaN(chatId) || chatId <= 0) {
console.error(`[ProvisioningService] Invalid Telegram user ID: ${telegramUserId} for order ${orderId}`);
return;
}
// Send main success message
const successMessage = `✅ 서버 프로비저닝 완료!
주문 #${orderId}
IP 주소: ${ipAddress || 'IP 할당 대기 중'}
라벨: ${label || `order-${orderId}`}
리전: ${region}
루트 비밀번호는 보안상 별도로 전송됩니다.`;
// Send success message with timeout
const controller1 = new AbortController();
const timeoutId1 = setTimeout(() => controller1.abort(), TELEGRAM_TIMEOUT_MS);
try {
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: successMessage,
}),
signal: controller1.signal,
});
} finally {
clearTimeout(timeoutId1);
}
console.log(`[ProvisioningService] Sent success notification for order ${orderId}`);
// Send password in separate message (security best practice)
const passwordMessage = `🔐 서버 #${orderId} 루트 비밀번호:
<code>${rootPassword}</code>
⚠️ 이 메시지는 즉시 삭제하고 비밀번호를 안전한 곳에 보관하세요.`;
// Send password message with timeout
const controller2 = new AbortController();
const timeoutId2 = setTimeout(() => controller2.abort(), TELEGRAM_TIMEOUT_MS);
try {
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: passwordMessage,
parse_mode: 'HTML',
}),
signal: controller2.signal,
});
} finally {
clearTimeout(timeoutId2);
}
console.log(`[ProvisioningService] Sent password notification for order ${orderId}`);
} catch (error) {
console.error(`[ProvisioningService] Failed to send Telegram notification for order ${orderId}:`, error);
// Don't throw - notification failure shouldn't fail the provisioning
}
}
/**

View File

@@ -84,16 +84,37 @@ export abstract class VPSProviderBase {
}
/**
* Generate cryptographically secure password
* Generate cryptographically secure password using rejection sampling
* to avoid modulo bias in character selection
*/
protected generateSecurePassword(length: number = 32): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
const array = new Uint8Array(length);
// Calculate max valid value to avoid modulo bias (256 % 70 = 46, creates bias)
const maxValidValue = Math.floor(256 / chars.length) * chars.length;
// Request more bytes than needed for rejection sampling
const array = new Uint8Array(length * 2);
crypto.getRandomValues(array);
let password = '';
for (let i = 0; i < length; i++) {
password += chars[array[i] % chars.length];
let i = 0;
while (password.length < length && i < array.length) {
if (array[i] < maxValidValue) {
password += chars[array[i] % chars.length];
}
i++;
}
// Fallback: if we run out of valid bytes, get more
while (password.length < length) {
const extraBytes = new Uint8Array(length);
crypto.getRandomValues(extraBytes);
for (let j = 0; j < extraBytes.length && password.length < length; j++) {
if (extraBytes[j] < maxValidValue) {
password += chars[extraBytes[j] % chars.length];
}
}
}
return password;
}
}

View File

@@ -5,6 +5,7 @@
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
import { TIMEOUTS } from '../config';
interface VultrInstance {
id: string;
@@ -38,7 +39,7 @@ interface VultrError {
export class VultrProvider extends VPSProviderBase {
static readonly DEFAULT_BASE_URL = 'https://api.vultr.com/v2';
constructor(apiKey: string, baseUrl?: string, timeout: number = 30000) {
constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) {
super({
apiKey,
baseUrl: baseUrl || VultrProvider.DEFAULT_BASE_URL,

View File

@@ -19,6 +19,11 @@ export interface Env {
PROVISION_API_KEY?: string; // Required for /api/provision/* endpoints
// Queue for async provisioning
PROVISION_QUEUE: Queue<ProvisionQueueMessage>;
// Telegram Bot Token for user notifications
BOT_TOKEN?: string; // Optional: for sending Telegram notifications after provisioning
// Admin SSH keys for server recovery
ADMIN_SSH_PUBLIC_KEY?: string; // Linode: public key string (ssh-rsa AAAA...)
ADMIN_SSH_KEY_ID_VULTR?: string; // Vultr: pre-registered SSH key ID
}
// Queue message for async server provisioning
@@ -253,6 +258,7 @@ export interface UserDeposit {
export interface ServerOrder {
id: number; // AUTO INCREMENT
user_id: number; // References users.id
telegram_user_id: string; // Telegram user ID (for direct reference)
spec_id: number; // Server spec ID
status: 'pending' | 'provisioning' | 'active' | 'failed' | 'cancelled' | 'terminated';
region: string;
@@ -268,6 +274,7 @@ export interface ServerOrder {
label: string | null;
image: string | null;
billing_type: string; // 'monthly' default
idempotency_key: string | null; // Idempotency key for duplicate prevention
}
export type VPSProvider = 'linode' | 'vultr';
@@ -278,6 +285,7 @@ export interface ProvisionRequest {
label?: string;
image?: string; // OS image (e.g., 'ubuntu_22_04')
dry_run?: boolean; // Test mode: validate only, don't create server
idempotency_key?: string; // Idempotency key for duplicate prevention (1-128 chars)
}
export interface ProvisionResponse {

View File

@@ -82,6 +82,18 @@ export function generateCacheKey(req: RecommendRequest): string {
// In-memory fallback for rate limiting when CACHE KV is unavailable
const inMemoryRateLimit = new Map<string, { count: number; resetTime: number }>();
const MAX_IN_MEMORY_ENTRIES = 10000;
/**
* Clean up expired entries from in-memory rate limit map
*/
function cleanupExpiredEntries(now: number): void {
for (const [key, record] of inMemoryRateLimit) {
if (record.resetTime < now) {
inMemoryRateLimit.delete(key);
}
}
}
/**
* Rate limiting check using KV storage with in-memory fallback
@@ -94,6 +106,18 @@ export async function checkRateLimit(clientIP: string, env: Env): Promise<{ allo
// Use in-memory fallback if CACHE unavailable
if (!env.CACHE) {
// Cleanup expired entries if map is getting too large
if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) {
cleanupExpiredEntries(now);
// If still too large after cleanup, remove oldest 10% by resetTime
if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) {
const entries = Array.from(inMemoryRateLimit.entries())
.sort((a, b) => a[1].resetTime - b[1].resetTime)
.slice(0, Math.floor(MAX_IN_MEMORY_ENTRIES * 0.1));
entries.forEach(([key]) => inMemoryRateLimit.delete(key));
}
}
const record = inMemoryRateLimit.get(clientIP);
if (!record || record.resetTime < now) {

View File

@@ -164,6 +164,20 @@ export function validateRecommendRequest(body: unknown, lang: string = 'en'): Va
}
}
// Validate cdn_enabled if provided
if (req.cdn_enabled !== undefined && typeof req.cdn_enabled !== 'boolean') {
invalidFields.push({ field: 'cdn_enabled', reason: 'must be a boolean' });
}
// Validate cdn_cache_hit_rate if provided
if (req.cdn_cache_hit_rate !== undefined) {
if (typeof req.cdn_cache_hit_rate !== 'number') {
invalidFields.push({ field: 'cdn_cache_hit_rate', reason: 'must be a number' });
} else if (req.cdn_cache_hit_rate < 0 || req.cdn_cache_hit_rate > 1) {
invalidFields.push({ field: 'cdn_cache_hit_rate', reason: 'must be between 0.0 and 1.0' });
}
}
// Return error if any issues found
if (missingFields.length > 0 || invalidFields.length > 0) {
return {