- Add OAuth 2.1 identity provider with PKCE S256 (ory/fosite) - Add RFC 7591 dynamic client registration for MCP clients - Add RFC 8414 OAuth metadata discovery endpoint - Add branded OAuth login/authorize pages with SynapBus design - Add SYNAPBUS_BASE_URL env var for remote/LAN deployments - Add OAuth bearer token authentication for MCP connections - Add dead letter queue with Web UI management page - Add channel leave, member list, and improved channel management - Add agent auth middleware for MCP-authenticated requests - Add console printer for structured server startup output - Hide human accounts from agent management UI - Fix SSE through middleware (Flush/Unwrap support) - Fix graceful shutdown by closing SSE clients before server stop - Fix localhost/127.0.0.1 redirect URI normalization for OAuth - Remove agent self-registration MCP tools (manage via Web UI only) - Update README with OAuth setup guide and MCP client config example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
6.6 KiB
Go
228 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/synapbus/synapbus/internal/agents"
|
|
"github.com/synapbus/synapbus/internal/auth"
|
|
"github.com/synapbus/synapbus/internal/channels"
|
|
"github.com/synapbus/synapbus/internal/trace"
|
|
)
|
|
|
|
// AgentsHandler handles REST API requests for agents.
|
|
type AgentsHandler struct {
|
|
agentService *agents.AgentService
|
|
channelService *channels.Service
|
|
traceStore trace.TraceStore
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewAgentsHandler creates a new agents handler.
|
|
func NewAgentsHandler(agentService *agents.AgentService, traceStore trace.TraceStore, channelService ...*channels.Service) *AgentsHandler {
|
|
h := &AgentsHandler{
|
|
agentService: agentService,
|
|
traceStore: traceStore,
|
|
logger: slog.Default().With("component", "api.agents"),
|
|
}
|
|
if len(channelService) > 0 {
|
|
h.channelService = channelService[0]
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ListAgents handles GET /api/agents.
|
|
func (h *AgentsHandler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
agentList, err := h.agentService.ListAgents(r.Context(), ownerID)
|
|
if err != nil {
|
|
h.logger.Error("list agents failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"agents": agentList})
|
|
}
|
|
|
|
// GetAgent handles GET /api/agents/{name}.
|
|
func (h *AgentsHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
name := chi.URLParam(r, "name")
|
|
agent, err := h.agentService.GetAgent(r.Context(), name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found"))
|
|
return
|
|
}
|
|
|
|
if agent.OwnerID != ownerID {
|
|
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this agent"))
|
|
return
|
|
}
|
|
|
|
// Get recent traces for this agent
|
|
var traces []trace.Trace
|
|
if h.traceStore != nil {
|
|
filter := trace.TraceFilter{
|
|
AgentName: name,
|
|
PageSize: 20,
|
|
Page: 1,
|
|
}
|
|
traces, _, _ = h.traceStore.Query(r.Context(), filter)
|
|
}
|
|
if traces == nil {
|
|
traces = []trace.Trace{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"agent": agent,
|
|
"traces": traces,
|
|
})
|
|
}
|
|
|
|
// RegisterAgent handles POST /api/agents.
|
|
func (h *AgentsHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
Type string `json:"type"`
|
|
Capabilities json.RawMessage `json:"capabilities,omitempty"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Agent name is required"))
|
|
return
|
|
}
|
|
|
|
agent, apiKey, err := h.agentService.Register(r.Context(), req.Name, req.DisplayName, req.Type, req.Capabilities, ownerID)
|
|
if err != nil {
|
|
h.logger.Error("register agent failed", "error", err)
|
|
writeJSON(w, http.StatusBadRequest, errorBody("register_failed", err.Error()))
|
|
return
|
|
}
|
|
|
|
// Auto-join agent to the owner's my-agents channel
|
|
if h.channelService != nil {
|
|
if user, ok := auth.UserFromContext(r.Context()); ok {
|
|
if err := h.channelService.JoinMyAgentsChannel(r.Context(), user.Username, agent.Name); err != nil {
|
|
h.logger.Warn("failed to join agent to my-agents channel",
|
|
"agent", agent.Name,
|
|
"username", user.Username,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"agent": agent,
|
|
"api_key": apiKey,
|
|
})
|
|
}
|
|
|
|
// UpdateAgent handles PUT /api/agents/{name}.
|
|
func (h *AgentsHandler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
name := chi.URLParam(r, "name")
|
|
|
|
// Verify ownership
|
|
agent, err := h.agentService.GetAgent(r.Context(), name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found"))
|
|
return
|
|
}
|
|
if agent.OwnerID != ownerID {
|
|
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this agent"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
DisplayName string `json:"display_name"`
|
|
Capabilities json.RawMessage `json:"capabilities,omitempty"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
|
|
return
|
|
}
|
|
|
|
updated, err := h.agentService.UpdateAgent(r.Context(), name, req.DisplayName, req.Capabilities)
|
|
if err != nil {
|
|
h.logger.Error("update agent failed", "error", err)
|
|
writeJSON(w, http.StatusBadRequest, errorBody("update_failed", err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"agent": updated})
|
|
}
|
|
|
|
// DeleteAgent handles DELETE /api/agents/{name}.
|
|
func (h *AgentsHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
name := chi.URLParam(r, "name")
|
|
|
|
if err := h.agentService.Deregister(r.Context(), name, ownerID); err != nil {
|
|
h.logger.Error("deregister agent failed", "error", err)
|
|
writeJSON(w, http.StatusBadRequest, errorBody("deregister_failed", err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deregistered"})
|
|
}
|
|
|
|
// RevokeKey handles POST /api/agents/{name}/revoke-key.
|
|
func (h *AgentsHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
|
|
ownerID, ok := OwnerIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
|
return
|
|
}
|
|
|
|
name := chi.URLParam(r, "name")
|
|
|
|
agent, newKey, err := h.agentService.RevokeKey(r.Context(), name, ownerID)
|
|
if err != nil {
|
|
h.logger.Error("revoke key failed", "error", err)
|
|
writeJSON(w, http.StatusBadRequest, errorBody("revoke_failed", err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"agent": agent,
|
|
"api_key": newKey,
|
|
})
|
|
}
|