|
1
|
// Package api implements the scuttlebot HTTP management API. |
|
2
|
// |
|
3
|
// /v1/ endpoints require a valid Bearer token. |
|
4
|
// /ui/ is served unauthenticated (static web UI). |
|
5
|
// /v1/channels/{channel}/stream uses ?token= query param (EventSource limitation). |
|
6
|
package api |
|
7
|
|
|
8
|
import ( |
|
9
|
"log/slog" |
|
10
|
"net/http" |
|
11
|
|
|
12
|
"github.com/conflicthq/scuttlebot/internal/auth" |
|
13
|
"github.com/conflicthq/scuttlebot/internal/config" |
|
14
|
"github.com/conflicthq/scuttlebot/internal/registry" |
|
15
|
) |
|
16
|
|
|
17
|
// Server is the scuttlebot HTTP API server. |
|
18
|
type Server struct { |
|
19
|
registry *registry.Registry |
|
20
|
apiKeys *auth.APIKeyStore |
|
21
|
log *slog.Logger |
|
22
|
bridge chatBridge // nil if bridge is disabled |
|
23
|
policies *PolicyStore // nil if not configured |
|
24
|
admins adminStore // nil if not configured |
|
25
|
llmCfg *config.LLMConfig // nil if no LLM backends configured |
|
26
|
topoMgr topologyManager // nil if topology not configured |
|
27
|
cfgStore *ConfigStore // nil if config write-back not configured |
|
28
|
loginRL *loginRateLimiter |
|
29
|
tlsDomain string // empty if no TLS |
|
30
|
} |
|
31
|
|
|
32
|
// New creates a new API Server. Pass nil for b to disable the chat bridge. |
|
33
|
// Pass nil for admins to disable admin authentication endpoints. |
|
34
|
// Pass nil for llmCfg to disable AI/LLM management endpoints. |
|
35
|
// Pass nil for topo to disable topology provisioning endpoints. |
|
36
|
// Pass nil for cfgStore to disable config read/write endpoints. |
|
37
|
func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { |
|
38
|
return &Server{ |
|
39
|
registry: reg, |
|
40
|
apiKeys: apiKeys, |
|
41
|
log: log, |
|
42
|
bridge: b, |
|
43
|
policies: ps, |
|
44
|
admins: admins, |
|
45
|
llmCfg: llmCfg, |
|
46
|
topoMgr: topo, |
|
47
|
cfgStore: cfgStore, |
|
48
|
loginRL: newLoginRateLimiter(), |
|
49
|
tlsDomain: tlsDomain, |
|
50
|
} |
|
51
|
} |
|
52
|
|
|
53
|
// Handler returns the HTTP handler with all routes registered. |
|
54
|
// /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
|
55
|
// Scoped routes additionally check the API key's scopes. |
|
56
|
func (s *Server) Handler() http.Handler { |
|
57
|
apiMux := http.NewServeMux() |
|
58
|
|
|
59
|
// Read-scope: status, metrics (also accessible with any scope via admin). |
|
60
|
apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus)) |
|
61
|
apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics)) |
|
62
|
|
|
63
|
// Policies — admin scope. |
|
64
|
if s.policies != nil { |
|
65
|
apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings)) |
|
66
|
apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies)) |
|
67
|
apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies)) |
|
68
|
apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies)) |
|
69
|
} |
|
70
|
|
|
71
|
// Agents — agents scope. |
|
72
|
apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents)) |
|
73
|
apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent)) |
|
74
|
apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent)) |
|
75
|
apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) |
|
76
|
apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) |
|
77
|
apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) |
|
78
|
apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) |
|
79
|
apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) |
|
80
|
apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents)) |
|
81
|
|
|
82
|
// Channels — channels scope (read), chat scope (send). |
|
83
|
if s.bridge != nil { |
|
84
|
apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels)) |
|
85
|
apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel)) |
|
86
|
apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel)) |
|
87
|
apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages)) |
|
88
|
apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage)) |
|
89
|
apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence)) |
|
90
|
apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers)) |
|
91
|
apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig)) |
|
92
|
apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig)) |
|
93
|
} |
|
94
|
|
|
95
|
// Topology — topology scope. |
|
96
|
if s.topoMgr != nil { |
|
97
|
apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) |
|
98
|
apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel)) |
|
99
|
apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology)) |
|
100
|
} |
|
101
|
// Blocker escalation — agents can signal they're stuck. |
|
102
|
if s.bridge != nil { |
|
103
|
apiMux.HandleFunc("POST /v1/agents/{nick}/blocker", s.requireScope(auth.ScopeAgents, s.handleAgentBlocker)) |
|
104
|
} |
|
105
|
|
|
106
|
// Instructions — available even without topology (uses policies store). |
|
107
|
apiMux.HandleFunc("GET /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleGetInstructions)) |
|
108
|
apiMux.HandleFunc("PUT /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handlePutInstructions)) |
|
109
|
apiMux.HandleFunc("DELETE /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleDeleteInstructions)) |
|
110
|
|
|
111
|
// Config — config scope. |
|
112
|
if s.cfgStore != nil { |
|
113
|
apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig)) |
|
114
|
apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig)) |
|
115
|
apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory)) |
|
116
|
apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry)) |
|
117
|
} |
|
118
|
|
|
119
|
// Admin — admin scope. |
|
120
|
if s.admins != nil { |
|
121
|
apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList)) |
|
122
|
apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd)) |
|
123
|
apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove)) |
|
124
|
apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword)) |
|
125
|
} |
|
126
|
|
|
127
|
// API key management — admin scope. |
|
128
|
apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys)) |
|
129
|
apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey)) |
|
130
|
apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey)) |
|
131
|
|
|
132
|
// LLM / AI gateway — bots scope. |
|
133
|
apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends)) |
|
134
|
apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate)) |
|
135
|
apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate)) |
|
136
|
apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete)) |
|
137
|
apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels)) |
|
138
|
apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover)) |
|
139
|
apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown)) |
|
140
|
apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete)) |
|
141
|
|
|
142
|
outer := http.NewServeMux() |
|
143
|
outer.HandleFunc("POST /login", s.handleLogin) |
|
144
|
outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
|
145
|
http.Redirect(w, r, "/ui/", http.StatusFound) |
|
146
|
}) |
|
147
|
outer.Handle("/ui/", s.uiFileServer()) |
|
148
|
// SSE stream uses ?token= auth (EventSource can't send headers), registered |
|
149
|
// on outer so it bypasses the Bearer-token authMiddleware on /v1/. |
|
150
|
if s.bridge != nil { |
|
151
|
outer.HandleFunc("GET /v1/channels/{channel}/stream", s.handleChannelStream) |
|
152
|
} |
|
153
|
outer.Handle("/v1/", s.authMiddleware(apiMux)) |
|
154
|
|
|
155
|
return outer |
|
156
|
} |
|
157
|
|