Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
219 changes: 219 additions & 0 deletions src/durable-objects/chainhooks-do.ts
Original file line number Diff line number Diff line change
@@ -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<Env> {
// 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<Response> {
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<any> {
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<any> {
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<any> {
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<string, any> = {};

// 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;
}
}
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions tests/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +32,7 @@ test_index
#test_supabase (deprecated)
#test_bns (deprecated)
test_contract_calls
test_chainhooks

echo "===================="
echo "Test Summary"
Expand Down
95 changes: 95 additions & 0 deletions tests/test_chainhooks.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<import('./src/index').BnsApiDO>;
HIRO_API_DO: DurableObjectNamespace<import('./src/index').HiroApiDO>;
STX_CITY_DO: DurableObjectNamespace<import('./src/index').StxCityDO>;
SUPABASE_DO: DurableObjectNamespace<import('./src/index').SupabaseDO>;
CONTRACT_CALLS_DO: DurableObjectNamespace<import('./src/index').ContractCallsDO>;
CHAINHOOKS_DO: DurableObjectNamespace<import('./src/index').ChainhooksDO>;
}
Loading