- Add Hono as HTTP framework for Cloudflare Workers - Create app.ts with declarative routing and middleware - Add hono-adapters.ts for auth, rate limit, request ID middleware - Refactor handlers to use Hono Context signature - Maintain all existing business logic unchanged - Keep scheduled handler separate for cron jobs Test: 175/176 tests pass (1 pre-existing failure) Build: wrangler deploy --dry-run succeeds Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
192 lines
5.0 KiB
TypeScript
192 lines
5.0 KiB
TypeScript
/**
|
|
* Hono Application Setup
|
|
*
|
|
* Configures Hono app with CORS, security headers, and routes.
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import type { Context } from 'hono';
|
|
import type { Env } from './types';
|
|
import { CORS, HTTP_STATUS } from './constants';
|
|
import { createLogger } from './utils/logger';
|
|
import {
|
|
requestIdMiddleware,
|
|
authMiddleware,
|
|
rateLimitMiddleware,
|
|
optionalAuthMiddleware,
|
|
} from './middleware/hono-adapters';
|
|
import { handleHealth } from './routes/health';
|
|
import { handleInstances } from './routes/instances';
|
|
import { handleSync } from './routes/sync';
|
|
|
|
const logger = createLogger('[App]');
|
|
|
|
// Context variables type
|
|
type Variables = {
|
|
requestId: string;
|
|
authenticated?: boolean;
|
|
};
|
|
|
|
// Create Hono app with type-safe bindings
|
|
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
|
|
/**
|
|
* Get CORS origin for request
|
|
* Reused from original index.ts logic
|
|
*/
|
|
function getCorsOrigin(c: Context<{ Bindings: Env; Variables: Variables }>): string {
|
|
const origin = c.req.header('Origin');
|
|
const env = c.env;
|
|
|
|
// Environment variable has explicit origin configured (highest priority)
|
|
if (env.CORS_ORIGIN && env.CORS_ORIGIN !== '*') {
|
|
return env.CORS_ORIGIN;
|
|
}
|
|
|
|
// Build allowed origins list based on environment
|
|
const isDevelopment = env.ENVIRONMENT === 'development';
|
|
const allowedOrigins = isDevelopment
|
|
? [...CORS.ALLOWED_ORIGINS, ...CORS.DEVELOPMENT_ORIGINS]
|
|
: CORS.ALLOWED_ORIGINS;
|
|
|
|
// Request origin is in allowed list
|
|
if (origin && allowedOrigins.includes(origin)) {
|
|
return origin;
|
|
}
|
|
|
|
// Log unmatched origins for security monitoring
|
|
if (origin && !allowedOrigins.includes(origin)) {
|
|
const sanitizedOrigin = origin.replace(/[\r\n\t]/g, '').substring(0, 256);
|
|
logger.warn('Unmatched origin - using default', {
|
|
requested_origin: sanitizedOrigin,
|
|
environment: env.ENVIRONMENT || 'production',
|
|
default_origin: CORS.DEFAULT_ORIGIN,
|
|
});
|
|
}
|
|
|
|
// Return explicit default (no wildcard)
|
|
return CORS.DEFAULT_ORIGIN;
|
|
}
|
|
|
|
/**
|
|
* CORS middleware
|
|
* Configured dynamically based on request origin
|
|
*/
|
|
app.use('*', async (c, next) => {
|
|
// Handle OPTIONS preflight - must come before await next()
|
|
if (c.req.method === 'OPTIONS') {
|
|
const origin = getCorsOrigin(c);
|
|
c.res.headers.set('Access-Control-Allow-Origin', origin);
|
|
c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
c.res.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');
|
|
c.res.headers.set('Access-Control-Max-Age', CORS.MAX_AGE);
|
|
return c.body(null, 204);
|
|
}
|
|
|
|
await next();
|
|
|
|
// Set CORS headers after processing
|
|
const origin = getCorsOrigin(c);
|
|
c.res.headers.set('Access-Control-Allow-Origin', origin);
|
|
c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
c.res.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');
|
|
c.res.headers.set('Access-Control-Max-Age', CORS.MAX_AGE);
|
|
c.res.headers.set(
|
|
'Access-Control-Expose-Headers',
|
|
'X-RateLimit-Retry-After, Retry-After, X-Request-ID'
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Request ID middleware
|
|
* Adds unique request ID for tracing
|
|
*/
|
|
app.use('*', requestIdMiddleware);
|
|
|
|
/**
|
|
* Security headers middleware
|
|
* Applied to all responses
|
|
*/
|
|
app.use('*', async (c, next) => {
|
|
await next();
|
|
|
|
// Add security headers to response
|
|
c.res.headers.set('X-Content-Type-Options', 'nosniff');
|
|
c.res.headers.set('X-Frame-Options', 'DENY');
|
|
c.res.headers.set('Strict-Transport-Security', 'max-age=31536000');
|
|
c.res.headers.set('Content-Security-Policy', "default-src 'none'");
|
|
c.res.headers.set('X-XSS-Protection', '1; mode=block');
|
|
c.res.headers.set('Referrer-Policy', 'no-referrer');
|
|
});
|
|
|
|
/**
|
|
* Environment validation middleware
|
|
* Checks required environment variables before processing
|
|
*/
|
|
app.use('*', async (c, next) => {
|
|
const required = ['API_KEY'];
|
|
const missing = required.filter((key) => !c.env[key as keyof Env]);
|
|
|
|
if (missing.length > 0) {
|
|
logger.error('Missing required environment variables', {
|
|
missing,
|
|
request_id: c.get('requestId'),
|
|
});
|
|
|
|
return c.json(
|
|
{
|
|
error: 'Service Unavailable',
|
|
message: 'Service configuration error',
|
|
},
|
|
503
|
|
);
|
|
}
|
|
|
|
return next();
|
|
});
|
|
|
|
/**
|
|
* Routes
|
|
*/
|
|
|
|
// Health check (public endpoint with optional authentication)
|
|
app.get('/health', optionalAuthMiddleware, handleHealth);
|
|
|
|
// Query instances (authenticated, rate limited)
|
|
app.get('/instances', authMiddleware, rateLimitMiddleware, handleInstances);
|
|
|
|
// Sync trigger (authenticated, rate limited)
|
|
app.post('/sync', authMiddleware, rateLimitMiddleware, handleSync);
|
|
|
|
/**
|
|
* 404 handler
|
|
*/
|
|
app.notFound((c) => {
|
|
return c.json(
|
|
{
|
|
error: 'Not Found',
|
|
path: c.req.path,
|
|
},
|
|
HTTP_STATUS.NOT_FOUND
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Global error handler
|
|
*/
|
|
app.onError((err, c) => {
|
|
logger.error('Request error', {
|
|
error: err,
|
|
request_id: c.get('requestId'),
|
|
});
|
|
|
|
return c.json(
|
|
{
|
|
error: 'Internal Server Error',
|
|
},
|
|
HTTP_STATUS.INTERNAL_ERROR
|
|
);
|
|
});
|
|
|
|
export default app;
|