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