diff --git a/src/config.ts b/src/config.ts index 051f41e..be70a3f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,7 +50,7 @@ export class AppConfig { return { // supported services for API caching // each entry is a durable object that handles requests - SUPPORTED_SERVICES: ['/bns', '/hiro-api', '/stx-city', '/supabase', '/contract-calls'], + SUPPORTED_SERVICES: ['/bns', '/hiro-api', '/stx-city', '/supabase', '/contract-calls', '/chainhooks'], // VALUES BELOW CAN BE OVERRIDDEN BY DURABLE OBJECTS // default cache TTL used for KV CACHE_TTL: 900, // 15 minutes diff --git a/src/durable-objects/chainhooks-do.ts b/src/durable-objects/chainhooks-do.ts new file mode 100644 index 0000000..738cd00 --- /dev/null +++ b/src/durable-objects/chainhooks-do.ts @@ -0,0 +1,219 @@ +import { DurableObject } from 'cloudflare:workers'; +import { Env } from '../../worker-configuration'; +import { AppConfig } from '../config'; +import { handleRequest } from '../utils/request-handler-util'; +import { ApiError } from '../utils/api-error-util'; +import { ErrorCode } from '../utils/error-catalog-util'; +import { Logger } from '../utils/logger-util'; + +export class ChainhooksDO extends DurableObject { + // Configuration constants + private readonly BASE_PATH: string = '/chainhooks'; + private readonly CACHE_PREFIX: string = this.BASE_PATH.replaceAll('/', ''); + private readonly SUPPORTED_ENDPOINTS: string[] = ['/post-event', '/events', '/events/:id']; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ctx = ctx; + this.env = env; + + // Initialize AppConfig with environment + const config = AppConfig.getInstance(env).getConfig(); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + + return handleRequest( + async () => { + if (!path.startsWith(this.BASE_PATH)) { + throw new ApiError(ErrorCode.NOT_FOUND, { resource: path }); + } + + // Remove base path to get the endpoint + const endpoint = path.replace(this.BASE_PATH, ''); + + // Handle root path + if (endpoint === '' || endpoint === '/') { + return { + message: `Supported endpoints: ${this.SUPPORTED_ENDPOINTS.join(', ')}`, + }; + } + + // Handle post-event endpoint + if (endpoint === '/post-event') { + if (method !== 'POST') { + throw new ApiError(ErrorCode.INVALID_REQUEST, { + reason: `Method ${method} not allowed for this endpoint. Use POST.`, + }); + } + + // Check authentication + if (!this.validateAuthToken(request)) { + throw new ApiError(ErrorCode.UNAUTHORIZED, { + reason: 'Invalid or missing authentication token', + }); + } + + return await this.handlePostEvent(request); + } + + // Handle get all events endpoint + if (endpoint === '/events') { + if (method !== 'GET') { + throw new ApiError(ErrorCode.INVALID_REQUEST, { + reason: `Method ${method} not allowed for this endpoint. Use GET.`, + }); + } + + return await this.handleGetAllEvents(); + } + + // Handle get specific event endpoint + if (endpoint.startsWith('/events/')) { + if (method !== 'GET') { + throw new ApiError(ErrorCode.INVALID_REQUEST, { + reason: `Method ${method} not allowed for this endpoint. Use GET.`, + }); + } + + const eventId = endpoint.replace('/events/', ''); + return await this.handleGetEvent(eventId); + } + + // If we get here, the endpoint is not supported + throw new ApiError(ErrorCode.NOT_FOUND, { + resource: endpoint, + supportedEndpoints: this.SUPPORTED_ENDPOINTS, + }); + }, + this.env, + { + path, + method, + } + ); + } + + private async handlePostEvent(request: Request): Promise { + const logger = Logger.getInstance(this.env); + + try { + // Clone the request to read the body + const clonedRequest = request.clone(); + + // Try to parse as JSON first + let body; + try { + body = await clonedRequest.json(); + } catch (e) { + // If JSON parsing fails, get the body as text + body = await request.text(); + } + + // Log the received event + logger.info('Received chainhook event', { + body, + headers: Object.fromEntries(request.headers.entries()), + }); + + // Store the event in Durable Object storage for later analysis + const eventId = crypto.randomUUID(); + await this.ctx.storage.put(`event_${eventId}`, { + timestamp: new Date().toISOString(), + body, + headers: Object.fromEntries(request.headers.entries()), + }); + + return { + message: 'Event received and logged successfully', + eventId, + }; + } catch (error) { + logger.error('Error processing chainhook event', error instanceof Error ? error : new Error(String(error))); + throw new ApiError(ErrorCode.INTERNAL_ERROR, { + reason: 'Failed to process chainhook event', + }); + } + } + + private async handleGetEvent(eventId: string): Promise { + const logger = Logger.getInstance(this.env); + + try { + // Retrieve the event from storage + const event = await this.ctx.storage.get(`event_${eventId}`); + + if (!event) { + throw new ApiError(ErrorCode.NOT_FOUND, { + resource: `Event with ID ${eventId}`, + }); + } + + return { + event, + }; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + logger.error(`Error retrieving event ${eventId}`, error instanceof Error ? error : new Error(String(error))); + throw new ApiError(ErrorCode.INTERNAL_ERROR, { + reason: `Failed to retrieve event ${eventId}`, + }); + } + } + + private async handleGetAllEvents(): Promise { + const logger = Logger.getInstance(this.env); + + try { + // Get all keys that start with "event_" + const eventKeys = await this.ctx.storage.list({ prefix: 'event_' }); + + // Create an array to hold all events + const events: Record = {}; + + // Retrieve each event and add it to the array + for (const [key, value] of eventKeys) { + const eventId = key.replace('event_', ''); + events[eventId] = value; + } + + return { + events, + count: Object.keys(events).length, + }; + } catch (error) { + logger.error('Error retrieving all events', error instanceof Error ? error : new Error(String(error))); + throw new ApiError(ErrorCode.INTERNAL_ERROR, { + reason: 'Failed to retrieve events', + }); + } + } + + /** + * Validates the authentication token from the request + * + * @param request - The incoming request + * @returns boolean indicating if the token is valid + */ + private validateAuthToken(request: Request): boolean { + // Extract the Authorization header + const authHeader = request.headers.get('Authorization'); + + // Check if the header exists and has the correct format + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + // Extract the token + const token = authHeader.replace('Bearer ', ''); + + // Compare with the stored token + return token === this.env.CHAINHOOKS_AUTH_TOKEN; + } +} diff --git a/src/index.ts b/src/index.ts index 676e1fa..b3ef707 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,13 +5,14 @@ import { HiroApiDO } from './durable-objects/hiro-api-do'; import { StxCityDO } from './durable-objects/stx-city-do'; import { SupabaseDO } from './durable-objects/supabase-do'; import { ContractCallsDO } from './durable-objects/contract-calls-do'; +import { ChainhooksDO } from './durable-objects/chainhooks-do'; import { corsHeaders, createErrorResponse, createSuccessResponse } from './utils/requests-responses-util'; import { ApiError } from './utils/api-error-util'; import { ErrorCode } from './utils/error-catalog-util'; import { Logger } from './utils/logger-util'; // export the Durable Object classes we're using -export { BnsApiDO, HiroApiDO, StxCityDO, SupabaseDO, ContractCallsDO }; +export { BnsApiDO, HiroApiDO, StxCityDO, SupabaseDO, ContractCallsDO, ChainhooksDO }; export default { /** @@ -90,6 +91,12 @@ export default { let stub = env.CONTRACT_CALLS_DO.get(id); // get the stub for communication return await stub.fetch(request); // forward the request to the Durable Object } + + if (path.startsWith('/chainhooks')) { + let id: DurableObjectId = env.CHAINHOOKS_DO.idFromName('chainhooks-do'); // create the instance + let stub = env.CHAINHOOKS_DO.get(id); // get the stub for communication + return await stub.fetch(request); // forward the request to the Durable Object + } } catch (error) { // Log errors from Durable Objects const duration = Date.now() - startTime; diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 3e821ff..2e6ad79 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -15,6 +15,7 @@ source "$SCRIPT_DIR/test_stx_city.sh" source "$SCRIPT_DIR/test_supabase.sh" source "$SCRIPT_DIR/test_bns.sh" source "$SCRIPT_DIR/test_contract_calls.sh" +source "$SCRIPT_DIR/test_chainhooks.sh" # If sleep flag is true, wait 10 seconds before starting tests if [ "$SLEEP_BEFORE_START" = true ]; then @@ -31,6 +32,7 @@ test_index #test_supabase (deprecated) #test_bns (deprecated) test_contract_calls +test_chainhooks echo "====================" echo "Test Summary" diff --git a/tests/test_chainhooks.sh b/tests/test_chainhooks.sh new file mode 100644 index 0000000..3879c32 --- /dev/null +++ b/tests/test_chainhooks.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# Set default API URL from argument if provided +export API_URL=${1:-"http://localhost:8787"} + +source "$(dirname "$0")/utils.sh" + +test_chainhooks() { + echo "====================" + echo "ChainhooksDO Tests" + echo "====================" + + # Test base endpoint + test_cors "/chainhooks" "Base endpoint CORS" + test_endpoint "/chainhooks" 200 "Base endpoint" + + # Test events endpoint (GET all events) + test_cors "/chainhooks/events" "Events endpoint CORS" + test_endpoint "/chainhooks/events" 200 "Get all events" + + # Test post-event endpoint without auth (should fail with 401) + echo "Testing post-event without auth (should fail)..." + local post_url="${API_URL}/chainhooks/post-event" + local payload='{"test":"data"}' + + # Test CORS for post-event endpoint + test_cors "/chainhooks/post-event" "Post event CORS" + + # Test unauthorized post (should return 401) + local unauth_response=$(curl -s -X POST -H "Content-Type: application/json" -d "$payload" "$post_url") + local unauth_status=$(echo "$unauth_response" | jq -r '.success // false') + + if [ "$unauth_status" == "false" ]; then + echo -e "${GREEN}✓${NC} Unauthorized post correctly rejected" + ((TOTAL_TESTS++)) + else + echo -e "${RED}✗${NC} Unauthorized post should have been rejected: $unauth_response" + ((TOTAL_TESTS++)) + ((FAILED_TESTS++)) + fi + + # Test post-event with auth token (if available in environment) + if [ -n "$CHAINHOOKS_AUTH_TOKEN" ]; then + echo "Testing post-event with auth..." + local auth_response=$(curl -s -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $CHAINHOOKS_AUTH_TOKEN" -d "$payload" "$post_url") + local auth_status=$(echo "$auth_response" | jq -r '.success // false') + local event_id=$(echo "$auth_response" | jq -r '.data.eventId // ""') + + if [ "$auth_status" == "true" ] && [ -n "$event_id" ]; then + echo -e "${GREEN}✓${NC} Authorized post successful" + ((TOTAL_TESTS++)) + + # Now test retrieving the specific event we just created + echo "Testing get specific event..." + test_endpoint "/chainhooks/events/$event_id" 200 "Get specific event" + else + echo -e "${RED}✗${NC} Authorized post failed: $auth_response" + ((TOTAL_TESTS++)) + ((FAILED_TESTS++)) + fi + else + echo "Skipping authorized post test (CHAINHOOKS_AUTH_TOKEN not set)" + fi + + # Test invalid event ID + test_endpoint "/chainhooks/events/invalid-id" 404 "Invalid event ID" + + # Test invalid endpoints + test_cors "/chainhooks/invalid" "Invalid endpoint CORS" + test_endpoint "/chainhooks/invalid" 404 "Invalid endpoint" +} + +# Allow running just this test file +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + export FAILED_TESTS=0 + export TOTAL_TESTS=0 + + echo -e "\nTesting Chainhooks API at: $API_URL" + test_chainhooks + + echo "====================" + echo "Test Summary" + echo "====================" + echo "Passed tests: $((TOTAL_TESTS - FAILED_TESTS))" + echo "Failed tests: $FAILED_TESTS" + echo "Total tests: $TOTAL_TESTS" + + if [ $FAILED_TESTS -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 + else + echo -e "${RED}Some tests failed!${NC}" + exit 1 + fi +fi diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 2163063..f7637ee 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -5,9 +5,11 @@ export interface Env { HIRO_API_KEY: string; SUPABASE_URL: string; SUPABASE_SERVICE_KEY: string; + CHAINHOOKS_AUTH_TOKEN: string; // Auth token for chainhooks POST endpoint BNS_API_DO: DurableObjectNamespace; HIRO_API_DO: DurableObjectNamespace; STX_CITY_DO: DurableObjectNamespace; SUPABASE_DO: DurableObjectNamespace; CONTRACT_CALLS_DO: DurableObjectNamespace; + CHAINHOOKS_DO: DurableObjectNamespace; } diff --git a/wrangler.toml b/wrangler.toml index f0987cb..f5fc2f0 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,6 +17,10 @@ new_classes = ["HiroApiDO", "SupabaseDO", "StxCityDO", "BnsApiDO"] tag = "20250323" new_classes = ["ContractCallsDO"] +[[migrations]] +tag = "20250417" +new_classes = ["ChainhooksDO"] + [env.preview] @@ -46,6 +50,10 @@ class_name = "BnsApiDO" name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" +[[env.preview.durable_objects.bindings]] +name = "CHAINHOOKS_DO" +class_name = "ChainhooksDO" + [env.staging] @@ -75,6 +83,10 @@ class_name = "BnsApiDO" name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" +[[env.staging.durable_objects.bindings]] +name = "CHAINHOOKS_DO" +class_name = "ChainhooksDO" + [env.production] @@ -103,3 +115,7 @@ class_name = "BnsApiDO" [[env.production.durable_objects.bindings]] name = "CONTRACT_CALLS_DO" class_name = "ContractCallsDO" + +[[env.production.durable_objects.bindings]] +name = "CHAINHOOKS_DO" +class_name = "ChainhooksDO"