2026-04-24 18:39:16 +00:00
//! Gateway HTTP handlers — thin transport shells for the gateway service.
//!
//! Each handler calls `service::gateway::*` for business logic and formats
//! the response. No inline business logic, no `reqwest`, no filesystem access.
use crate ::service ::gateway ::{ self , GatewayState } ;
2026-04-28 00:17:44 +00:00
use futures ::StreamExt ;
2026-04-24 18:39:16 +00:00
use poem ::handler ;
use poem ::http ::StatusCode ;
use poem ::web ::Path as PoemPath ;
2026-04-28 00:17:44 +00:00
use poem ::web ::Query ;
use poem ::web ::websocket ::{ Message as WsMessage , WebSocket } ;
2026-04-24 18:39:16 +00:00
use poem ::web ::{ Data , Json } ;
use poem ::{ Body , Request , Response } ;
use serde ::{ Deserialize , Serialize } ;
use serde_json ::{ Value , json } ;
use std ::collections ::BTreeMap ;
use std ::sync ::Arc ;
// ── JSON-RPC types ──────────────────────────────────────────────────────────
/// JSON-RPC request.
#[ derive(Deserialize) ]
struct JsonRpcRequest {
jsonrpc : String ,
id : Option < Value > ,
method : String ,
#[ serde(default) ]
params : Value ,
}
/// JSON-RPC response.
#[ derive(Serialize) ]
pub ( crate ) struct JsonRpcResponse {
jsonrpc : & 'static str ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub ( crate ) id : Option < Value > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub ( crate ) result : Option < Value > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub ( crate ) error : Option < JsonRpcError > ,
}
#[ derive(Debug, Serialize) ]
pub ( crate ) struct JsonRpcError {
code : i64 ,
pub ( crate ) message : String ,
}
impl JsonRpcResponse {
pub ( crate ) fn success ( id : Option < Value > , result : Value ) -> Self {
Self {
jsonrpc : " 2.0 " ,
id ,
result : Some ( result ) ,
error : None ,
}
}
pub ( crate ) fn error ( id : Option < Value > , code : i64 , message : String ) -> Self {
Self {
jsonrpc : " 2.0 " ,
id ,
result : None ,
error : Some ( JsonRpcError { code , message } ) ,
}
}
}
fn to_json_response ( resp : JsonRpcResponse ) -> Response {
let body = serde_json ::to_vec ( & resp ) . unwrap_or_default ( ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( body ) )
}
// ── MCP tool definitions ─────────────────────────────────────────� � � ──────────
/// Gateway-specific MCP tools exposed alongside the proxied tools.
const GATEWAY_TOOLS : & [ & str ] = & [
" switch_project " ,
" gateway_status " ,
" gateway_health " ,
" init_project " ,
" aggregate_pipeline_status " ,
] ;
/// Gateway tool definitions.
pub ( crate ) fn gateway_tool_definitions ( ) -> Vec < Value > {
vec! [
json! ( {
" name " : " switch_project " ,
" description " : " Switch the active project. All subsequent MCP tool calls will be proxied to this project's container. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : {
" project " : {
" type " : " string " ,
" description " : " Name of the project to switch to (must exist in projects.toml) "
}
} ,
" required " : [ " project " ]
}
} ) ,
json! ( {
" name " : " gateway_status " ,
" description " : " Show pipeline status for the active project by proxying the get_pipeline_status tool call. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
json! ( {
" name " : " gateway_health " ,
" description " : " Health check aggregation across all registered projects. Returns the health status of every project container. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
json! ( {
" name " : " init_project " ,
" description " : " Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init <path>`. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : {
" path " : {
" type " : " string " ,
" description " : " Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist. "
} ,
" name " : {
" type " : " string " ,
" description " : " Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`. "
} ,
" url " : {
" type " : " string " ,
" description " : " Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given. "
}
} ,
" required " : [ " path " ]
}
} ) ,
json! ( {
" name " : " aggregate_pipeline_status " ,
" description " : " Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
]
}
// ── MCP POST handler ────────────────────────────────────────────────────────
/// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and
/// proxies everything else to the active project's container.
#[ handler ]
pub async fn gateway_mcp_post_handler (
req : & Request ,
body : Body ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
let content_type = req . header ( " content-type " ) . unwrap_or ( " " ) ;
if ! content_type . is_empty ( ) & & ! content_type . contains ( " application/json " ) {
return to_json_response ( JsonRpcResponse ::error (
None ,
- 32700 ,
" Unsupported Content-Type; expected application/json " . into ( ) ,
) ) ;
}
let bytes = match body . into_bytes ( ) . await {
Ok ( b ) = > b ,
Err ( _ ) = > {
return to_json_response ( JsonRpcResponse ::error ( None , - 32700 , " Parse error " . into ( ) ) ) ;
}
} ;
let rpc : JsonRpcRequest = match serde_json ::from_slice ( & bytes ) {
Ok ( r ) = > r ,
Err ( _ ) = > {
return to_json_response ( JsonRpcResponse ::error ( None , - 32700 , " Parse error " . into ( ) ) ) ;
}
} ;
if rpc . jsonrpc ! = " 2.0 " {
return to_json_response ( JsonRpcResponse ::error (
rpc . id ,
- 32600 ,
" Invalid JSON-RPC version " . into ( ) ,
) ) ;
}
if rpc . id . is_none ( ) | | rpc . id . as_ref ( ) = = Some ( & Value ::Null ) {
if rpc . method . starts_with ( " notifications/ " ) {
return Response ::builder ( )
. status ( StatusCode ::ACCEPTED )
. body ( Body ::empty ( ) ) ;
}
return to_json_response ( JsonRpcResponse ::error ( None , - 32600 , " Missing id " . into ( ) ) ) ;
}
match rpc . method . as_str ( ) {
" initialize " = > to_json_response ( handle_initialize ( rpc . id ) ) ,
" tools/list " = > match handle_tools_list ( & state , rpc . id . clone ( ) ) . await {
Ok ( resp ) = > to_json_response ( resp ) ,
Err ( e ) = > to_json_response ( JsonRpcResponse ::error ( rpc . id , - 32603 , e ) ) ,
} ,
" tools/call " = > {
let tool_name = rpc
. params
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
if GATEWAY_TOOLS . contains ( & tool_name ) {
to_json_response (
handle_gateway_tool ( tool_name , & rpc . params , & state , rpc . id . clone ( ) ) . await ,
)
} else {
proxy_and_respond ( & state , & bytes , rpc . id ) . await
}
}
_ = > proxy_and_respond ( & state , & bytes , rpc . id ) . await ,
}
}
/// Proxy a request to the active project and format the response.
async fn proxy_and_respond ( state : & GatewayState , bytes : & [ u8 ] , id : Option < Value > ) -> Response {
let url = match state . active_url ( ) . await {
Ok ( u ) = > u ,
Err ( e ) = > {
return to_json_response ( JsonRpcResponse ::error ( id , - 32603 , e . to_string ( ) ) ) ;
}
} ;
match gateway ::io ::proxy_mcp_call ( & state . client , & url , bytes ) . await {
Ok ( resp_body ) = > Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( resp_body ) ) ,
Err ( e ) = > to_json_response ( JsonRpcResponse ::error (
id ,
- 32603 ,
format! ( " proxy error: {e} " ) ,
) ) ,
}
}
/// GET handler — method not allowed.
#[ handler ]
pub async fn gateway_mcp_get_handler ( ) -> Response {
Response ::builder ( )
. status ( StatusCode ::METHOD_NOT_ALLOWED )
. body ( Body ::empty ( ) )
}
// ── Protocol handlers ───────────────────────────────────────────────────────
fn handle_initialize ( id : Option < Value > ) -> JsonRpcResponse {
JsonRpcResponse ::success (
id ,
json! ( {
" protocolVersion " : " 2025-03-26 " ,
" capabilities " : { " tools " : { } } ,
" serverInfo " : {
" name " : " huskies-gateway " ,
" version " : " 1.0.0 "
}
} ) ,
)
}
/// Fetch tools/list from the active project and merge in gateway tools.
async fn handle_tools_list (
state : & GatewayState ,
id : Option < Value > ,
) -> Result < JsonRpcResponse , String > {
let url = state . active_url ( ) . await . map_err ( | e | e . to_string ( ) ) ? ;
let resp_json = gateway ::io ::fetch_tools_list ( & state . client , & url ) . await ? ;
let mut tools : Vec < Value > = resp_json
. get ( " result " )
. and_then ( | r | r . get ( " tools " ) )
. and_then ( | t | t . as_array ( ) )
. cloned ( )
. unwrap_or_default ( ) ;
let mut all_tools = gateway_tool_definitions ( ) ;
all_tools . append ( & mut tools ) ;
Ok ( JsonRpcResponse ::success ( id , json! ( { " tools " : all_tools } ) ) )
}
// ── Gateway tool dispatch ──────────────────────────────────────────────� � ────
/// Dispatch a gateway-specific tool call.
async fn handle_gateway_tool (
tool_name : & str ,
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
match tool_name {
" switch_project " = > handle_switch_project_tool ( params , state , id ) . await ,
" gateway_status " = > handle_gateway_status_tool ( state , id ) . await ,
" gateway_health " = > handle_gateway_health_tool ( state , id ) . await ,
" init_project " = > handle_init_project_tool ( params , state , id ) . await ,
" aggregate_pipeline_status " = > handle_aggregate_pipeline_status_tool ( state , id ) . await ,
_ = > JsonRpcResponse ::error ( id , - 32601 , format! ( " Unknown gateway tool: {tool_name} " ) ) ,
}
}
async fn handle_switch_project_tool (
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
let project = params
. get ( " arguments " )
. and_then ( | a | a . get ( " project " ) )
. or_else ( | | params . get ( " project " ) )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
match gateway ::switch_project ( state , project ) . await {
Ok ( url ) = > JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! ( " Switched to project '{project}' ({url}) " )
} ]
} ) ,
) ,
Err ( e ) = > JsonRpcResponse ::error ( id , - 32602 , e . to_string ( ) ) ,
}
}
async fn handle_gateway_status_tool ( state : & GatewayState , id : Option < Value > ) -> JsonRpcResponse {
let active = state . active_project . read ( ) . await . clone ( ) ;
let url = match state . active_url ( ) . await {
Ok ( u ) = > u ,
Err ( e ) = > return JsonRpcResponse ::error ( id . clone ( ) , - 32603 , e . to_string ( ) ) ,
} ;
match gateway ::io ::fetch_pipeline_status_for_project ( & state . client , & url ) . await {
Ok ( upstream ) = > {
let pipeline = upstream . get ( " result " ) . cloned ( ) . unwrap_or ( json! ( null ) ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! (
" Pipeline status for '{active}': \n {} " ,
serde_json ::to_string_pretty ( & pipeline ) . unwrap_or_default ( )
)
} ]
} ) ,
)
}
Err ( e ) = > JsonRpcResponse ::error ( id , - 32603 , e ) ,
}
}
async fn handle_gateway_health_tool ( state : & GatewayState , id : Option < Value > ) -> JsonRpcResponse {
let mut results = BTreeMap ::new ( ) ;
let project_entries : Vec < ( String , String ) > = state
. projects
. read ( )
. await
. iter ( )
. map ( | ( n , e ) | ( n . clone ( ) , e . url . clone ( ) ) )
. collect ( ) ;
for ( name , url ) in & project_entries {
let status = match gateway ::io ::check_project_health ( & state . client , url ) . await {
Ok ( true ) = > " healthy " . to_string ( ) ,
Ok ( false ) = > " unhealthy " . to_string ( ) ,
Err ( e ) = > e ,
} ;
results . insert ( name . clone ( ) , status ) ;
}
let active = state . active_project . read ( ) . await . clone ( ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! (
" Health check (active: '{active}'): \n {} " ,
results . iter ( )
. map ( | ( name , status ) | format! ( " {name} : {status} " ) )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
)
} ]
} ) ,
)
}
async fn handle_init_project_tool (
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
let args = params . get ( " arguments " ) . unwrap_or ( params ) ;
let path_str = args . get ( " path " ) . and_then ( | v | v . as_str ( ) ) . unwrap_or ( " " ) ;
let name = args . get ( " name " ) . and_then ( | v | v . as_str ( ) ) ;
let url = args . get ( " url " ) . and_then ( | v | v . as_str ( ) ) ;
match gateway ::init_project ( state , path_str , name , url ) . await {
Ok ( registered_name ) = > {
let next_steps = if let Some ( ref n ) = registered_name {
format! (
" Project registered as ' {n} ' in projects.toml. \n \
Next steps: \n \
1. Start a huskies server at ' {path_str} ' \
(e.g. `huskies {path_str} ` or via Docker). \n \
2. Call switch_project with name=' {n} ' to make it active. \n \
3. Call wizard_status to begin the setup wizard. "
)
} else {
format! (
" Next steps: \n \
1. Start a huskies server at ' {path_str} ' \
(e.g. `huskies {path_str} ` or via Docker). \n \
2. Register the project: call init_project again with name and url \
parameters, or add it to projects.toml manually. \n \
3. Call switch_project and then wizard_status to begin the setup wizard. \n \n \
Note: wizard_* MCP tools require a running huskies server for the project. "
)
} ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! ( " Successfully initialised huskies project at '{path_str}'. \n \n {next_steps} " )
} ]
} ) ,
)
}
Err ( e ) = > {
let code = match & e {
gateway ::Error ::Config ( _ ) = > - 32602 ,
gateway ::Error ::DuplicateToken ( _ ) = > - 32602 ,
_ = > - 32603 ,
} ;
JsonRpcResponse ::error ( id , code , e . to_string ( ) )
}
}
}
async fn handle_aggregate_pipeline_status_tool (
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
let project_urls : BTreeMap < String , String > = state
. projects
. read ( )
. await
. iter ( )
. map ( | ( name , entry ) | ( name . clone ( ) , entry . url . clone ( ) ) )
. collect ( ) ;
let statuses =
gateway ::io ::fetch_all_project_pipeline_statuses ( & project_urls , & state . client ) . await ;
let active = state . active_project . read ( ) . await . clone ( ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! (
" Aggregate pipeline status (active: '{active}'): \n {} " ,
serde_json ::to_string_pretty ( & statuses ) . unwrap_or_default ( )
)
} ] ,
" projects " : statuses ,
" active " : active ,
} ) ,
)
}
// ── Agent REST handlers ─────────────────────────────────────────────────────
/// `GET /gateway/mode` — returns `{"mode":"gateway"}`.
#[ handler ]
pub async fn gateway_mode_handler ( ) -> Response {
let body = json! ( { " mode " : " gateway " } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & body ) . unwrap_or_default ( ) ) )
}
/// `POST /gateway/tokens` — generate a one-time join token.
#[ handler ]
pub async fn gateway_generate_token_handler ( state : Data < & Arc < GatewayState > > ) -> Response {
let token = gateway ::generate_join_token ( & state ) . await ;
let body = json! ( { " token " : token } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & body ) . unwrap_or_default ( ) ) )
}
/// Request body sent by a build agent when registering with the gateway.
#[ derive(Deserialize) ]
struct RegisterAgentRequest {
token : String ,
label : String ,
address : String ,
}
/// `POST /gateway/register` — build agent presents its join token and registers.
#[ handler ]
pub async fn gateway_register_agent_handler (
body : Body ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
let bytes = match body . into_bytes ( ) . await {
Ok ( b ) = > b ,
Err ( _ ) = > {
return Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. body ( Body ::from ( " could not read request body " ) ) ;
}
} ;
let req : RegisterAgentRequest = match serde_json ::from_slice ( & bytes ) {
Ok ( r ) = > r ,
Err ( _ ) = > {
return Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. body ( Body ::from ( " invalid JSON body " ) ) ;
}
} ;
match gateway ::register_agent ( & state , & req . token , req . label , req . address ) . await {
Ok ( agent ) = > {
let body = serde_json ::to_vec ( & agent ) . unwrap_or_default ( ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( body ) )
}
Err ( _ ) = > Response ::builder ( )
. status ( StatusCode ::UNAUTHORIZED )
. body ( Body ::from ( " invalid or already-used join token " ) ) ,
}
}
/// `GET /gateway/agents` — list all registered build agents.
#[ handler ]
pub async fn gateway_list_agents_handler ( state : Data < & Arc < GatewayState > > ) -> Response {
let agents = state . joined_agents . read ( ) . await . clone ( ) ;
let body = serde_json ::to_vec ( & agents ) . unwrap_or_default ( ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( body ) )
}
/// `DELETE /gateway/agents/:id` — remove a registered build agent.
#[ handler ]
pub async fn gateway_remove_agent_handler (
PoemPath ( id ) : PoemPath < String > ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
if gateway ::remove_agent ( & state , & id ) . await {
Response ::builder ( )
. status ( StatusCode ::NO_CONTENT )
. body ( Body ::empty ( ) )
} else {
Response ::builder ( )
. status ( StatusCode ::NOT_FOUND )
. body ( Body ::from ( " agent not found " ) )
}
}
/// Request body for assigning an agent to a project.
#[ derive(Deserialize) ]
struct AssignAgentRequest {
project : Option < String > ,
}
/// `POST /gateway/agents/:id/assign` — assign or unassign an agent to a project.
#[ handler ]
pub async fn gateway_assign_agent_handler (
PoemPath ( id ) : PoemPath < String > ,
body : Json < AssignAgentRequest > ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
match gateway ::assign_agent ( & state , & id , body . 0. project ) . await {
Ok ( agent ) = > {
let body = serde_json ::to_vec ( & agent ) . unwrap_or_default ( ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( body ) )
}
Err ( gateway ::Error ::ProjectNotFound ( msg ) ) = > Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. body ( Body ::from ( msg ) ) ,
Err ( _ ) = > Response ::builder ( )
. status ( StatusCode ::NOT_FOUND )
. body ( Body ::from ( " agent not found " ) ) ,
}
}
/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp.
#[ handler ]
pub async fn gateway_heartbeat_handler (
PoemPath ( id ) : PoemPath < String > ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
if gateway ::heartbeat_agent ( & state , & id ) . await {
Response ::builder ( )
. status ( StatusCode ::NO_CONTENT )
. body ( Body ::empty ( ) )
} else {
Response ::builder ( )
. status ( StatusCode ::NOT_FOUND )
. body ( Body ::from ( " agent not found " ) )
}
}
2026-04-28 00:17:44 +00:00
// ── Event-push WebSocket handler ────────────────────────────────────────────
/// Query parameters accepted on the `/gateway/events/push` WebSocket upgrade.
#[ derive(Deserialize) ]
struct EventPushQueryParams {
/// One-time join token generated by `POST /gateway/tokens`.
token : Option < String > ,
/// The project name this node represents (e.g. `"huskies"`).
project : Option < String > ,
}
/// `GET /gateway/events/push` — WebSocket endpoint for project nodes to push
/// [`StatusEvent`] frames to the gateway.
///
/// # Authentication
///
/// The connecting node must supply a valid one-time join token via the `token`
/// query parameter, obtained from `POST /gateway/tokens`. The token is
/// consumed on the first successful upgrade — the connection itself is then
/// kept open indefinitely.
///
/// # Protocol
///
/// Each message from the project node must be a JSON-encoded
/// [`crate::service::events::StoredEvent`]. The gateway fan-outs the event
/// (tagged with the project name) to all current local subscribers.
///
/// The server does not send data back; clients should treat any close frame
/// as a signal to reconnect with exponential back-off (see docs/gateway-protocol.html).
///
/// # Reconnect-with-backoff
///
/// Project nodes MUST reconnect on disconnect. Recommended policy:
///
/// - Initial retry delay: **1 s**
/// - Back-off multiplier: **2× ** per attempt
/// - Max delay cap: **60 s**
/// - Jitter: add ±10 % to the delay to avoid thundering herds
#[ handler ]
pub async fn gateway_event_push_handler (
ws : WebSocket ,
state : Data < & Arc < GatewayState > > ,
Query ( params ) : Query < EventPushQueryParams > ,
) -> poem ::Response {
// ── Authentication (pre-upgrade) ─────────────────────────────────────
let token = match params . token {
Some ( t ) if ! t . is_empty ( ) = > t ,
_ = > {
return poem ::Response ::builder ( )
. status ( StatusCode ::UNAUTHORIZED )
. body ( " token query parameter required " ) ;
}
} ;
let project = match params . project {
Some ( p ) if ! p . is_empty ( ) = > p ,
_ = > {
return poem ::Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. body ( " project query parameter required " ) ;
}
} ;
// Validate and consume the one-time token.
{
let mut tokens = state . pending_tokens . write ( ) . await ;
if ! tokens . contains_key ( & token ) {
return poem ::Response ::builder ( )
. status ( StatusCode ::UNAUTHORIZED )
. body ( " invalid or already-used join token " ) ;
}
tokens . remove ( & token ) ;
}
// ── WebSocket upgrade ────────────────────────────────────────────────
use poem ::IntoResponse as _ ;
let state = Arc ::clone ( & state ) ;
ws . on_upgrade ( move | socket | async move {
let ( _ , mut stream ) = socket . split ( ) ;
crate ::slog! (
" [gateway] Project node '{}' connected to event-push endpoint " ,
project
) ;
while let Some ( msg ) = stream . next ( ) . await {
let text = match msg {
Ok ( WsMessage ::Text ( t ) ) = > t ,
Ok ( WsMessage ::Close ( _ ) ) | Err ( _ ) = > break ,
_ = > continue ,
} ;
match serde_json ::from_str ::< crate ::service ::events ::StoredEvent > ( & text ) {
Ok ( event ) = > {
gateway ::broadcast_status_event ( & state , project . clone ( ) , event ) ;
}
Err ( e ) = > {
crate ::slog! (
" [gateway] event-push: invalid frame from '{}': {e} " ,
project
) ;
}
}
}
crate ::slog! (
" [gateway] Project node '{}' disconnected from event-push endpoint " ,
project
) ;
} )
. into_response ( )
}
2026-04-24 18:39:16 +00:00
// ── Health handler ──────────────────────────────────────────────────────────
/// HTTP GET `/health` handler for the gateway.
#[ handler ]
pub async fn gateway_health_handler ( state : Data < & Arc < GatewayState > > ) -> Response {
let ( all_healthy , statuses ) = gateway ::health_check_all ( & state ) . await ;
let body = json! ( {
" status " : if all_healthy { " ok " } else { " degraded " } ,
" projects " : statuses ,
} ) ;
let status = if all_healthy {
StatusCode ::OK
} else {
StatusCode ::SERVICE_UNAVAILABLE
} ;
Response ::builder ( )
. status ( status )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & body ) . unwrap_or_default ( ) ) )
}
// ── Gateway Web UI ──────────────────────────────────────────────────────────
/// `GET /api/gateway` — returns the list of registered projects and the active project.
#[ handler ]
pub async fn gateway_api_handler ( state : Data < & Arc < GatewayState > > ) -> Response {
let active = state . active_project . read ( ) . await . clone ( ) ;
let projects : Vec < Value > = state
. projects
. read ( )
. await
. iter ( )
. map ( | ( name , entry ) | {
json! ( {
" name " : name ,
" url " : entry . url ,
} )
} )
. collect ( ) ;
let body = json! ( { " active " : active , " projects " : projects } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & body ) . unwrap_or_default ( ) ) )
}
/// Request body for `POST /api/gateway/switch`.
#[ derive(Deserialize) ]
struct SwitchRequest {
project : String ,
}
/// `POST /api/gateway/switch` — switch the active project.
#[ handler ]
pub async fn gateway_switch_handler (
state : Data < & Arc < GatewayState > > ,
body : Json < SwitchRequest > ,
) -> Response {
match gateway ::switch_project ( & state , & body . project ) . await {
Ok ( _ ) = > {
let body_val = json! ( { " ok " : true } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from (
serde_json ::to_vec ( & body_val ) . unwrap_or_default ( ) ,
) )
}
Err ( e ) = > {
let body_val = json! ( { " ok " : false , " error " : e . to_string ( ) } ) ;
Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from (
serde_json ::to_vec ( & body_val ) . unwrap_or_default ( ) ,
) )
}
}
}
// ── Project management API ──────────────────────────────────────────────────
/// Request body for adding a new project.
#[ derive(Deserialize) ]
struct AddProjectRequest {
name : String ,
url : String ,
}
/// `POST /api/gateway/projects` — add a new project.
#[ handler ]
pub async fn gateway_add_project_handler (
state : Data < & Arc < GatewayState > > ,
body : Json < AddProjectRequest > ,
) -> Response {
match gateway ::add_project ( & state , & body . name , & body . url ) . await {
Ok ( ( ) ) = > {
let name = body . 0. name . trim ( ) . to_string ( ) ;
let url = body . 0. url . trim ( ) . to_string ( ) ;
let body_val = json! ( { " name " : name , " url " : url } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from (
serde_json ::to_vec ( & body_val ) . unwrap_or_default ( ) ,
) )
}
Err ( gateway ::Error ::DuplicateToken ( _ ) ) = > Response ::builder ( )
. status ( StatusCode ::CONFLICT )
. body ( Body ::from ( format! (
" project ' {} ' already exists " ,
body . 0. name . trim ( )
) ) ) ,
Err ( e ) = > Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. body ( Body ::from ( e . to_string ( ) ) ) ,
}
}
/// `DELETE /api/gateway/projects/:name` — remove a project.
#[ handler ]
pub async fn gateway_remove_project_handler (
PoemPath ( name ) : PoemPath < String > ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
match gateway ::remove_project ( & state , & name ) . await {
Ok ( ( ) ) = > Response ::builder ( )
. status ( StatusCode ::NO_CONTENT )
. body ( Body ::empty ( ) ) ,
Err ( gateway ::Error ::ProjectNotFound ( msg ) ) = > Response ::builder ( )
. status ( StatusCode ::NOT_FOUND )
. body ( Body ::from ( msg ) ) ,
Err ( e ) = > Response ::builder ( )
. status ( StatusCode ::BAD_REQUEST )
. body ( Body ::from ( e . to_string ( ) ) ) ,
}
}
// ── Bot configuration API ───────────────────────────────────────────────────
/// Request/response body for the bot configuration API.
#[ derive(Deserialize, Serialize, Default) ]
pub ( crate ) struct BotConfigPayload {
transport : String ,
homeserver : Option < String > ,
username : Option < String > ,
password : Option < String > ,
slack_bot_token : Option < String > ,
slack_signing_secret : Option < String > ,
}
/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON.
#[ handler ]
pub async fn gateway_bot_config_get_handler ( state : Data < & Arc < GatewayState > > ) -> Response {
let fields = gateway ::io ::read_bot_config_raw ( & state . config_dir ) ;
let payload = BotConfigPayload {
transport : fields . transport ,
homeserver : fields . homeserver ,
username : fields . username ,
password : fields . password ,
slack_bot_token : fields . slack_bot_token ,
slack_signing_secret : fields . slack_signing_secret ,
} ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & payload ) . unwrap_or_default ( ) ) )
}
/// `POST /api/gateway/bot-config` — write new bot.toml and restart the bot.
#[ handler ]
pub async fn gateway_bot_config_save_handler (
state : Data < & Arc < GatewayState > > ,
body : Json < BotConfigPayload > ,
) -> Response {
let content = gateway ::config ::serialize_bot_config (
& body . transport ,
body . homeserver . as_deref ( ) ,
body . username . as_deref ( ) ,
body . password . as_deref ( ) ,
body . slack_bot_token . as_deref ( ) ,
body . slack_signing_secret . as_deref ( ) ,
) ;
match gateway ::save_bot_config_and_restart ( & state , & content ) . await {
Ok ( ( ) ) = > {
let ok = json! ( { " ok " : true } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & ok ) . unwrap_or_default ( ) ) )
}
Err ( e ) = > {
let err = json! ( { " ok " : false , " error " : e . to_string ( ) } ) ;
Response ::builder ( )
. status ( StatusCode ::INTERNAL_SERVER_ERROR )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & err ) . unwrap_or_default ( ) ) )
}
}
}
/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects.
#[ handler ]
pub async fn gateway_all_pipeline_handler ( state : Data < & Arc < GatewayState > > ) -> Response {
let project_urls : BTreeMap < String , String > = state
. projects
. read ( )
. await
. iter ( )
. map ( | ( n , e ) | ( n . clone ( ) , e . url . clone ( ) ) )
. collect ( ) ;
let results =
gateway ::io ::fetch_all_project_pipeline_statuses ( & project_urls , & state . client ) . await ;
let active = state . active_project . read ( ) . await . clone ( ) ;
let body = json! ( { " active " : active , " projects " : results } ) ;
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( serde_json ::to_vec ( & body ) . unwrap_or_default ( ) ) )
}
// ── Bot config page ─────────────────────────────────────────────────────────
/// Self-contained HTML page for bot configuration.
const GATEWAY_BOT_CONFIG_HTML : & str = r # "<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bot Configuration — Huskies Gateway</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 520px;
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.back {
color: #64748b;
text-decoration: none;
font-size: 0.85rem;
margin-right: auto;
}
.back:hover { color: #94a3b8; }
.logo { font-size: 1.5rem; }
h1 { font-size: 1.2rem; font-weight: 600; color: #f8fafc; }
.field { margin-bottom: 1rem; }
label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
input, select {
width: 100%;
padding: 0.625rem 0.875rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 0.9rem;
}
input:focus, select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
padding-right: 2.5rem;
}
.section { margin-top: 1rem; }
.divider {
border: none;
border-top: 1px solid #334155;
margin: 1.25rem 0;
}
button {
width: 100%;
padding: 0.75rem;
background: #6366f1;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
margin-top: 1.25rem;
}
button:hover { background: #4f46e5; }
button:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
.status { margin-top: 0.875rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
.status.ok { color: #4ade80; }
.status.err { color: #f87171; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<a href="/" class="back">← Gateway</a>
<span class="logo">🤖</span>
<h1>Bot Configuration</h1>
</div>
<div class="field">
<label for="transport">Transport</label>
<select id="transport" onchange="onTransportChange(this.value)">
<option value="matrix">Matrix</option>
<option value="slack">Slack</option>
</select>
</div>
<hr class="divider">
<div id="matrix-fields" class="section">
<div class="field">
<label for="homeserver">Homeserver URL</label>
<input type="text" id="homeserver" placeholder="https://matrix.example.com">
</div>
<div class="field">
<label for="username">Bot Username</label>
<input type="text" id="username" placeholder="@bot:example.com">
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" placeholder="••••••••">
</div>
</div>
<div id="slack-fields" class="section" style="display:none">
<div class="field">
<label for="slack-bot-token">Bot Token</label>
<input type="password" id="slack-bot-token" placeholder="xoxb-…">
</div>
<div class="field">
<label for="slack-signing-secret">App / Signing Secret</label>
<input type="password" id="slack-signing-secret" placeholder="Your signing secret">
</div>
</div>
<button id="save-btn" onclick="save()">Save & Restart Bot</button>
<div id="status" class="status"></div>
</div>
<script>
function onTransportChange(v) {
document.getElementById('matrix-fields').style.display = v === 'matrix' ? '' : 'none';
document.getElementById('slack-fields').style.display = v === 'slack' ? '' : 'none';
}
async function loadConfig() {
try {
const r = await fetch('/api/gateway/bot-config');
const d = await r.json();
document.getElementById('transport').value = d.transport || 'matrix';
onTransportChange(d.transport || 'matrix');
document.getElementById('homeserver').value = d.homeserver || '';
document.getElementById('username').value = d.username || '';
document.getElementById('password').value = d.password || '';
document.getElementById('slack-bot-token').value = d.slack_bot_token || '';
document.getElementById('slack-signing-secret').value = d.slack_signing_secret || '';
} catch(e) {
document.getElementById('status').textContent = 'Failed to load config: ' + e;
document.getElementById('status').className = 'status err';
}
}
async function save() {
const btn = document.getElementById('save-btn');
const statusEl = document.getElementById('status');
btn.disabled = true;
btn.textContent = 'Saving\u2026';
statusEl.className = 'status';
statusEl.textContent = '';
const transport = document.getElementById('transport').value;
const payload = { transport };
if (transport === 'matrix') {
payload.homeserver = document.getElementById('homeserver').value;
payload.username = document.getElementById('username').value;
payload.password = document.getElementById('password').value;
} else {
payload.slack_bot_token = document.getElementById('slack-bot-token').value;
payload.slack_signing_secret = document.getElementById('slack-signing-secret').value;
}
try {
const r = await fetch('/api/gateway/bot-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const d = await r.json();
if (d.ok) {
statusEl.className = 'status ok';
statusEl.textContent = 'Saved \u2014 bot restarted with new credentials.';
} else {
statusEl.className = 'status err';
statusEl.textContent = d.error || 'Save failed';
}
} catch(e) {
statusEl.className = 'status err';
statusEl.textContent = 'Error: ' + e;
}
btn.disabled = false;
btn.textContent = 'Save & Restart Bot';
}
loadConfig();
</script>
</body>
</html>
"# ;
/// Serve the bot configuration HTML page at `GET /bot-config`.
#[ handler ]
pub async fn gateway_bot_config_page_handler ( ) -> Response {
Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " text/html; charset=utf-8 " )
. body ( Body ::from ( GATEWAY_BOT_CONFIG_HTML ) )
}