ScuttleBot

feat: API key management — per-consumer tokens with scoped permissions (#124) Add APIKeyStore (internal/auth/apikeys.go) with SHA-256 hashed tokens, 8 permission scopes (admin, agents, channels, topology, bots, config, read, chat), per-key expiry, and last-used tracking. Auth middleware now resolves Bearer tokens to API keys, injects into request context, and enforces per-route scope requirements. Every endpoint is scoped: admin for management, agents for registration, channels/chat for messaging, etc. API key CRUD endpoints: POST/GET/DELETE /v1/api-keys (admin scope). Create returns plaintext token once (like GitHub PATs). Login creates 24h session keys instead of returning the shared token. Startup token migrated to api_keys.json as first admin-scope key. MCP server updated to use TokenValidator interface.

lmata 2026-04-05 14:15 trunk
Commit 485e07b5edede205dc5afcbe044100031cca358fa1c16c0d0ab4bd82b63fe737
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138138
} else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139139
log.Error("registry load", "err", err)
140140
os.Exit(1)
141141
}
142142
143
- // Shared API token — persisted so the UI token survives restarts.
144
- apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
143
+ // API key store — per-consumer tokens with scoped permissions.
144
+ apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json"))
145145
if err != nil {
146
- log.Error("api token", "err", err)
146
+ log.Error("api key store", "err", err)
147147
os.Exit(1)
148148
}
149
- log.Info("api token", "token", apiToken) // printed on every startup
150
- tokens := []string{apiToken}
149
+ // Migrate legacy api_token into key store on first run.
150
+ if apiKeyStore.IsEmpty() {
151
+ apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
152
+ if err != nil {
153
+ log.Error("api token", "err", err)
154
+ os.Exit(1)
155
+ }
156
+ if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil {
157
+ log.Error("migrate api token to key store", "err", err)
158
+ os.Exit(1)
159
+ }
160
+ log.Info("migrated api_token to api_keys.json", "token", apiToken)
161
+ } else {
162
+ log.Info("api key store loaded", "keys", len(apiKeyStore.List()))
163
+ }
151164
152165
// Start bridge bot (powers the web chat UI).
153166
var bridgeBot *bridge.Bot
154167
if cfg.Bridge.Enabled {
155168
if cfg.Bridge.Password == "" {
@@ -352,11 +365,11 @@
352365
// non-nil (Go nil interface trap) and causes panics in setAgentModes.
353366
var topoIface api.TopologyManager
354367
if topoMgr != nil {
355368
topoIface = topoMgr
356369
}
357
- apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
370
+ apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
358371
handler := apiSrv.Handler()
359372
360373
var httpServer, tlsServer *http.Server
361374
362375
if cfg.TLS.Domain != "" {
@@ -418,11 +431,11 @@
418431
}
419432
}()
420433
}
421434
422435
// Start MCP server.
423
- mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
436
+ mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
424437
mcpServer := &http.Server{
425438
Addr: cfg.MCPAddr,
426439
Handler: mcpSrv.Handler(),
427440
}
428441
go func() {
429442
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138 } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139 log.Error("registry load", "err", err)
140 os.Exit(1)
141 }
142
143 // Shared API token — persisted so the UI token survives restarts.
144 apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
145 if err != nil {
146 log.Error("api token", "err", err)
147 os.Exit(1)
148 }
149 log.Info("api token", "token", apiToken) // printed on every startup
150 tokens := []string{apiToken}
 
 
 
 
 
 
 
 
 
 
 
 
 
151
152 // Start bridge bot (powers the web chat UI).
153 var bridgeBot *bridge.Bot
154 if cfg.Bridge.Enabled {
155 if cfg.Bridge.Password == "" {
@@ -352,11 +365,11 @@
352 // non-nil (Go nil interface trap) and causes panics in setAgentModes.
353 var topoIface api.TopologyManager
354 if topoMgr != nil {
355 topoIface = topoMgr
356 }
357 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
358 handler := apiSrv.Handler()
359
360 var httpServer, tlsServer *http.Server
361
362 if cfg.TLS.Domain != "" {
@@ -418,11 +431,11 @@
418 }
419 }()
420 }
421
422 // Start MCP server.
423 mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
424 mcpServer := &http.Server{
425 Addr: cfg.MCPAddr,
426 Handler: mcpSrv.Handler(),
427 }
428 go func() {
429
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138 } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139 log.Error("registry load", "err", err)
140 os.Exit(1)
141 }
142
143 // API key store — per-consumer tokens with scoped permissions.
144 apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json"))
145 if err != nil {
146 log.Error("api key store", "err", err)
147 os.Exit(1)
148 }
149 // Migrate legacy api_token into key store on first run.
150 if apiKeyStore.IsEmpty() {
151 apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
152 if err != nil {
153 log.Error("api token", "err", err)
154 os.Exit(1)
155 }
156 if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil {
157 log.Error("migrate api token to key store", "err", err)
158 os.Exit(1)
159 }
160 log.Info("migrated api_token to api_keys.json", "token", apiToken)
161 } else {
162 log.Info("api key store loaded", "keys", len(apiKeyStore.List()))
163 }
164
165 // Start bridge bot (powers the web chat UI).
166 var bridgeBot *bridge.Bot
167 if cfg.Bridge.Enabled {
168 if cfg.Bridge.Password == "" {
@@ -352,11 +365,11 @@
365 // non-nil (Go nil interface trap) and causes panics in setAgentModes.
366 var topoIface api.TopologyManager
367 if topoMgr != nil {
368 topoIface = topoMgr
369 }
370 apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
371 handler := apiSrv.Handler()
372
373 var httpServer, tlsServer *http.Server
374
375 if cfg.TLS.Domain != "" {
@@ -418,11 +431,11 @@
431 }
432 }()
433 }
434
435 // Start MCP server.
436 mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
437 mcpServer := &http.Server{
438 Addr: cfg.MCPAddr,
439 Handler: mcpSrv.Handler(),
440 }
441 go func() {
442
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
88
"net/http/httptest"
99
"sync"
1010
"testing"
1111
1212
"github.com/conflicthq/scuttlebot/internal/api"
13
+ "github.com/conflicthq/scuttlebot/internal/auth"
1314
"github.com/conflicthq/scuttlebot/internal/registry"
1415
"log/slog"
1516
"os"
1617
)
1718
@@ -50,11 +51,11 @@
5051
const testToken = "test-api-token-abc123"
5152
5253
func newTestServer(t *testing.T) *httptest.Server {
5354
t.Helper()
5455
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
56
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
5657
return httptest.NewServer(srv.Handler())
5758
}
5859
5960
func authHeader() http.Header {
6061
h := http.Header{}
6162
6263
ADDED internal/api/apikeys.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
8 "net/http/httptest"
9 "sync"
10 "testing"
11
12 "github.com/conflicthq/scuttlebot/internal/api"
 
13 "github.com/conflicthq/scuttlebot/internal/registry"
14 "log/slog"
15 "os"
16 )
17
@@ -50,11 +51,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, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/apikeys.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
8 "net/http/httptest"
9 "sync"
10 "testing"
11
12 "github.com/conflicthq/scuttlebot/internal/api"
13 "github.com/conflicthq/scuttlebot/internal/auth"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 "log/slog"
16 "os"
17 )
18
@@ -50,11 +51,11 @@
51 const testToken = "test-api-token-abc123"
52
53 func newTestServer(t *testing.T) *httptest.Server {
54 t.Helper()
55 reg := registry.New(newMock(), []byte("test-signing-key"))
56 srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
57 return httptest.NewServer(srv.Handler())
58 }
59
60 func authHeader() http.Header {
61 h := http.Header{}
62
63 DDED internal/api/apikeys.go
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,33 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/auth"
9
+)
10
+
11
+type createAPIKeyRequest struct {
12
+ Name string `json:"name"`
13
+ Scopes []string `json:"scopes"`
14
+ ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never
15
+}
16
+
17
+type createAPIKeyResponse struc {
18
+ ID string `json:"i`json:"nam"`
19
+ Token string `json:"token"` // plaintext, shown only once
20
+ Scopes `json:"created_at"`
21
+ api
22
+
23
+import (
24
+ "encodipackage api
25
+
26
+import (
27
+ "encodion:"expires_at,omitempty"`
28
+}
29
+ {
30
+ ID string `json:"iruct {
31
+ Name string `json:"name"`
32
+ Scopes `json:"created_a`json:"last_used,omitempt`json:"expires_at,omitempt"`
33
+ Active bool
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,33 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/internal/auth"
9 )
10
11 type createAPIKeyRequest struct {
12 Name string `json:"name"`
13 Scopes []string `json:"scopes"`
14 ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never
15 }
16
17 type createAPIKeyResponse struc {
18 ID string `json:"i`json:"nam"`
19 Token string `json:"token"` // plaintext, shown only once
20 Scopes `json:"created_at"`
21 api
22
23 import (
24 "encodipackage api
25
26 import (
27 "encodion:"expires_at,omitempty"`
28 }
29 {
30 ID string `json:"iruct {
31 Name string `json:"name"`
32 Scopes `json:"created_a`json:"last_used,omitempt`json:"expires_at,omitempt"`
33 Active bool
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
99
"net/http"
1010
"net/http/httptest"
1111
"testing"
1212
"time"
1313
14
+ "github.com/conflicthq/scuttlebot/internal/auth"
1415
"github.com/conflicthq/scuttlebot/internal/config"
1516
"github.com/conflicthq/scuttlebot/internal/registry"
1617
"github.com/conflicthq/scuttlebot/internal/topology"
1718
)
1819
@@ -74,11 +75,11 @@
7475
7576
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
7677
t.Helper()
7778
reg := registry.New(nil, []byte("key"))
7879
log := slog.New(slog.NewTextHandler(io.Discard, nil))
79
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
80
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
8081
t.Cleanup(srv.Close)
8182
return srv, "tok"
8283
}
8384
8485
// newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +86,11 @@
8586
// real registry backed by stubProvisioner, so agent registration works.
8687
func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
8788
t.Helper()
8889
reg := registry.New(newStubProvisioner(), []byte("key"))
8990
log := slog.New(slog.NewTextHandler(io.Discard, nil))
90
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
91
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
9192
t.Cleanup(srv.Close)
9293
return srv, "tok"
9394
}
9495
9596
func TestHandleProvisionChannel(t *testing.T) {
9697
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
 
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 "github.com/conflicthq/scuttlebot/internal/topology"
17 )
18
@@ -74,11 +75,11 @@
74
75 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
76 t.Helper()
77 reg := registry.New(nil, []byte("key"))
78 log := slog.New(slog.NewTextHandler(io.Discard, nil))
79 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
80 t.Cleanup(srv.Close)
81 return srv, "tok"
82 }
83
84 // newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +86,11 @@
85 // real registry backed by stubProvisioner, so agent registration works.
86 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
87 t.Helper()
88 reg := registry.New(newStubProvisioner(), []byte("key"))
89 log := slog.New(slog.NewTextHandler(io.Discard, nil))
90 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
91 t.Cleanup(srv.Close)
92 return srv, "tok"
93 }
94
95 func TestHandleProvisionChannel(t *testing.T) {
96
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
14 "github.com/conflicthq/scuttlebot/internal/auth"
15 "github.com/conflicthq/scuttlebot/internal/config"
16 "github.com/conflicthq/scuttlebot/internal/registry"
17 "github.com/conflicthq/scuttlebot/internal/topology"
18 )
19
@@ -74,11 +75,11 @@
75
76 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
77 t.Helper()
78 reg := registry.New(nil, []byte("key"))
79 log := slog.New(slog.NewTextHandler(io.Discard, nil))
80 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
81 t.Cleanup(srv.Close)
82 return srv, "tok"
83 }
84
85 // newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +86,11 @@
86 // real registry backed by stubProvisioner, so agent registration works.
87 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
88 t.Helper()
89 reg := registry.New(newStubProvisioner(), []byte("key"))
90 log := slog.New(slog.NewTextHandler(io.Discard, nil))
91 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
92 t.Cleanup(srv.Close)
93 return srv, "tok"
94 }
95
96 func TestHandleProvisionChannel(t *testing.T) {
97
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
55
"encoding/json"
66
"fmt"
77
"net/http"
88
"time"
99
10
+ "github.com/conflicthq/scuttlebot/internal/auth"
1011
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
1112
)
1213
1314
// chatBridge is the interface the API layer requires from the bridge bot.
1415
type chatBridge interface {
@@ -118,11 +119,12 @@
118119
119120
// handleChannelStream serves an SSE stream of IRC messages for a channel.
120121
// Auth is via ?token= query param because EventSource doesn't support custom headers.
121122
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122123
token := r.URL.Query().Get("token")
123
- if _, ok := s.tokens[token]; !ok {
124
+ key := s.apiKeys.Lookup(token)
125
+ if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
124126
writeError(w, http.StatusUnauthorized, "invalid or missing token")
125127
return
126128
}
127129
128130
channel := "#" + r.PathValue("channel")
129131
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9
 
10 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
11 )
12
13 // chatBridge is the interface the API layer requires from the bridge bot.
14 type chatBridge interface {
@@ -118,11 +119,12 @@
118
119 // handleChannelStream serves an SSE stream of IRC messages for a channel.
120 // Auth is via ?token= query param because EventSource doesn't support custom headers.
121 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122 token := r.URL.Query().Get("token")
123 if _, ok := s.tokens[token]; !ok {
 
124 writeError(w, http.StatusUnauthorized, "invalid or missing token")
125 return
126 }
127
128 channel := "#" + r.PathValue("channel")
129
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9
10 "github.com/conflicthq/scuttlebot/internal/auth"
11 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
12 )
13
14 // chatBridge is the interface the API layer requires from the bridge bot.
15 type chatBridge interface {
@@ -118,11 +119,12 @@
119
120 // handleChannelStream serves an SSE stream of IRC messages for a channel.
121 // Auth is via ?token= query param because EventSource doesn't support custom headers.
122 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
123 token := r.URL.Query().Get("token")
124 key := s.apiKeys.Lookup(token)
125 if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
126 writeError(w, http.StatusUnauthorized, "invalid or missing token")
127 return
128 }
129
130 channel := "#" + r.PathValue("channel")
131
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
88
"log/slog"
99
"net/http"
1010
"net/http/httptest"
1111
"testing"
1212
13
+ "github.com/conflicthq/scuttlebot/internal/auth"
1314
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
1415
"github.com/conflicthq/scuttlebot/internal/registry"
1516
)
1617
1718
type stubChatBridge struct {
@@ -42,11 +43,11 @@
4243
t.Helper()
4344
4445
bridgeStub := &stubChatBridge{}
4546
reg := registry.New(nil, []byte("test-signing-key"))
4647
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
47
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
48
+ srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
4849
defer srv.Close()
4950
5051
body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
5152
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
5253
if err != nil {
@@ -75,11 +76,11 @@
7576
t.Helper()
7677
7778
bridgeStub := &stubChatBridge{}
7879
reg := registry.New(nil, []byte("test-signing-key"))
7980
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
81
+ srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
8182
defer srv.Close()
8283
8384
body, _ := json.Marshal(map[string]string{})
8485
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
8586
if err != nil {
8687
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12
 
13 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 )
16
17 type stubChatBridge struct {
@@ -42,11 +43,11 @@
42 t.Helper()
43
44 bridgeStub := &stubChatBridge{}
45 reg := registry.New(nil, []byte("test-signing-key"))
46 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
47 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
48 defer srv.Close()
49
50 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
51 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
52 if err != nil {
@@ -75,11 +76,11 @@
75 t.Helper()
76
77 bridgeStub := &stubChatBridge{}
78 reg := registry.New(nil, []byte("test-signing-key"))
79 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
81 defer srv.Close()
82
83 body, _ := json.Marshal(map[string]string{})
84 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
85 if err != nil {
86
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12
13 "github.com/conflicthq/scuttlebot/internal/auth"
14 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 )
17
18 type stubChatBridge struct {
@@ -42,11 +43,11 @@
43 t.Helper()
44
45 bridgeStub := &stubChatBridge{}
46 reg := registry.New(nil, []byte("test-signing-key"))
47 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
48 srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
49 defer srv.Close()
50
51 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
52 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
53 if err != nil {
@@ -75,11 +76,11 @@
76 t.Helper()
77
78 bridgeStub := &stubChatBridge{}
79 reg := registry.New(nil, []byte("test-signing-key"))
80 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
81 srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
82 defer srv.Close()
83
84 body, _ := json.Marshal(map[string]string{})
85 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
86 if err != nil {
87
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
99
"net/http/httptest"
1010
"path/filepath"
1111
"testing"
1212
"time"
1313
14
+ "github.com/conflicthq/scuttlebot/internal/auth"
1415
"github.com/conflicthq/scuttlebot/internal/config"
1516
"github.com/conflicthq/scuttlebot/internal/registry"
1617
)
1718
1819
func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
2526
cfg.Ergo.DataDir = dir
2627
2728
store := NewConfigStore(path, cfg)
2829
reg := registry.New(nil, []byte("key"))
2930
log := slog.New(slog.NewTextHandler(io.Discard, nil))
30
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler())
31
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
3132
t.Cleanup(srv.Close)
3233
return srv, store
3334
}
3435
3536
func TestHandleGetConfig(t *testing.T) {
3637
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
9 "net/http/httptest"
10 "path/filepath"
11 "testing"
12 "time"
13
 
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 )
17
18 func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
25 cfg.Ergo.DataDir = dir
26
27 store := NewConfigStore(path, cfg)
28 reg := registry.New(nil, []byte("key"))
29 log := slog.New(slog.NewTextHandler(io.Discard, nil))
30 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler())
31 t.Cleanup(srv.Close)
32 return srv, store
33 }
34
35 func TestHandleGetConfig(t *testing.T) {
36
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
9 "net/http/httptest"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "github.com/conflicthq/scuttlebot/internal/auth"
15 "github.com/conflicthq/scuttlebot/internal/config"
16 "github.com/conflicthq/scuttlebot/internal/registry"
17 )
18
19 func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
26 cfg.Ergo.DataDir = dir
27
28 store := NewConfigStore(path, cfg)
29 reg := registry.New(nil, []byte("key"))
30 log := slog.New(slog.NewTextHandler(io.Discard, nil))
31 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
32 t.Cleanup(srv.Close)
33 return srv, store
34 }
35
36 func TestHandleGetConfig(t *testing.T) {
37
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
9393
if !s.admins.Authenticate(req.Username, req.Password) {
9494
writeError(w, http.StatusUnauthorized, "invalid credentials")
9595
return
9696
}
9797
98
- // Return the first API token — the shared server token.
99
- var token string
100
- for t := range s.tokens {
101
- token = t
102
- break
98
+ // Create a session API key for this admin login.
99
+ sessionName := "session:" + req.Username
100
+ token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
101
+ if err != nil {
102
+ s.log.Error("login: create session key", "err", err)
103
+ writeError(w, http.StatusInternalServerError, "failed to create session")
104
+ return
103105
}
104106
105107
writeJSON(w, http.StatusOK, map[string]string{
106108
"token": token,
107109
"username": req.Username,
108110
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Return the first API token — the shared server token.
99 var token string
100 for t := range s.tokens {
101 token = t
102 break
 
 
103 }
104
105 writeJSON(w, http.StatusOK, map[string]string{
106 "token": token,
107 "username": req.Username,
108
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Create a session API key for this admin login.
99 sessionName := "session:" + req.Username
100 token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
101 if err != nil {
102 s.log.Error("login: create session key", "err", err)
103 writeError(w, http.StatusInternalServerError, "failed to create session")
104 return
105 }
106
107 writeJSON(w, http.StatusOK, map[string]string{
108 "token": token,
109 "username": req.Username,
110
--- 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, nil, nil, "", testLog)
33
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, 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, nil, nil, "", testLog)
40
+ srv := api.New(reg, auth.TestStore(testToken), nil, 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, 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, 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, auth.TestStore(testToken), nil, nil, admins, nil, 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, auth.TestStore(testToken), nil, 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/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
11
package api
22
33
import (
4
+ "context"
45
"net/http"
56
"strings"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/auth"
69
)
710
11
+type ctxKey string
12
+
13
+const ctxAPIKey ctxKey = "apikey"
14
+
15
+// apiKeyFromContext returns the authenticated APIKey from the request context,
16
+// or nil if not authenticated.
17
+func apiKeyFromContext(ctx context.Context) *auth.APIKey {
18
+ k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey)
19
+ return k
20
+}
21
+
22
+// authMiddleware validates the Bearer token and injects the APIKey into context.
823
func (s *Server) authMiddleware(next http.Handler) http.Handler {
924
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1025
token := bearerToken(r)
1126
if token == "" {
1227
writeError(w, http.StatusUnauthorized, "missing authorization header")
1328
return
1429
}
15
- if _, ok := s.tokens[token]; !ok {
30
+ key := s.apiKeys.Lookup(token)
31
+ if key == nil {
1632
writeError(w, http.StatusUnauthorized, "invalid token")
1733
return
1834
}
19
- next.ServeHTTP(w, r)
35
+ // Update last-used timestamp in the background.
36
+ go s.apiKeys.TouchLastUsed(key.ID)
37
+
38
+ ctx := context.WithValue(r.Context(), ctxAPIKey, key)
39
+ next.ServeHTTP(w, r.WithContext(ctx))
2040
})
2141
}
42
+
43
+// requireScope returns middleware that rejects requests without the given scope.
44
+func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc {
45
+ return func(w http.ResponseWriter, r *http.Request) {
46
+ key := apiKeyFromContext(r.Context())
47
+ if key == nil {
48
+ writeError(w, http.StatusUnauthorized, "missing authentication")
49
+ return
50
+ }
51
+ if !key.HasScope(scope) {
52
+ writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope))
53
+ return
54
+ }
55
+ next(w, r)
56
+ }
57
+}
2258
2359
func bearerToken(r *http.Request) string {
2460
auth := r.Header.Get("Authorization")
2561
token, found := strings.CutPrefix(auth, "Bearer ")
2662
if !found {
2763
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
1 package api
2
3 import (
 
4 "net/http"
5 "strings"
 
 
6 )
7
 
 
 
 
 
 
 
 
 
 
 
 
8 func (s *Server) authMiddleware(next http.Handler) http.Handler {
9 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10 token := bearerToken(r)
11 if token == "" {
12 writeError(w, http.StatusUnauthorized, "missing authorization header")
13 return
14 }
15 if _, ok := s.tokens[token]; !ok {
 
16 writeError(w, http.StatusUnauthorized, "invalid token")
17 return
18 }
19 next.ServeHTTP(w, r)
 
 
 
 
20 })
21 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
23 func bearerToken(r *http.Request) string {
24 auth := r.Header.Get("Authorization")
25 token, found := strings.CutPrefix(auth, "Bearer ")
26 if !found {
27
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
1 package api
2
3 import (
4 "context"
5 "net/http"
6 "strings"
7
8 "github.com/conflicthq/scuttlebot/internal/auth"
9 )
10
11 type ctxKey string
12
13 const ctxAPIKey ctxKey = "apikey"
14
15 // apiKeyFromContext returns the authenticated APIKey from the request context,
16 // or nil if not authenticated.
17 func apiKeyFromContext(ctx context.Context) *auth.APIKey {
18 k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey)
19 return k
20 }
21
22 // authMiddleware validates the Bearer token and injects the APIKey into context.
23 func (s *Server) authMiddleware(next http.Handler) http.Handler {
24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 token := bearerToken(r)
26 if token == "" {
27 writeError(w, http.StatusUnauthorized, "missing authorization header")
28 return
29 }
30 key := s.apiKeys.Lookup(token)
31 if key == nil {
32 writeError(w, http.StatusUnauthorized, "invalid token")
33 return
34 }
35 // Update last-used timestamp in the background.
36 go s.apiKeys.TouchLastUsed(key.ID)
37
38 ctx := context.WithValue(r.Context(), ctxAPIKey, key)
39 next.ServeHTTP(w, r.WithContext(ctx))
40 })
41 }
42
43 // requireScope returns middleware that rejects requests without the given scope.
44 func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc {
45 return func(w http.ResponseWriter, r *http.Request) {
46 key := apiKeyFromContext(r.Context())
47 if key == nil {
48 writeError(w, http.StatusUnauthorized, "missing authentication")
49 return
50 }
51 if !key.HasScope(scope) {
52 writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope))
53 return
54 }
55 next(w, r)
56 }
57 }
58
59 func bearerToken(r *http.Request) string {
60 auth := r.Header.Get("Authorization")
61 token, found := strings.CutPrefix(auth, "Bearer ")
62 if !found {
63
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
77
88
import (
99
"log/slog"
1010
"net/http"
1111
12
+ "github.com/conflicthq/scuttlebot/internal/auth"
1213
"github.com/conflicthq/scuttlebot/internal/config"
1314
"github.com/conflicthq/scuttlebot/internal/registry"
1415
)
1516
1617
// Server is the scuttlebot HTTP API server.
1718
type Server struct {
1819
registry *registry.Registry
19
- tokens map[string]struct{}
20
+ apiKeys *auth.APIKeyStore
2021
log *slog.Logger
2122
bridge chatBridge // nil if bridge is disabled
2223
policies *PolicyStore // nil if not configured
2324
admins adminStore // nil if not configured
2425
llmCfg *config.LLMConfig // nil if no LLM backends configured
@@ -31,18 +32,14 @@
3132
// New creates a new API Server. Pass nil for b to disable the chat bridge.
3233
// Pass nil for admins to disable admin authentication endpoints.
3334
// Pass nil for llmCfg to disable AI/LLM management endpoints.
3435
// Pass nil for topo to disable topology provisioning endpoints.
3536
// Pass nil for cfgStore to disable config read/write endpoints.
36
-func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
37
- tokenSet := make(map[string]struct{}, len(tokens))
38
- for _, t := range tokens {
39
- tokenSet[t] = struct{}{}
40
- }
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 {
4138
return &Server{
4239
registry: reg,
43
- tokens: tokenSet,
40
+ apiKeys: apiKeys,
4441
log: log,
4542
bridge: b,
4643
policies: ps,
4744
admins: admins,
4845
llmCfg: llmCfg,
@@ -53,65 +50,84 @@
5350
}
5451
}
5552
5653
// Handler returns the HTTP handler with all routes registered.
5754
// /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
55
+// Scoped routes additionally check the API key's scopes.
5856
func (s *Server) Handler() http.Handler {
5957
apiMux := http.NewServeMux()
60
- apiMux.HandleFunc("GET /v1/status", s.handleStatus)
61
- apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62
- if s.policies != nil {
63
- apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64
- apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65
- apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66
- apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
67
- }
68
- apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
69
- apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
70
- apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
71
- apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
72
- apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
73
- apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
74
- apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
75
- apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
76
- if s.bridge != nil {
77
- apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
78
- apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
79
- apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80
- apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81
- apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82
- apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83
- apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84
- }
85
- if s.topoMgr != nil {
86
- apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
87
- apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
88
- apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
89
- }
90
- if s.cfgStore != nil {
91
- apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
92
- apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
93
- apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
94
- apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
95
- }
96
-
97
- if s.admins != nil {
98
- apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
99
- apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
100
- apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
101
- apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword)
102
- }
103
-
104
- // LLM / AI gateway endpoints.
105
- apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends)
106
- apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate)
107
- apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate)
108
- apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete)
109
- apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels)
110
- apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover)
111
- apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown)
112
- apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete)
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
+
81
+ // Channels — channels scope (read), chat scope (send).
82
+ if s.bridge != nil {
83
+ apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
84
+ apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
85
+ apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
86
+ apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
87
+ apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
88
+ apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
89
+ apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
90
+ }
91
+
92
+ // Topology — topology scope.
93
+ if s.topoMgr != nil {
94
+ apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
95
+ apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
96
+ apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
97
+ }
98
+
99
+ // Config — config scope.
100
+ if s.cfgStore != nil {
101
+ apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
102
+ apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
103
+ apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
104
+ apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
105
+ }
106
+
107
+ // Admin — admin scope.
108
+ if s.admins != nil {
109
+ apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
110
+ apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
111
+ apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
112
+ apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
113
+ }
114
+
115
+ // API key management — admin scope.
116
+ apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
117
+ apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
118
+ apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
119
+
120
+ // LLM / AI gateway — bots scope.
121
+ apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
122
+ apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
123
+ apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
124
+ apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
125
+ apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
126
+ apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
127
+ apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
128
+ apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
113129
114130
outer := http.NewServeMux()
115131
outer.HandleFunc("POST /login", s.handleLogin)
116132
outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
117133
http.Redirect(w, r, "/ui/", http.StatusFound)
118134
119135
ADDED internal/auth/apikeys.go
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
7
8 import (
9 "log/slog"
10 "net/http"
11
 
12 "github.com/conflicthq/scuttlebot/internal/config"
13 "github.com/conflicthq/scuttlebot/internal/registry"
14 )
15
16 // Server is the scuttlebot HTTP API server.
17 type Server struct {
18 registry *registry.Registry
19 tokens map[string]struct{}
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
@@ -31,18 +32,14 @@
31 // New creates a new API Server. Pass nil for b to disable the chat bridge.
32 // Pass nil for admins to disable admin authentication endpoints.
33 // Pass nil for llmCfg to disable AI/LLM management endpoints.
34 // Pass nil for topo to disable topology provisioning endpoints.
35 // Pass nil for cfgStore to disable config read/write endpoints.
36 func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
37 tokenSet := make(map[string]struct{}, len(tokens))
38 for _, t := range tokens {
39 tokenSet[t] = struct{}{}
40 }
41 return &Server{
42 registry: reg,
43 tokens: tokenSet,
44 log: log,
45 bridge: b,
46 policies: ps,
47 admins: admins,
48 llmCfg: llmCfg,
@@ -53,65 +50,84 @@
53 }
54 }
55
56 // Handler returns the HTTP handler with all routes registered.
57 // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
 
58 func (s *Server) Handler() http.Handler {
59 apiMux := http.NewServeMux()
60 apiMux.HandleFunc("GET /v1/status", s.handleStatus)
61 apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62 if s.policies != nil {
63 apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64 apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65 apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66 apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
67 }
68 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
69 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
70 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
71 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
72 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
73 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
74 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
75 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
76 if s.bridge != nil {
77 apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
78 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
79 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84 }
85 if s.topoMgr != nil {
86 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
87 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
88 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
89 }
90 if s.cfgStore != nil {
91 apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
92 apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
93 apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
94 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
95 }
96
97 if s.admins != nil {
98 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
99 apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
100 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
101 apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword)
102 }
103
104 // LLM / AI gateway endpoints.
105 apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends)
106 apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate)
107 apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate)
108 apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete)
109 apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels)
110 apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover)
111 apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown)
112 apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
114 outer := http.NewServeMux()
115 outer.HandleFunc("POST /login", s.handleLogin)
116 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
117 http.Redirect(w, r, "/ui/", http.StatusFound)
118
119 DDED internal/auth/apikeys.go
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
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
@@ -31,18 +32,14 @@
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,
@@ -53,65 +50,84 @@
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
81 // Channels — channels scope (read), chat scope (send).
82 if s.bridge != nil {
83 apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
84 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
85 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
86 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
87 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
88 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
89 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
90 }
91
92 // Topology — topology scope.
93 if s.topoMgr != nil {
94 apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
95 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
96 apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
97 }
98
99 // Config — config scope.
100 if s.cfgStore != nil {
101 apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
102 apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
103 apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
104 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
105 }
106
107 // Admin — admin scope.
108 if s.admins != nil {
109 apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
110 apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
111 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
112 apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
113 }
114
115 // API key management — admin scope.
116 apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
117 apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
118 apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
119
120 // LLM / AI gateway — bots scope.
121 apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
122 apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
123 apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
124 apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
125 apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
126 apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
127 apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
128 apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
129
130 outer := http.NewServeMux()
131 outer.HandleFunc("POST /login", s.handleLogin)
132 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
133 http.Redirect(w, r, "/ui/", http.StatusFound)
134
135 DDED internal/auth/apikeys.go
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,211 @@
1
+package auth
2
+
3
+import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/oklog/ulid/v2"
15
+)
16
+
17
+// Scope represents a permission scope for an API key.
18
+type Scope string
19
+
20
+const (
21
+ ScopeAdmin Scope = "admin" // full access
22
+ ScopeAgents Scope = "agents" // agent registration, rotation, revocation
23
+ ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
24
+ ScopeTopology Scope = "topology" // channel provisioning, topology management
25
+ ScopeBots Scope = "bots" // bot configuration, start/stop
26
+ ScopeConfig Scope = "config" // server config read/write
27
+ ScopeRead Scope = "read" // read-only access to all GET endpoints
28
+ ScopeChat Scope = "chat" // send/receive messages only
29
+)
30
+
31
+// ValidScopes is the set of all recognised scopes.
32
+var ValidScopes = map[Scope]bool{
33
+ ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
34
+ ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
35
+ ScopeRead: true, ScopeChat: true,
36
+}
37
+
38
+// APIKey is a single API key record.
39
+type APIKey struct {
40
+ ID string `json:"id"`
41
+ Name string `json:"name"`
42
+ Hash string `json:"hash"` // SHA-256 of the plaintext token
43
+ Scopes []Scope `json:"scopes"`
44
+ CreatedAt time.Time `json:"created_at"`
45
+ LastUsed time.Time `json:"last_used,omitempty"`
46
+ ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
47
+ Active bool `json:"active"`
48
+}
49
+
50
+// HasScope reports whether the key has the given scope (or admin, which implies all).
51
+func (k *APIKey) HasScope(s Scope) bool {
52
+ for _, scope := range k.Scopes {
53
+ if scope == ScopeAdmin || scope == s {
54
+ return true
55
+ }
56
+ }
57
+ return false
58
+}
59
+
60
+// IsExpired reports whether the key has passed its expiry time.
61
+func (k *APIKey) IsExpired() bool {
62
+ return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
63
+}
64
+
65
+// APIKeyStore persists API keys to a JSON file.
66
+type APIKeyStore struct {
67
+ mu sync.RWMutex
68
+ path string
69
+ data []APIKey
70
+}
71
+
72
+// NewAPIKeyStore loads (or creates) the API key store at the given path.
73
+func NewAPIKeyStore(path string) (*APIKeyStore, error) {
74
+ s := &APIKeyStore{path: path}
75
+ if err := s.load(); err != nil {
76
+ return nil, err
77
+ }
78
+ return s, nil
79
+}
80
+
81
+// Create generates a new API key with the given name and scopes.
82
+// Returns the plaintext token (shown only once) and the stored key record.
83
+func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
84
+ s.mu.Lock()
85
+ defer s.mu.Unlock()
86
+
87
+ token, err := genToken()
88
+ if err != nil {
89
+ return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
90
+ }
91
+
92
+ key = APIKey{
93
+ ID: newULID(),
94
+ Name: name,
95
+ Hash: hashToken(token),
96
+ Scopes: scopes,
97
+ CreatedAt: time.Now().UTC(),
98
+ ExpiresAt: expiresAt,
99
+ Active: true,
100
+ }
101
+ s.data = append(s.data, key)
102
+ if err := s.save(); err != nil {
103
+ // Roll back.
104
+ s.data = s.data[:len(s.data)-1]
105
+ return "", APIKey{}, err
106
+ }
107
+ return token, key, nil
108
+}
109
+
110
+// Insert adds a pre-built API key with a known plaintext token.
111
+// Used for migrating the startup token into the store.
112
+func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
113
+ s.mu.Lock()
114
+ defer s.mu.Unlock()
115
+
116
+ key := APIKey{
117
+ ID: newULID(),
118
+ Name: name,
119
+ Hash: hashToken(plaintext),
120
+ Scopes: scopes,
121
+ CreatedAt: time.Now().UTC(),
122
+ Active: true,
123
+ }
124
+ s.data = append(s.data, key)
125
+ if err := s.save(); err != nil {
126
+ s.data = s.data[:len(s.data)-1]
127
+ return APIKey{}, err
128
+ }
129
+ return key, nil
130
+}
131
+
132
+// Lookup finds an active, non-expired key by plaintext token.
133
+// Returns nil if no match.
134
+func (s *APIKeyStore) Lookup(token string) *APIKey {
135
+ hash := hashToken(token)
136
+ s.mu.RLock()
137
+ defer s.mu.RUnlock()
138
+ for i := range s.data {
139
+ if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
140
+ k := s.data[i]
141
+ return &k
142
+ }
143
+ }
144
+ return nil
145
+}
146
+
147
+// TouchLastUsed updates the last-used timestamp for a key by ID.
148
+func (s *APIKeyStore) TouchLastUsed(id string) {
149
+ s.mu.Lock()
150
+ defer s.mu.Unlock()
151
+ for i := range s.data {
152
+ if s.data[i].ID == id {
153
+ s.data[i].LastUsed = time.Now().UTC()
154
+ _ = s.save() // best-effort persistence
155
+ return
156
+ }
157
+ }
158
+}
159
+
160
+// Get returns a key by ID, or nil if not found.
161
+func (s *APIKeyStore) Get(id string) *APIKey {
162
+ s.mu.RLock()
163
+ defer s.mu.RUnlock()
164
+ for i := range s.data {
165
+ if s.data[i].ID == id {
166
+ k := s.data[i]
167
+ return &k
168
+ }
169
+ }
170
+ return nil
171
+}
172
+
173
+// List returns all keys (active and revoked).
174
+func (s *APIKeyStore) List() []APIKey {
175
+ s.mu.RLock()
176
+ defer s.mu.RUnlock()
177
+ out := make([]APIKey, len(s.data))
178
+ copy(out, s.data)
179
+ return out
180
+}
181
+
182
+// Revoke deactivates a key by ID.
183
+func (s *APIKeyStore) Revoke(id string) error {
184
+ s.mu.Lock()
185
+ defer s.mu.Unlock()
186
+ for i := range s.data {
187
+ if s.data[i].ID == id {
188
+ if !s.data[i].Active {
189
+ return fmt.Errorf("apikeys: key %q already revoked", id)
190
+ }
191
+ s.data[i].Active = false
192
+ return s.save()
193
+ }
194
+ }
195
+ return fmt.Errorf("apikeys: key %q not found", id)
196
+}
197
+
198
+// Lookup (TokenValidator interface) reports whether the token is valid.
199
+// Satisfies the mcp.TokenValidator interface.
200
+func (s *APIKeyStore) ValidToken(token string) bool {
201
+ return s.Lookup(token) != nil
202
+}
203
+
204
+// TestStore creates an in-memory APIKeyStore with a single admin-scope key
205
+// for the given token. Intended for tests only — does not persist to disk.
206
+func TestStore(token string) *APIKeyStore {
207
+ s := &APIKeyStore{path: "", data: []APIKey{{
208
+ ID: "test-key",
209
+ Name: "test",
210
+ Hash: hashToken(token),
211
+ Scopes: []Scop
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,211 @@
1 package auth
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "os"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/oklog/ulid/v2"
15 )
16
17 // Scope represents a permission scope for an API key.
18 type Scope string
19
20 const (
21 ScopeAdmin Scope = "admin" // full access
22 ScopeAgents Scope = "agents" // agent registration, rotation, revocation
23 ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
24 ScopeTopology Scope = "topology" // channel provisioning, topology management
25 ScopeBots Scope = "bots" // bot configuration, start/stop
26 ScopeConfig Scope = "config" // server config read/write
27 ScopeRead Scope = "read" // read-only access to all GET endpoints
28 ScopeChat Scope = "chat" // send/receive messages only
29 )
30
31 // ValidScopes is the set of all recognised scopes.
32 var ValidScopes = map[Scope]bool{
33 ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
34 ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
35 ScopeRead: true, ScopeChat: true,
36 }
37
38 // APIKey is a single API key record.
39 type APIKey struct {
40 ID string `json:"id"`
41 Name string `json:"name"`
42 Hash string `json:"hash"` // SHA-256 of the plaintext token
43 Scopes []Scope `json:"scopes"`
44 CreatedAt time.Time `json:"created_at"`
45 LastUsed time.Time `json:"last_used,omitempty"`
46 ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
47 Active bool `json:"active"`
48 }
49
50 // HasScope reports whether the key has the given scope (or admin, which implies all).
51 func (k *APIKey) HasScope(s Scope) bool {
52 for _, scope := range k.Scopes {
53 if scope == ScopeAdmin || scope == s {
54 return true
55 }
56 }
57 return false
58 }
59
60 // IsExpired reports whether the key has passed its expiry time.
61 func (k *APIKey) IsExpired() bool {
62 return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
63 }
64
65 // APIKeyStore persists API keys to a JSON file.
66 type APIKeyStore struct {
67 mu sync.RWMutex
68 path string
69 data []APIKey
70 }
71
72 // NewAPIKeyStore loads (or creates) the API key store at the given path.
73 func NewAPIKeyStore(path string) (*APIKeyStore, error) {
74 s := &APIKeyStore{path: path}
75 if err := s.load(); err != nil {
76 return nil, err
77 }
78 return s, nil
79 }
80
81 // Create generates a new API key with the given name and scopes.
82 // Returns the plaintext token (shown only once) and the stored key record.
83 func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
84 s.mu.Lock()
85 defer s.mu.Unlock()
86
87 token, err := genToken()
88 if err != nil {
89 return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
90 }
91
92 key = APIKey{
93 ID: newULID(),
94 Name: name,
95 Hash: hashToken(token),
96 Scopes: scopes,
97 CreatedAt: time.Now().UTC(),
98 ExpiresAt: expiresAt,
99 Active: true,
100 }
101 s.data = append(s.data, key)
102 if err := s.save(); err != nil {
103 // Roll back.
104 s.data = s.data[:len(s.data)-1]
105 return "", APIKey{}, err
106 }
107 return token, key, nil
108 }
109
110 // Insert adds a pre-built API key with a known plaintext token.
111 // Used for migrating the startup token into the store.
112 func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115
116 key := APIKey{
117 ID: newULID(),
118 Name: name,
119 Hash: hashToken(plaintext),
120 Scopes: scopes,
121 CreatedAt: time.Now().UTC(),
122 Active: true,
123 }
124 s.data = append(s.data, key)
125 if err := s.save(); err != nil {
126 s.data = s.data[:len(s.data)-1]
127 return APIKey{}, err
128 }
129 return key, nil
130 }
131
132 // Lookup finds an active, non-expired key by plaintext token.
133 // Returns nil if no match.
134 func (s *APIKeyStore) Lookup(token string) *APIKey {
135 hash := hashToken(token)
136 s.mu.RLock()
137 defer s.mu.RUnlock()
138 for i := range s.data {
139 if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
140 k := s.data[i]
141 return &k
142 }
143 }
144 return nil
145 }
146
147 // TouchLastUsed updates the last-used timestamp for a key by ID.
148 func (s *APIKeyStore) TouchLastUsed(id string) {
149 s.mu.Lock()
150 defer s.mu.Unlock()
151 for i := range s.data {
152 if s.data[i].ID == id {
153 s.data[i].LastUsed = time.Now().UTC()
154 _ = s.save() // best-effort persistence
155 return
156 }
157 }
158 }
159
160 // Get returns a key by ID, or nil if not found.
161 func (s *APIKeyStore) Get(id string) *APIKey {
162 s.mu.RLock()
163 defer s.mu.RUnlock()
164 for i := range s.data {
165 if s.data[i].ID == id {
166 k := s.data[i]
167 return &k
168 }
169 }
170 return nil
171 }
172
173 // List returns all keys (active and revoked).
174 func (s *APIKeyStore) List() []APIKey {
175 s.mu.RLock()
176 defer s.mu.RUnlock()
177 out := make([]APIKey, len(s.data))
178 copy(out, s.data)
179 return out
180 }
181
182 // Revoke deactivates a key by ID.
183 func (s *APIKeyStore) Revoke(id string) error {
184 s.mu.Lock()
185 defer s.mu.Unlock()
186 for i := range s.data {
187 if s.data[i].ID == id {
188 if !s.data[i].Active {
189 return fmt.Errorf("apikeys: key %q already revoked", id)
190 }
191 s.data[i].Active = false
192 return s.save()
193 }
194 }
195 return fmt.Errorf("apikeys: key %q not found", id)
196 }
197
198 // Lookup (TokenValidator interface) reports whether the token is valid.
199 // Satisfies the mcp.TokenValidator interface.
200 func (s *APIKeyStore) ValidToken(token string) bool {
201 return s.Lookup(token) != nil
202 }
203
204 // TestStore creates an in-memory APIKeyStore with a single admin-scope key
205 // for the given token. Intended for tests only — does not persist to disk.
206 func TestStore(token string) *APIKeyStore {
207 s := &APIKeyStore{path: "", data: []APIKey{{
208 ID: "test-key",
209 Name: "test",
210 Hash: hashToken(token),
211 Scopes: []Scop
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
5252
type ChannelInfo struct {
5353
Name string `json:"name"`
5454
Topic string `json:"topic,omitempty"`
5555
Count int `json:"count"`
5656
}
57
+
58
+// TokenValidator validates API tokens.
59
+type TokenValidator interface {
60
+ ValidToken(token string) bool
61
+}
5762
5863
// Server is the MCP server.
5964
type Server struct {
6065
registry *registry.Registry
6166
channels ChannelLister
6267
sender Sender // optional — send_message returns error if nil
6368
history HistoryQuerier // optional — get_history returns error if nil
64
- tokens map[string]struct{}
69
+ tokens TokenValidator
6570
log *slog.Logger
6671
}
6772
6873
// New creates an MCP Server.
69
-func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server {
70
- t := make(map[string]struct{}, len(tokens))
71
- for _, tok := range tokens {
72
- t[tok] = struct{}{}
73
- }
74
+func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
7475
return &Server{
7576
registry: reg,
7677
channels: channels,
77
- tokens: t,
78
+ tokens: tokens,
7879
log: log,
7980
}
8081
}
8182
8283
// WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
101102
// --- Auth ---
102103
103104
func (s *Server) authMiddleware(next http.Handler) http.Handler {
104105
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105106
token := bearerToken(r)
106
- if _, ok := s.tokens[token]; !ok {
107
+ if !s.tokens.ValidToken(token) {
107108
writeRPCError(w, nil, -32001, "unauthorized")
108109
return
109110
}
110111
next.ServeHTTP(w, r)
111112
})
112113
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
52 type ChannelInfo struct {
53 Name string `json:"name"`
54 Topic string `json:"topic,omitempty"`
55 Count int `json:"count"`
56 }
 
 
 
 
 
57
58 // Server is the MCP server.
59 type Server struct {
60 registry *registry.Registry
61 channels ChannelLister
62 sender Sender // optional — send_message returns error if nil
63 history HistoryQuerier // optional — get_history returns error if nil
64 tokens map[string]struct{}
65 log *slog.Logger
66 }
67
68 // New creates an MCP Server.
69 func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server {
70 t := make(map[string]struct{}, len(tokens))
71 for _, tok := range tokens {
72 t[tok] = struct{}{}
73 }
74 return &Server{
75 registry: reg,
76 channels: channels,
77 tokens: t,
78 log: log,
79 }
80 }
81
82 // WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
101 // --- Auth ---
102
103 func (s *Server) authMiddleware(next http.Handler) http.Handler {
104 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105 token := bearerToken(r)
106 if _, ok := s.tokens[token]; !ok {
107 writeRPCError(w, nil, -32001, "unauthorized")
108 return
109 }
110 next.ServeHTTP(w, r)
111 })
112
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
52 type ChannelInfo struct {
53 Name string `json:"name"`
54 Topic string `json:"topic,omitempty"`
55 Count int `json:"count"`
56 }
57
58 // TokenValidator validates API tokens.
59 type TokenValidator interface {
60 ValidToken(token string) bool
61 }
62
63 // Server is the MCP server.
64 type Server struct {
65 registry *registry.Registry
66 channels ChannelLister
67 sender Sender // optional — send_message returns error if nil
68 history HistoryQuerier // optional — get_history returns error if nil
69 tokens TokenValidator
70 log *slog.Logger
71 }
72
73 // New creates an MCP Server.
74 func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
 
 
 
 
75 return &Server{
76 registry: reg,
77 channels: channels,
78 tokens: tokens,
79 log: log,
80 }
81 }
82
83 // WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
102 // --- Auth ---
103
104 func (s *Server) authMiddleware(next http.Handler) http.Handler {
105 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106 token := bearerToken(r)
107 if !s.tokens.ValidToken(token) {
108 writeRPCError(w, nil, -32001, "unauthorized")
109 return
110 }
111 next.ServeHTTP(w, r)
112 })
113
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
1919
var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
2020
2121
const testToken = "test-mcp-token"
2222
2323
// --- mocks ---
24
+
25
+type tokenSet map[string]struct{}
26
+
27
+func (t tokenSet) ValidToken(tok string) bool {
28
+ _, ok := t[tok]
29
+ return ok
30
+}
2431
2532
type mockProvisioner struct {
2633
mu sync.Mutex
2734
accounts map[string]string
2835
}
@@ -93,11 +100,11 @@
93100
hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94101
"#fleet": {
95102
{Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96103
},
97104
}}
98
- srv := mcp.New(reg, channels, []string{testToken}, testLog).
105
+ srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
99106
WithSender(sender).
100107
WithHistory(hist)
101108
return httptest.NewServer(srv.Handler())
102109
}
103110
104111
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
 
 
 
 
 
 
 
24
25 type mockProvisioner struct {
26 mu sync.Mutex
27 accounts map[string]string
28 }
@@ -93,11 +100,11 @@
93 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94 "#fleet": {
95 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96 },
97 }}
98 srv := mcp.New(reg, channels, []string{testToken}, testLog).
99 WithSender(sender).
100 WithHistory(hist)
101 return httptest.NewServer(srv.Handler())
102 }
103
104
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
24
25 type tokenSet map[string]struct{}
26
27 func (t tokenSet) ValidToken(tok string) bool {
28 _, ok := t[tok]
29 return ok
30 }
31
32 type mockProvisioner struct {
33 mu sync.Mutex
34 accounts map[string]string
35 }
@@ -93,11 +100,11 @@
100 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
101 "#fleet": {
102 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
103 },
104 }}
105 srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
106 WithSender(sender).
107 WithHistory(hist)
108 return httptest.NewServer(srv.Handler())
109 }
110
111

Keyboard Shortcuts

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