ScuttleBot

feat(#37,#40): POST /v1/channels and GET /v1/topology — channel provisioning and discovery API - Add topologyManager interface to internal/api (satisfied by *topology.Manager) - Server.New() takes an optional topologyManager; routes gated on non-nil - POST /v1/channels: validates name, merges autojoin from policy, provisions via ChanServ, returns {channel, type, supervision, autojoin} - GET /v1/topology: returns static channel names and channel type rules from policy - Add topology.Nick config field (default "topology") and Defaults() support - Hoist topoMgr to package-level var in main.go so it flows into api.New()

lmata 2026-04-02 12:52 trunk
Commit dd3a887e30cee4f75a4cfccb3dbaf92a1cf3f30572ea608f661315a240be9bff
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -158,20 +158,21 @@
158158
}
159159
}()
160160
}
161161
162162
// Topology manager — provisions static channels and enforces autojoin policy.
163
- topoPolicy := topology.NewPolicy(cfg.Topology)
163
+ var topoMgr *topology.Manager
164164
if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 {
165
+ topoPolicy := topology.NewPolicy(cfg.Topology)
165166
topoPass := mustGenToken()
166167
if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil {
167168
if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
168169
log.Error("topology account setup failed", "err", err2)
169170
os.Exit(1)
170171
}
171172
}
172
- topoMgr := topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
173
+ topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
173174
topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
174175
if err := topoMgr.Connect(topoCtx); err != nil {
175176
topoCancel()
176177
log.Error("topology manager connect failed", "err", err)
177178
os.Exit(1)
@@ -193,11 +194,10 @@
193194
go func() {
194195
<-ctx.Done()
195196
topoMgr.Close()
196197
}()
197198
}
198
- _ = topoPolicy // available for future API wiring (#37–#42)
199199
200200
// Policy store — persists behavior/agent/logging settings.
201201
policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
202202
if err != nil {
203203
log.Error("policy store", "err", err)
@@ -264,11 +264,11 @@
264264
// Start HTTP REST API server.
265265
var llmCfg *config.LLMConfig
266266
if len(cfg.LLM.Backends) > 0 {
267267
llmCfg = &cfg.LLM
268268
}
269
- apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, cfg.TLS.Domain, log)
269
+ apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfg.TLS.Domain, log)
270270
handler := apiSrv.Handler()
271271
272272
var httpServer, tlsServer *http.Server
273273
274274
if cfg.TLS.Domain != "" {
275275
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -158,20 +158,21 @@
158 }
159 }()
160 }
161
162 // Topology manager — provisions static channels and enforces autojoin policy.
163 topoPolicy := topology.NewPolicy(cfg.Topology)
164 if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 {
 
165 topoPass := mustGenToken()
166 if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil {
167 if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
168 log.Error("topology account setup failed", "err", err2)
169 os.Exit(1)
170 }
171 }
172 topoMgr := topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
173 topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
174 if err := topoMgr.Connect(topoCtx); err != nil {
175 topoCancel()
176 log.Error("topology manager connect failed", "err", err)
177 os.Exit(1)
@@ -193,11 +194,10 @@
193 go func() {
194 <-ctx.Done()
195 topoMgr.Close()
196 }()
197 }
198 _ = topoPolicy // available for future API wiring (#37–#42)
199
200 // Policy store — persists behavior/agent/logging settings.
201 policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
202 if err != nil {
203 log.Error("policy store", "err", err)
@@ -264,11 +264,11 @@
264 // Start HTTP REST API server.
265 var llmCfg *config.LLMConfig
266 if len(cfg.LLM.Backends) > 0 {
267 llmCfg = &cfg.LLM
268 }
269 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, cfg.TLS.Domain, log)
270 handler := apiSrv.Handler()
271
272 var httpServer, tlsServer *http.Server
273
274 if cfg.TLS.Domain != "" {
275
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -158,20 +158,21 @@
158 }
159 }()
160 }
161
162 // Topology manager — provisions static channels and enforces autojoin policy.
163 var topoMgr *topology.Manager
164 if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 {
165 topoPolicy := topology.NewPolicy(cfg.Topology)
166 topoPass := mustGenToken()
167 if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil {
168 if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil {
169 log.Error("topology account setup failed", "err", err2)
170 os.Exit(1)
171 }
172 }
173 topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, topoPolicy, log)
174 topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second)
175 if err := topoMgr.Connect(topoCtx); err != nil {
176 topoCancel()
177 log.Error("topology manager connect failed", "err", err)
178 os.Exit(1)
@@ -193,11 +194,10 @@
194 go func() {
195 <-ctx.Done()
196 topoMgr.Close()
197 }()
198 }
 
199
200 // Policy store — persists behavior/agent/logging settings.
201 policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
202 if err != nil {
203 log.Error("policy store", "err", err)
@@ -264,11 +264,11 @@
264 // Start HTTP REST API server.
265 var llmCfg *config.LLMConfig
266 if len(cfg.LLM.Backends) > 0 {
267 llmCfg = &cfg.LLM
268 }
269 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoMgr, cfg.TLS.Domain, log)
270 handler := apiSrv.Handler()
271
272 var httpServer, tlsServer *http.Server
273
274 if cfg.TLS.Domain != "" {
275
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
5050
const testToken = "test-api-token-abc123"
5151
5252
func newTestServer(t *testing.T) *httptest.Server {
5353
t.Helper()
5454
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, "", testLog)
55
+ srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
5656
return httptest.NewServer(srv.Handler())
5757
}
5858
5959
func authHeader() http.Header {
6060
h := http.Header{}
6161
6262
ADDED internal/api/channels_topology.go
6363
ADDED internal/api/channels_topology_test.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/channels_topology.go
63 DDED internal/api/channels_topology_test.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/channels_topology.go
63 DDED internal/api/channels_topology_test.go
--- a/internal/api/channels_topology.go
+++ b/internal/api/channels_topology.go
@@ -0,0 +1,42 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/topology"
8
+)
9
+
10
+// TopologyManager is the interface the API server uses to provision channels
11
+// and query the channel policy. Satisfied ger = topologyManager
12
+type topologyManager interface {
13
+ ProvisionChannel(ch topology.ChannelConfig) error
14
+ opChannel(channel string)
15
+ Polil {
16
+ autojoin = pjoinFor(req.Name)
17
+ }
18
+ var modes []string
19
+ if policy != nil {
20
+ modes = policy.ModesFor(req.Name)
21
+ }
22
+
23
+ ch := topology.ChannelConfig{
24
+ Name: req.Name,
25
+ Topoin,
26
+ Modes: modes,
27
+ }
28
+ if err := s.topoMgr.ProvisionChannel(ch); err != nil {
29
+ s.log.Error("provision channel", "channel", req.Name, "err"e, "err", err)
30
+ writeError(w, http.StatusInternalServerError, "provision failed")
31
+ return
32
+ }
33
+
34
+ and supervision channel.
35
+func (s *Server) handleProvisionChannel(w http.ResponseWriter, r *http.Request) {
36
+ var req provisionChannelRequest
37
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
38
+ writeError(w, http.StatusBadRequest, "invalid request body")
39
+ return
40
+ }
41
+ if err := topology.ValidateName(req.Name); err != nil {
42
+ writeError(w, http.S
--- a/internal/api/channels_topology.go
+++ b/internal/api/channels_topology.go
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/channels_topology.go
+++ b/internal/api/channels_topology.go
@@ -0,0 +1,42 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6
7 "github.com/conflicthq/scuttlebot/internal/topology"
8 )
9
10 // TopologyManager is the interface the API server uses to provision channels
11 // and query the channel policy. Satisfied ger = topologyManager
12 type topologyManager interface {
13 ProvisionChannel(ch topology.ChannelConfig) error
14 opChannel(channel string)
15 Polil {
16 autojoin = pjoinFor(req.Name)
17 }
18 var modes []string
19 if policy != nil {
20 modes = policy.ModesFor(req.Name)
21 }
22
23 ch := topology.ChannelConfig{
24 Name: req.Name,
25 Topoin,
26 Modes: modes,
27 }
28 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
29 s.log.Error("provision channel", "channel", req.Name, "err"e, "err", err)
30 writeError(w, http.StatusInternalServerError, "provision failed")
31 return
32 }
33
34 and supervision channel.
35 func (s *Server) handleProvisionChannel(w http.ResponseWriter, r *http.Request) {
36 var req provisionChannelRequest
37 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
38 writeError(w, http.StatusBadRequest, "invalid request body")
39 return
40 }
41 if err := topology.ValidateName(req.Name); err != nil {
42 writeError(w, http.S
--- a/internal/api/channels_topology_test.go
+++ b/internal/api/channels_topology_test.go
@@ -0,0 +1,56 @@
1
+package api
2
+
3
+import (
4
+
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "log/slog"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/confconfig"
15
+ "github.com/conflicthq/scuttlebot/internal/registry"
16
+ "github.com/conflicthq/scuttlebo implements topologyManager for tests.
17
+// It records the last ProvisionChannel call and returns a canned Policy.
18
+type stubTopologyManager struct {
19
+ last topology.ChannelConfig
20
+ policy *topology.Policy
21
+ provErr error
22
+ager) RevokeAccess(nick, channel nick, channel, lech topology.ChannelConfig) error {
23
+ s.last = ch
24
+ return s.provErr
25
+}
26
+
27
+func (s *stubTopologyManager) DropChannel(_ string) {}
28
+
29
+func (s *stubTopologyManager) Policy() *topolog grants []accessCall
30
+ revokes []accessCall
31
+}
32
+
33
+func (s *sevel string) {
34
+ s.grants = append(s.grants, accessCall{Nick: nick, Channel: channel, Level: level})
35
+}
36
+
37
+func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
38
+ s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
39
+}
40
+
41
+// stubProvisioner ni[] stubProvisioner ni[]string{"tok"}ng) {}
42
+
43
+func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
44
+
45
+func (s *stu//[]string{"tok"}ng) {}
46
+
47
+func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
48
+
49
+func (s *stubTopologyManager) GrantAccess(ni_ts, accessCall{Nick: nick, Channel: channel, Level: level})
50
+}
51
+
52
+func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
53
+ s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
54
+}
55
+
56
+// stubProvisioner nivok
--- a/internal/api/channels_topology_test.go
+++ b/internal/api/channels_topology_test.go
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/channels_topology_test.go
+++ b/internal/api/channels_topology_test.go
@@ -0,0 +1,56 @@
1 package api
2
3 import (
4
5 "encoding/json"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
14 "github.com/confconfig"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 "github.com/conflicthq/scuttlebo implements topologyManager for tests.
17 // It records the last ProvisionChannel call and returns a canned Policy.
18 type stubTopologyManager struct {
19 last topology.ChannelConfig
20 policy *topology.Policy
21 provErr error
22 ager) RevokeAccess(nick, channel nick, channel, lech topology.ChannelConfig) error {
23 s.last = ch
24 return s.provErr
25 }
26
27 func (s *stubTopologyManager) DropChannel(_ string) {}
28
29 func (s *stubTopologyManager) Policy() *topolog grants []accessCall
30 revokes []accessCall
31 }
32
33 func (s *sevel string) {
34 s.grants = append(s.grants, accessCall{Nick: nick, Channel: channel, Level: level})
35 }
36
37 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
38 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
39 }
40
41 // stubProvisioner ni[] stubProvisioner ni[]string{"tok"}ng) {}
42
43 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
44
45 func (s *stu//[]string{"tok"}ng) {}
46
47 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
48
49 func (s *stubTopologyManager) GrantAccess(ni_ts, accessCall{Nick: nick, Channel: channel, Level: level})
50 }
51
52 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
53 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
54 }
55
56 // stubProvisioner nivok
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -39,11 +39,11 @@
3939
t.Helper()
4040
4141
bridgeStub := &stubChatBridge{}
4242
reg := registry.New(nil, []byte("test-signing-key"))
4343
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
44
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, "", logger).Handler())
44
+ srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
4545
defer srv.Close()
4646
4747
body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
4848
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
4949
if err != nil {
@@ -72,11 +72,11 @@
7272
t.Helper()
7373
7474
bridgeStub := &stubChatBridge{}
7575
reg := registry.New(nil, []byte("test-signing-key"))
7676
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
77
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, "", logger).Handler())
77
+ srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
7878
defer srv.Close()
7979
8080
body, _ := json.Marshal(map[string]string{})
8181
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
8282
if err != nil {
8383
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -39,11 +39,11 @@
39 t.Helper()
40
41 bridgeStub := &stubChatBridge{}
42 reg := registry.New(nil, []byte("test-signing-key"))
43 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
44 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, "", logger).Handler())
45 defer srv.Close()
46
47 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
48 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
49 if err != nil {
@@ -72,11 +72,11 @@
72 t.Helper()
73
74 bridgeStub := &stubChatBridge{}
75 reg := registry.New(nil, []byte("test-signing-key"))
76 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
77 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, "", logger).Handler())
78 defer srv.Close()
79
80 body, _ := json.Marshal(map[string]string{})
81 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
82 if err != nil {
83
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -39,11 +39,11 @@
39 t.Helper()
40
41 bridgeStub := &stubChatBridge{}
42 reg := registry.New(nil, []byte("test-signing-key"))
43 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
44 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
45 defer srv.Close()
46
47 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
48 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
49 if err != nil {
@@ -72,11 +72,11 @@
72 t.Helper()
73
74 bridgeStub := &stubChatBridge{}
75 reg := registry.New(nil, []byte("test-signing-key"))
76 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
77 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, "", logger).Handler())
78 defer srv.Close()
79
80 body, _ := json.Marshal(map[string]string{})
81 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
82 if err != nil {
83
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
2828
admins := newAdminStore(t)
2929
if err := admins.Add("admin", "hunter2"); err != nil {
3030
t.Fatalf("Add admin: %v", err)
3131
}
3232
reg := registry.New(newMock(), []byte("test-signing-key"))
33
- srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, "", testLog)
33
+ srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, "", testLog)
3434
return httptest.NewServer(srv.Handler()), admins
3535
}
3636
3737
func TestLoginNoAdmins(t *testing.T) {
3838
// When admins is nil, login returns 404.
3939
reg := registry.New(newMock(), []byte("test-signing-key"))
40
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, "", testLog)
40
+ srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
4141
ts := httptest.NewServer(srv.Handler())
4242
defer ts.Close()
4343
4444
resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
4545
defer resp.Body.Close()
4646
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/server.go
+++ internal/api/server.go
@@ -20,18 +20,20 @@
2020
log *slog.Logger
2121
bridge chatBridge // nil if bridge is disabled
2222
policies *PolicyStore // nil if not configured
2323
admins adminStore // nil if not configured
2424
llmCfg *config.LLMConfig // nil if no LLM backends configured
25
+ topoMgr topologyManager // nil if topology not configured
2526
loginRL *loginRateLimiter
2627
tlsDomain string // empty if no TLS
2728
}
2829
2930
// New creates a new API Server. Pass nil for b to disable the chat bridge.
3031
// Pass nil for admins to disable admin authentication endpoints.
3132
// Pass nil for llmCfg to disable AI/LLM management endpoints.
32
-func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, tlsDomain string, log *slog.Logger) *Server {
33
+// Pass nil for topo to disable topology provisioning endpoints.
34
+func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, tlsDomain string, log *slog.Logger) *Server {
3335
tokenSet := make(map[string]struct{}, len(tokens))
3436
for _, t := range tokens {
3537
tokenSet[t] = struct{}{}
3638
}
3739
return &Server{
@@ -40,10 +42,11 @@
4042
log: log,
4143
bridge: b,
4244
policies: ps,
4345
admins: admins,
4446
llmCfg: llmCfg,
47
+ topoMgr: topo,
4548
loginRL: newLoginRateLimiter(),
4649
tlsDomain: tlsDomain,
4750
}
4851
}
4952
@@ -73,10 +76,14 @@
7376
apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
7477
apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
7578
apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
7679
apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
7780
}
81
+ if s.topoMgr != nil {
82
+ apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
83
+ apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
84
+ }
7885
7986
if s.admins != nil {
8087
apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
8188
apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
8289
apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
8390
--- internal/api/server.go
+++ internal/api/server.go
@@ -20,18 +20,20 @@
20 log *slog.Logger
21 bridge chatBridge // nil if bridge is disabled
22 policies *PolicyStore // nil if not configured
23 admins adminStore // nil if not configured
24 llmCfg *config.LLMConfig // nil if no LLM backends configured
 
25 loginRL *loginRateLimiter
26 tlsDomain string // empty if no TLS
27 }
28
29 // New creates a new API Server. Pass nil for b to disable the chat bridge.
30 // Pass nil for admins to disable admin authentication endpoints.
31 // Pass nil for llmCfg to disable AI/LLM management endpoints.
32 func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, tlsDomain string, log *slog.Logger) *Server {
 
33 tokenSet := make(map[string]struct{}, len(tokens))
34 for _, t := range tokens {
35 tokenSet[t] = struct{}{}
36 }
37 return &Server{
@@ -40,10 +42,11 @@
40 log: log,
41 bridge: b,
42 policies: ps,
43 admins: admins,
44 llmCfg: llmCfg,
 
45 loginRL: newLoginRateLimiter(),
46 tlsDomain: tlsDomain,
47 }
48 }
49
@@ -73,10 +76,14 @@
73 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
74 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
75 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
76 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
77 }
 
 
 
 
78
79 if s.admins != nil {
80 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
81 apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
82 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
83
--- internal/api/server.go
+++ internal/api/server.go
@@ -20,18 +20,20 @@
20 log *slog.Logger
21 bridge chatBridge // nil if bridge is disabled
22 policies *PolicyStore // nil if not configured
23 admins adminStore // nil if not configured
24 llmCfg *config.LLMConfig // nil if no LLM backends configured
25 topoMgr topologyManager // nil if topology not configured
26 loginRL *loginRateLimiter
27 tlsDomain string // empty if no TLS
28 }
29
30 // New creates a new API Server. Pass nil for b to disable the chat bridge.
31 // Pass nil for admins to disable admin authentication endpoints.
32 // Pass nil for llmCfg to disable AI/LLM management endpoints.
33 // Pass nil for topo to disable topology provisioning endpoints.
34 func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, tlsDomain string, log *slog.Logger) *Server {
35 tokenSet := make(map[string]struct{}, len(tokens))
36 for _, t := range tokens {
37 tokenSet[t] = struct{}{}
38 }
39 return &Server{
@@ -40,10 +42,11 @@
42 log: log,
43 bridge: b,
44 policies: ps,
45 admins: admins,
46 llmCfg: llmCfg,
47 topoMgr: topo,
48 loginRL: newLoginRateLimiter(),
49 tlsDomain: tlsDomain,
50 }
51 }
52
@@ -73,10 +76,14 @@
76 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
77 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
78 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
79 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
80 }
81 if s.topoMgr != nil {
82 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
83 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
84 }
85
86 if s.admins != nil {
87 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
88 apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
89 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
90

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button