ScuttleBot
Merge pull request #139 from ConflictHQ/feature/124-api-key-management feat: API key management — per-consumer tokens with scoped permissions
Commit
68677f99845f236e1486ca67634860058baa89b680ba70258ba9e02e53ee0d8d
Parent
c189ae5199d6e33…
19 files changed
+20
-7
+20
-7
+20
+99
+2
-1
+125
+3
-2
+3
-1
+3
-2
+2
-1
+7
-5
+2
-2
+38
-2
+76
-60
+94
+94
+288
+9
-8
+8
-1
~
cmd/scuttlebot/main.go
~
cmd/scuttlebot/main.go
~
cmd/scuttlectl/internal/apiclient/apiclient.go
~
cmd/scuttlectl/main.go
~
internal/api/api_test.go
~
internal/api/apikeys.go
~
internal/api/channels_topology_test.go
~
internal/api/chat.go
~
internal/api/chat_test.go
~
internal/api/config_handlers_test.go
~
internal/api/login.go
~
internal/api/login_test.go
~
internal/api/middleware.go
~
internal/api/server.go
~
internal/api/ui/index.html
~
internal/api/ui/index.html
~
internal/auth/apikeys.go
~
internal/mcp/mcp.go
~
internal/mcp/mcp_test.go
+20
-7
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -138,18 +138,31 @@ | ||
| 138 | 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | 139 | log.Error("registry load", "err", err) |
| 140 | 140 | os.Exit(1) |
| 141 | 141 | } |
| 142 | 142 | |
| 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")) | |
| 145 | 145 | if err != nil { |
| 146 | - log.Error("api token", "err", err) | |
| 146 | + log.Error("api key store", "err", err) | |
| 147 | 147 | os.Exit(1) |
| 148 | 148 | } |
| 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 | + } | |
| 151 | 164 | |
| 152 | 165 | // Start bridge bot (powers the web chat UI). |
| 153 | 166 | var bridgeBot *bridge.Bot |
| 154 | 167 | if cfg.Bridge.Enabled { |
| 155 | 168 | if cfg.Bridge.Password == "" { |
| @@ -354,11 +367,11 @@ | ||
| 354 | 367 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 355 | 368 | var topoIface api.TopologyManager |
| 356 | 369 | if topoMgr != nil { |
| 357 | 370 | topoIface = topoMgr |
| 358 | 371 | } |
| 359 | - apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 372 | + apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 360 | 373 | handler := apiSrv.Handler() |
| 361 | 374 | |
| 362 | 375 | var httpServer, tlsServer *http.Server |
| 363 | 376 | |
| 364 | 377 | if cfg.TLS.Domain != "" { |
| @@ -420,11 +433,11 @@ | ||
| 420 | 433 | } |
| 421 | 434 | }() |
| 422 | 435 | } |
| 423 | 436 | |
| 424 | 437 | // Start MCP server. |
| 425 | - mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) | |
| 438 | + mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) | |
| 426 | 439 | mcpServer := &http.Server{ |
| 427 | 440 | Addr: cfg.MCPAddr, |
| 428 | 441 | Handler: mcpSrv.Handler(), |
| 429 | 442 | } |
| 430 | 443 | go func() { |
| 431 | 444 |
| --- 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 == "" { |
| @@ -354,11 +367,11 @@ | |
| 354 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 355 | var topoIface api.TopologyManager |
| 356 | if topoMgr != nil { |
| 357 | topoIface = topoMgr |
| 358 | } |
| 359 | apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 360 | handler := apiSrv.Handler() |
| 361 | |
| 362 | var httpServer, tlsServer *http.Server |
| 363 | |
| 364 | if cfg.TLS.Domain != "" { |
| @@ -420,11 +433,11 @@ | |
| 420 | } |
| 421 | }() |
| 422 | } |
| 423 | |
| 424 | // Start MCP server. |
| 425 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) |
| 426 | mcpServer := &http.Server{ |
| 427 | Addr: cfg.MCPAddr, |
| 428 | Handler: mcpSrv.Handler(), |
| 429 | } |
| 430 | go func() { |
| 431 |
| --- 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 == "" { |
| @@ -354,11 +367,11 @@ | |
| 367 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 368 | var topoIface api.TopologyManager |
| 369 | if topoMgr != nil { |
| 370 | topoIface = topoMgr |
| 371 | } |
| 372 | apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 373 | handler := apiSrv.Handler() |
| 374 | |
| 375 | var httpServer, tlsServer *http.Server |
| 376 | |
| 377 | if cfg.TLS.Domain != "" { |
| @@ -420,11 +433,11 @@ | |
| 433 | } |
| 434 | }() |
| 435 | } |
| 436 | |
| 437 | // Start MCP server. |
| 438 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) |
| 439 | mcpServer := &http.Server{ |
| 440 | Addr: cfg.MCPAddr, |
| 441 | Handler: mcpSrv.Handler(), |
| 442 | } |
| 443 | go func() { |
| 444 |
+20
-7
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -138,18 +138,31 @@ | ||
| 138 | 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | 139 | log.Error("registry load", "err", err) |
| 140 | 140 | os.Exit(1) |
| 141 | 141 | } |
| 142 | 142 | |
| 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")) | |
| 145 | 145 | if err != nil { |
| 146 | - log.Error("api token", "err", err) | |
| 146 | + log.Error("api key store", "err", err) | |
| 147 | 147 | os.Exit(1) |
| 148 | 148 | } |
| 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 | + } | |
| 151 | 164 | |
| 152 | 165 | // Start bridge bot (powers the web chat UI). |
| 153 | 166 | var bridgeBot *bridge.Bot |
| 154 | 167 | if cfg.Bridge.Enabled { |
| 155 | 168 | if cfg.Bridge.Password == "" { |
| @@ -354,11 +367,11 @@ | ||
| 354 | 367 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 355 | 368 | var topoIface api.TopologyManager |
| 356 | 369 | if topoMgr != nil { |
| 357 | 370 | topoIface = topoMgr |
| 358 | 371 | } |
| 359 | - apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 372 | + apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 360 | 373 | handler := apiSrv.Handler() |
| 361 | 374 | |
| 362 | 375 | var httpServer, tlsServer *http.Server |
| 363 | 376 | |
| 364 | 377 | if cfg.TLS.Domain != "" { |
| @@ -420,11 +433,11 @@ | ||
| 420 | 433 | } |
| 421 | 434 | }() |
| 422 | 435 | } |
| 423 | 436 | |
| 424 | 437 | // Start MCP server. |
| 425 | - mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) | |
| 438 | + mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) | |
| 426 | 439 | mcpServer := &http.Server{ |
| 427 | 440 | Addr: cfg.MCPAddr, |
| 428 | 441 | Handler: mcpSrv.Handler(), |
| 429 | 442 | } |
| 430 | 443 | go func() { |
| 431 | 444 |
| --- 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 == "" { |
| @@ -354,11 +367,11 @@ | |
| 354 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 355 | var topoIface api.TopologyManager |
| 356 | if topoMgr != nil { |
| 357 | topoIface = topoMgr |
| 358 | } |
| 359 | apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 360 | handler := apiSrv.Handler() |
| 361 | |
| 362 | var httpServer, tlsServer *http.Server |
| 363 | |
| 364 | if cfg.TLS.Domain != "" { |
| @@ -420,11 +433,11 @@ | |
| 420 | } |
| 421 | }() |
| 422 | } |
| 423 | |
| 424 | // Start MCP server. |
| 425 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) |
| 426 | mcpServer := &http.Server{ |
| 427 | Addr: cfg.MCPAddr, |
| 428 | Handler: mcpSrv.Handler(), |
| 429 | } |
| 430 | go func() { |
| 431 |
| --- 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 == "" { |
| @@ -354,11 +367,11 @@ | |
| 367 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 368 | var topoIface api.TopologyManager |
| 369 | if topoMgr != nil { |
| 370 | topoIface = topoMgr |
| 371 | } |
| 372 | apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 373 | handler := apiSrv.Handler() |
| 374 | |
| 375 | var httpServer, tlsServer *http.Server |
| 376 | |
| 377 | if cfg.TLS.Domain != "" { |
| @@ -420,11 +433,11 @@ | |
| 433 | } |
| 434 | }() |
| 435 | } |
| 436 | |
| 437 | // Start MCP server. |
| 438 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) |
| 439 | mcpServer := &http.Server{ |
| 440 | Addr: cfg.MCPAddr, |
| 441 | Handler: mcpSrv.Handler(), |
| 442 | } |
| 443 | go func() { |
| 444 |
| --- cmd/scuttlectl/internal/apiclient/apiclient.go | ||
| +++ cmd/scuttlectl/internal/apiclient/apiclient.go | ||
| @@ -137,10 +137,30 @@ | ||
| 137 | 137 | // RemoveAdmin sends DELETE /v1/admins/{username}. |
| 138 | 138 | func (c *Client) RemoveAdmin(username string) error { |
| 139 | 139 | _, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
| 140 | 140 | return err |
| 141 | 141 | } |
| 142 | + | |
| 143 | +// ListAPIKeys returns GET /v1/api-keys. | |
| 144 | +func (c *Client) ListAPIKeys() (json.RawMessage, error) { | |
| 145 | + return c.get("/v1/api-keys") | |
| 146 | +} | |
| 147 | + | |
| 148 | +// CreateAPIKey sends POST /v1/api-keys. | |
| 149 | +func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) { | |
| 150 | + body := map[string]any{"name": name, "scopes": scopes} | |
| 151 | + if expiresIn != "" { | |
| 152 | + body["expires_in"] = expiresIn | |
| 153 | + } | |
| 154 | + return c.post("/v1/api-keys", body) | |
| 155 | +} | |
| 156 | + | |
| 157 | +// RevokeAPIKey sends DELETE /v1/api-keys/{id}. | |
| 158 | +func (c *Client) RevokeAPIKey(id string) error { | |
| 159 | + _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id) | |
| 160 | + return err | |
| 161 | +} | |
| 142 | 162 | |
| 143 | 163 | // SetAdminPassword sends PUT /v1/admins/{username}/password. |
| 144 | 164 | func (c *Client) SetAdminPassword(username, password string) error { |
| 145 | 165 | _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
| 146 | 166 | return err |
| 147 | 167 |
| --- cmd/scuttlectl/internal/apiclient/apiclient.go | |
| +++ cmd/scuttlectl/internal/apiclient/apiclient.go | |
| @@ -137,10 +137,30 @@ | |
| 137 | // RemoveAdmin sends DELETE /v1/admins/{username}. |
| 138 | func (c *Client) RemoveAdmin(username string) error { |
| 139 | _, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
| 140 | return err |
| 141 | } |
| 142 | |
| 143 | // SetAdminPassword sends PUT /v1/admins/{username}/password. |
| 144 | func (c *Client) SetAdminPassword(username, password string) error { |
| 145 | _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
| 146 | return err |
| 147 |
| --- cmd/scuttlectl/internal/apiclient/apiclient.go | |
| +++ cmd/scuttlectl/internal/apiclient/apiclient.go | |
| @@ -137,10 +137,30 @@ | |
| 137 | // RemoveAdmin sends DELETE /v1/admins/{username}. |
| 138 | func (c *Client) RemoveAdmin(username string) error { |
| 139 | _, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
| 140 | return err |
| 141 | } |
| 142 | |
| 143 | // ListAPIKeys returns GET /v1/api-keys. |
| 144 | func (c *Client) ListAPIKeys() (json.RawMessage, error) { |
| 145 | return c.get("/v1/api-keys") |
| 146 | } |
| 147 | |
| 148 | // CreateAPIKey sends POST /v1/api-keys. |
| 149 | func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) { |
| 150 | body := map[string]any{"name": name, "scopes": scopes} |
| 151 | if expiresIn != "" { |
| 152 | body["expires_in"] = expiresIn |
| 153 | } |
| 154 | return c.post("/v1/api-keys", body) |
| 155 | } |
| 156 | |
| 157 | // RevokeAPIKey sends DELETE /v1/api-keys/{id}. |
| 158 | func (c *Client) RevokeAPIKey(id string) error { |
| 159 | _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id) |
| 160 | return err |
| 161 | } |
| 162 | |
| 163 | // SetAdminPassword sends PUT /v1/admins/{username}/password. |
| 164 | func (c *Client) SetAdminPassword(username, password string) error { |
| 165 | _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
| 166 | return err |
| 167 |
+99
| --- cmd/scuttlectl/main.go | ||
| +++ cmd/scuttlectl/main.go | ||
| @@ -108,10 +108,28 @@ | ||
| 108 | 108 | requireArgs(args, 3, "scuttlectl admin passwd <username>") |
| 109 | 109 | cmdAdminPasswd(api, args[2]) |
| 110 | 110 | default: |
| 111 | 111 | fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1]) |
| 112 | 112 | os.Exit(1) |
| 113 | + } | |
| 114 | + case "api-key", "api-keys": | |
| 115 | + if len(args) < 2 { | |
| 116 | + fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n") | |
| 117 | + os.Exit(1) | |
| 118 | + } | |
| 119 | + switch args[1] { | |
| 120 | + case "list": | |
| 121 | + cmdAPIKeyList(api, *jsonFlag) | |
| 122 | + case "create": | |
| 123 | + requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>") | |
| 124 | + cmdAPIKeyCreate(api, args[2:], *jsonFlag) | |
| 125 | + case "revoke": | |
| 126 | + requireArgs(args, 3, "scuttlectl api-key revoke <id>") | |
| 127 | + cmdAPIKeyRevoke(api, args[2]) | |
| 128 | + default: | |
| 129 | + fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1]) | |
| 130 | + os.Exit(1) | |
| 113 | 131 | } |
| 114 | 132 | case "channels", "channel": |
| 115 | 133 | if len(args) < 2 { |
| 116 | 134 | fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n") |
| 117 | 135 | os.Exit(1) |
| @@ -491,10 +509,88 @@ | ||
| 491 | 509 | fmt.Fprintf(tw, "password\t%s\n", creds.Password) |
| 492 | 510 | fmt.Fprintf(tw, "server\t%s\n", creds.Server) |
| 493 | 511 | tw.Flush() |
| 494 | 512 | fmt.Println("\nStore this password — it will not be shown again.") |
| 495 | 513 | } |
| 514 | + | |
| 515 | +func cmdAPIKeyList(api *apiclient.Client, asJSON bool) { | |
| 516 | + raw, err := api.ListAPIKeys() | |
| 517 | + die(err) | |
| 518 | + if asJSON { | |
| 519 | + printJSON(raw) | |
| 520 | + return | |
| 521 | + } | |
| 522 | + | |
| 523 | + var keys []struct { | |
| 524 | + ID string `json:"id"` | |
| 525 | + Name string `json:"name"` | |
| 526 | + Scopes []string `json:"scopes"` | |
| 527 | + CreatedAt string `json:"created_at"` | |
| 528 | + LastUsed *string `json:"last_used"` | |
| 529 | + ExpiresAt *string `json:"expires_at"` | |
| 530 | + Active bool `json:"active"` | |
| 531 | + } | |
| 532 | + must(json.Unmarshal(raw, &keys)) | |
| 533 | + | |
| 534 | + if len(keys) == 0 { | |
| 535 | + fmt.Println("no API keys") | |
| 536 | + return | |
| 537 | + } | |
| 538 | + | |
| 539 | + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 540 | + fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED") | |
| 541 | + for _, k := range keys { | |
| 542 | + lastUsed := "-" | |
| 543 | + if k.LastUsed != nil { | |
| 544 | + lastUsed = *k.LastUsed | |
| 545 | + } | |
| 546 | + status := "yes" | |
| 547 | + if !k.Active { | |
| 548 | + status = "revoked" | |
| 549 | + } | |
| 550 | + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed) | |
| 551 | + } | |
| 552 | + tw.Flush() | |
| 553 | +} | |
| 554 | + | |
| 555 | +func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) { | |
| 556 | + fs := flag.NewFlagSet("api-key create", flag.ExitOnError) | |
| 557 | + nameFlag := fs.String("name", "", "key name (required)") | |
| 558 | + scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)") | |
| 559 | + expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)") | |
| 560 | + _ = fs.Parse(args) | |
| 561 | + | |
| 562 | + if *nameFlag == "" || *scopesFlag == "" { | |
| 563 | + fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]") | |
| 564 | + os.Exit(1) | |
| 565 | + } | |
| 566 | + | |
| 567 | + scopes := strings.Split(*scopesFlag, ",") | |
| 568 | + raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag) | |
| 569 | + die(err) | |
| 570 | + | |
| 571 | + if asJSON { | |
| 572 | + printJSON(raw) | |
| 573 | + return | |
| 574 | + } | |
| 575 | + | |
| 576 | + var key struct { | |
| 577 | + ID string `json:"id"` | |
| 578 | + Name string `json:"name"` | |
| 579 | + Token string `json:"token"` | |
| 580 | + } | |
| 581 | + must(json.Unmarshal(raw, &key)) | |
| 582 | + | |
| 583 | + fmt.Printf("API key created: %s\n\n", key.Name) | |
| 584 | + fmt.Printf(" Token: %s\n\n", key.Token) | |
| 585 | + fmt.Println("Store this token — it will not be shown again.") | |
| 586 | +} | |
| 587 | + | |
| 588 | +func cmdAPIKeyRevoke(api *apiclient.Client, id string) { | |
| 589 | + die(api.RevokeAPIKey(id)) | |
| 590 | + fmt.Printf("API key revoked: %s\n", id) | |
| 591 | +} | |
| 496 | 592 | |
| 497 | 593 | func usage() { |
| 498 | 594 | fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI |
| 499 | 595 | |
| 500 | 596 | Usage: |
| @@ -526,10 +622,13 @@ | ||
| 526 | 622 | backend rename <old> <new> rename a backend |
| 527 | 623 | admin list list admin accounts |
| 528 | 624 | admin add <username> add admin (prompts for password) |
| 529 | 625 | admin remove <username> remove admin |
| 530 | 626 | admin passwd <username> change admin password (prompts) |
| 627 | + api-key list list API keys | |
| 628 | + api-key create --name <name> --scopes <s1,s2> [--expires 720h] | |
| 629 | + api-key revoke <id> revoke an API key | |
| 531 | 630 | `, version) |
| 532 | 631 | } |
| 533 | 632 | |
| 534 | 633 | func printJSON(raw json.RawMessage) { |
| 535 | 634 | var buf []byte |
| 536 | 635 |
| --- cmd/scuttlectl/main.go | |
| +++ cmd/scuttlectl/main.go | |
| @@ -108,10 +108,28 @@ | |
| 108 | requireArgs(args, 3, "scuttlectl admin passwd <username>") |
| 109 | cmdAdminPasswd(api, args[2]) |
| 110 | default: |
| 111 | fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1]) |
| 112 | os.Exit(1) |
| 113 | } |
| 114 | case "channels", "channel": |
| 115 | if len(args) < 2 { |
| 116 | fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n") |
| 117 | os.Exit(1) |
| @@ -491,10 +509,88 @@ | |
| 491 | fmt.Fprintf(tw, "password\t%s\n", creds.Password) |
| 492 | fmt.Fprintf(tw, "server\t%s\n", creds.Server) |
| 493 | tw.Flush() |
| 494 | fmt.Println("\nStore this password — it will not be shown again.") |
| 495 | } |
| 496 | |
| 497 | func usage() { |
| 498 | fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI |
| 499 | |
| 500 | Usage: |
| @@ -526,10 +622,13 @@ | |
| 526 | backend rename <old> <new> rename a backend |
| 527 | admin list list admin accounts |
| 528 | admin add <username> add admin (prompts for password) |
| 529 | admin remove <username> remove admin |
| 530 | admin passwd <username> change admin password (prompts) |
| 531 | `, version) |
| 532 | } |
| 533 | |
| 534 | func printJSON(raw json.RawMessage) { |
| 535 | var buf []byte |
| 536 |
| --- cmd/scuttlectl/main.go | |
| +++ cmd/scuttlectl/main.go | |
| @@ -108,10 +108,28 @@ | |
| 108 | requireArgs(args, 3, "scuttlectl admin passwd <username>") |
| 109 | cmdAdminPasswd(api, args[2]) |
| 110 | default: |
| 111 | fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1]) |
| 112 | os.Exit(1) |
| 113 | } |
| 114 | case "api-key", "api-keys": |
| 115 | if len(args) < 2 { |
| 116 | fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n") |
| 117 | os.Exit(1) |
| 118 | } |
| 119 | switch args[1] { |
| 120 | case "list": |
| 121 | cmdAPIKeyList(api, *jsonFlag) |
| 122 | case "create": |
| 123 | requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>") |
| 124 | cmdAPIKeyCreate(api, args[2:], *jsonFlag) |
| 125 | case "revoke": |
| 126 | requireArgs(args, 3, "scuttlectl api-key revoke <id>") |
| 127 | cmdAPIKeyRevoke(api, args[2]) |
| 128 | default: |
| 129 | fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1]) |
| 130 | os.Exit(1) |
| 131 | } |
| 132 | case "channels", "channel": |
| 133 | if len(args) < 2 { |
| 134 | fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n") |
| 135 | os.Exit(1) |
| @@ -491,10 +509,88 @@ | |
| 509 | fmt.Fprintf(tw, "password\t%s\n", creds.Password) |
| 510 | fmt.Fprintf(tw, "server\t%s\n", creds.Server) |
| 511 | tw.Flush() |
| 512 | fmt.Println("\nStore this password — it will not be shown again.") |
| 513 | } |
| 514 | |
| 515 | func cmdAPIKeyList(api *apiclient.Client, asJSON bool) { |
| 516 | raw, err := api.ListAPIKeys() |
| 517 | die(err) |
| 518 | if asJSON { |
| 519 | printJSON(raw) |
| 520 | return |
| 521 | } |
| 522 | |
| 523 | var keys []struct { |
| 524 | ID string `json:"id"` |
| 525 | Name string `json:"name"` |
| 526 | Scopes []string `json:"scopes"` |
| 527 | CreatedAt string `json:"created_at"` |
| 528 | LastUsed *string `json:"last_used"` |
| 529 | ExpiresAt *string `json:"expires_at"` |
| 530 | Active bool `json:"active"` |
| 531 | } |
| 532 | must(json.Unmarshal(raw, &keys)) |
| 533 | |
| 534 | if len(keys) == 0 { |
| 535 | fmt.Println("no API keys") |
| 536 | return |
| 537 | } |
| 538 | |
| 539 | tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) |
| 540 | fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED") |
| 541 | for _, k := range keys { |
| 542 | lastUsed := "-" |
| 543 | if k.LastUsed != nil { |
| 544 | lastUsed = *k.LastUsed |
| 545 | } |
| 546 | status := "yes" |
| 547 | if !k.Active { |
| 548 | status = "revoked" |
| 549 | } |
| 550 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed) |
| 551 | } |
| 552 | tw.Flush() |
| 553 | } |
| 554 | |
| 555 | func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) { |
| 556 | fs := flag.NewFlagSet("api-key create", flag.ExitOnError) |
| 557 | nameFlag := fs.String("name", "", "key name (required)") |
| 558 | scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)") |
| 559 | expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)") |
| 560 | _ = fs.Parse(args) |
| 561 | |
| 562 | if *nameFlag == "" || *scopesFlag == "" { |
| 563 | fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]") |
| 564 | os.Exit(1) |
| 565 | } |
| 566 | |
| 567 | scopes := strings.Split(*scopesFlag, ",") |
| 568 | raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag) |
| 569 | die(err) |
| 570 | |
| 571 | if asJSON { |
| 572 | printJSON(raw) |
| 573 | return |
| 574 | } |
| 575 | |
| 576 | var key struct { |
| 577 | ID string `json:"id"` |
| 578 | Name string `json:"name"` |
| 579 | Token string `json:"token"` |
| 580 | } |
| 581 | must(json.Unmarshal(raw, &key)) |
| 582 | |
| 583 | fmt.Printf("API key created: %s\n\n", key.Name) |
| 584 | fmt.Printf(" Token: %s\n\n", key.Token) |
| 585 | fmt.Println("Store this token — it will not be shown again.") |
| 586 | } |
| 587 | |
| 588 | func cmdAPIKeyRevoke(api *apiclient.Client, id string) { |
| 589 | die(api.RevokeAPIKey(id)) |
| 590 | fmt.Printf("API key revoked: %s\n", id) |
| 591 | } |
| 592 | |
| 593 | func usage() { |
| 594 | fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI |
| 595 | |
| 596 | Usage: |
| @@ -526,10 +622,13 @@ | |
| 622 | backend rename <old> <new> rename a backend |
| 623 | admin list list admin accounts |
| 624 | admin add <username> add admin (prompts for password) |
| 625 | admin remove <username> remove admin |
| 626 | admin passwd <username> change admin password (prompts) |
| 627 | api-key list list API keys |
| 628 | api-key create --name <name> --scopes <s1,s2> [--expires 720h] |
| 629 | api-key revoke <id> revoke an API key |
| 630 | `, version) |
| 631 | } |
| 632 | |
| 633 | func printJSON(raw json.RawMessage) { |
| 634 | var buf []byte |
| 635 |
+2
-1
| --- internal/api/api_test.go | ||
| +++ internal/api/api_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "net/http/httptest" |
| 9 | 9 | "sync" |
| 10 | 10 | "testing" |
| 11 | 11 | |
| 12 | 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | "log/slog" |
| 15 | 16 | "os" |
| 16 | 17 | ) |
| 17 | 18 | |
| @@ -50,11 +51,11 @@ | ||
| 50 | 51 | const testToken = "test-api-token-abc123" |
| 51 | 52 | |
| 52 | 53 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | 54 | t.Helper() |
| 54 | 55 | 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) | |
| 56 | 57 | return httptest.NewServer(srv.Handler()) |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | func authHeader() http.Header { |
| 60 | 61 | h := http.Header{} |
| 61 | 62 | |
| 62 | 63 | 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 |
+125
| --- a/internal/api/apikeys.go | ||
| +++ b/internal/api/apikeys.go | ||
| @@ -0,0 +1,125 @@ | ||
| 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 struct { | |
| 18 | + ID string `json:"id"` | |
| 19 | + Name string `json:"name"` | |
| 20 | + Token string `json:"token"` // plaintext, shown only once | |
| 21 | + Scopes []auth.Scope `json:"scopes"` | |
| 22 | + CreatedAt time.Time `json:"created_at"` | |
| 23 | + ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 24 | +} | |
| 25 | + | |
| 26 | +type apiKeyListEntry struct { | |
| 27 | + ID string `json:"id"` | |
| 28 | + Name string `json:"name"` | |
| 29 | + Scopes []auth.Scope `json:"scopes"` | |
| 30 | + CreatedAt time.Time `json:"created_at"` | |
| 31 | + LastUsed *time.Time `json:"last_used,omitempty"` | |
| 32 | + ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 33 | + Active bool `json:"active"` | |
| 34 | +} | |
| 35 | + | |
| 36 | +// handleListAPIKeys handles GET /v1/api-keys. | |
| 37 | +func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { | |
| 38 | + keys := s.apiKeys.List() | |
| 39 | + out := make([]apiKeyListEntry, len(keys)) | |
| 40 | + for i, k := range keys { | |
| 41 | + out[i] = apiKeyListEntry{ | |
| 42 | + ID: k.ID, | |
| 43 | + Name: k.Name, | |
| 44 | + Scopes: k.Scopes, | |
| 45 | + CreatedAt: k.CreatedAt, | |
| 46 | + Active: k.Active, | |
| 47 | + } | |
| 48 | + if !k.LastUsed.IsZero() { | |
| 49 | + t := k.LastUsed | |
| 50 | + out[i].LastUsed = &t | |
| 51 | + } | |
| 52 | + if !k.ExpiresAt.IsZero() { | |
| 53 | + t := k.ExpiresAt | |
| 54 | + out[i].ExpiresAt = &t | |
| 55 | + } | |
| 56 | + } | |
| 57 | + writeJSON(w, http.StatusOK, out) | |
| 58 | +} | |
| 59 | + | |
| 60 | +// handleCreateAPIKey handles POST /v1/api-keys. | |
| 61 | +func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { | |
| 62 | + var req createAPIKeyRequest | |
| 63 | + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 64 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 65 | + return | |
| 66 | + } | |
| 67 | + if req.Name == "" { | |
| 68 | + writeError(w, http.StatusBadRequest, "name is required") | |
| 69 | + return | |
| 70 | + } | |
| 71 | + | |
| 72 | + scopes := make([]auth.Scope, len(req.Scopes)) | |
| 73 | + for i, s := range req.Scopes { | |
| 74 | + scope := auth.Scope(s) | |
| 75 | + if !auth.ValidScopes[scope] { | |
| 76 | + writeError(w, http.StatusBadRequest, "unknown scope: "+s) | |
| 77 | + return | |
| 78 | + } | |
| 79 | + scopes[i] = scope | |
| 80 | + } | |
| 81 | + if len(scopes) == 0 { | |
| 82 | + writeError(w, http.StatusBadRequest, "at least one scope is required") | |
| 83 | + return | |
| 84 | + } | |
| 85 | + | |
| 86 | + var expiresAt time.Time | |
| 87 | + if req.ExpiresIn != "" { | |
| 88 | + dur, err := time.ParseDuration(req.ExpiresIn) | |
| 89 | + if err != nil { | |
| 90 | + writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error()) | |
| 91 | + return | |
| 92 | + } | |
| 93 | + expiresAt = time.Now().Add(dur) | |
| 94 | + } | |
| 95 | + | |
| 96 | + token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt) | |
| 97 | + if err != nil { | |
| 98 | + s.log.Error("create api key", "err", err) | |
| 99 | + writeError(w, http.StatusInternalServerError, "failed to create API key") | |
| 100 | + return | |
| 101 | + } | |
| 102 | + | |
| 103 | + resp := createAPIKeyResponse{ | |
| 104 | + ID: key.ID, | |
| 105 | + Name: key.Name, | |
| 106 | + Token: token, | |
| 107 | + Scopes: key.Scopes, | |
| 108 | + CreatedAt: key.CreatedAt, | |
| 109 | + } | |
| 110 | + if !key.ExpiresAt.IsZero() { | |
| 111 | + t := key.ExpiresAt | |
| 112 | + resp.ExpiresAt = &t | |
| 113 | + } | |
| 114 | + writeJSON(w, http.StatusCreated, resp) | |
| 115 | +} | |
| 116 | + | |
| 117 | +// handleRevokeAPIKey handles DELETE /v1/api-keys/{id}. | |
| 118 | +func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) { | |
| 119 | + id := r.PathValue("id") | |
| 120 | + if err := s.apiKeys.Revoke(id); err != nil { | |
| 121 | + writeError(w, http.StatusNotFound, err.Error()) | |
| 122 | + return | |
| 123 | + } | |
| 124 | + w.WriteHeader(http.StatusNoContent) | |
| 125 | +} |
| --- a/internal/api/apikeys.go | |
| +++ b/internal/api/apikeys.go | |
| @@ -0,0 +1,125 @@ | |
| --- a/internal/api/apikeys.go | |
| +++ b/internal/api/apikeys.go | |
| @@ -0,0 +1,125 @@ | |
| 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 struct { |
| 18 | ID string `json:"id"` |
| 19 | Name string `json:"name"` |
| 20 | Token string `json:"token"` // plaintext, shown only once |
| 21 | Scopes []auth.Scope `json:"scopes"` |
| 22 | CreatedAt time.Time `json:"created_at"` |
| 23 | ExpiresAt *time.Time `json:"expires_at,omitempty"` |
| 24 | } |
| 25 | |
| 26 | type apiKeyListEntry struct { |
| 27 | ID string `json:"id"` |
| 28 | Name string `json:"name"` |
| 29 | Scopes []auth.Scope `json:"scopes"` |
| 30 | CreatedAt time.Time `json:"created_at"` |
| 31 | LastUsed *time.Time `json:"last_used,omitempty"` |
| 32 | ExpiresAt *time.Time `json:"expires_at,omitempty"` |
| 33 | Active bool `json:"active"` |
| 34 | } |
| 35 | |
| 36 | // handleListAPIKeys handles GET /v1/api-keys. |
| 37 | func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { |
| 38 | keys := s.apiKeys.List() |
| 39 | out := make([]apiKeyListEntry, len(keys)) |
| 40 | for i, k := range keys { |
| 41 | out[i] = apiKeyListEntry{ |
| 42 | ID: k.ID, |
| 43 | Name: k.Name, |
| 44 | Scopes: k.Scopes, |
| 45 | CreatedAt: k.CreatedAt, |
| 46 | Active: k.Active, |
| 47 | } |
| 48 | if !k.LastUsed.IsZero() { |
| 49 | t := k.LastUsed |
| 50 | out[i].LastUsed = &t |
| 51 | } |
| 52 | if !k.ExpiresAt.IsZero() { |
| 53 | t := k.ExpiresAt |
| 54 | out[i].ExpiresAt = &t |
| 55 | } |
| 56 | } |
| 57 | writeJSON(w, http.StatusOK, out) |
| 58 | } |
| 59 | |
| 60 | // handleCreateAPIKey handles POST /v1/api-keys. |
| 61 | func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { |
| 62 | var req createAPIKeyRequest |
| 63 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 64 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 65 | return |
| 66 | } |
| 67 | if req.Name == "" { |
| 68 | writeError(w, http.StatusBadRequest, "name is required") |
| 69 | return |
| 70 | } |
| 71 | |
| 72 | scopes := make([]auth.Scope, len(req.Scopes)) |
| 73 | for i, s := range req.Scopes { |
| 74 | scope := auth.Scope(s) |
| 75 | if !auth.ValidScopes[scope] { |
| 76 | writeError(w, http.StatusBadRequest, "unknown scope: "+s) |
| 77 | return |
| 78 | } |
| 79 | scopes[i] = scope |
| 80 | } |
| 81 | if len(scopes) == 0 { |
| 82 | writeError(w, http.StatusBadRequest, "at least one scope is required") |
| 83 | return |
| 84 | } |
| 85 | |
| 86 | var expiresAt time.Time |
| 87 | if req.ExpiresIn != "" { |
| 88 | dur, err := time.ParseDuration(req.ExpiresIn) |
| 89 | if err != nil { |
| 90 | writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error()) |
| 91 | return |
| 92 | } |
| 93 | expiresAt = time.Now().Add(dur) |
| 94 | } |
| 95 | |
| 96 | token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt) |
| 97 | if err != nil { |
| 98 | s.log.Error("create api key", "err", err) |
| 99 | writeError(w, http.StatusInternalServerError, "failed to create API key") |
| 100 | return |
| 101 | } |
| 102 | |
| 103 | resp := createAPIKeyResponse{ |
| 104 | ID: key.ID, |
| 105 | Name: key.Name, |
| 106 | Token: token, |
| 107 | Scopes: key.Scopes, |
| 108 | CreatedAt: key.CreatedAt, |
| 109 | } |
| 110 | if !key.ExpiresAt.IsZero() { |
| 111 | t := key.ExpiresAt |
| 112 | resp.ExpiresAt = &t |
| 113 | } |
| 114 | writeJSON(w, http.StatusCreated, resp) |
| 115 | } |
| 116 | |
| 117 | // handleRevokeAPIKey handles DELETE /v1/api-keys/{id}. |
| 118 | func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) { |
| 119 | id := r.PathValue("id") |
| 120 | if err := s.apiKeys.Revoke(id); err != nil { |
| 121 | writeError(w, http.StatusNotFound, err.Error()) |
| 122 | return |
| 123 | } |
| 124 | w.WriteHeader(http.StatusNoContent) |
| 125 | } |
| --- internal/api/channels_topology_test.go | ||
| +++ internal/api/channels_topology_test.go | ||
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 17 | 18 | ) |
| 18 | 19 | |
| @@ -74,11 +75,11 @@ | ||
| 74 | 75 | |
| 75 | 76 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | 77 | t.Helper() |
| 77 | 78 | reg := registry.New(nil, []byte("key")) |
| 78 | 79 | 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()) | |
| 80 | 81 | t.Cleanup(srv.Close) |
| 81 | 82 | return srv, "tok" |
| 82 | 83 | } |
| 83 | 84 | |
| 84 | 85 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +86,11 @@ | ||
| 85 | 86 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | 87 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | 88 | t.Helper() |
| 88 | 89 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | 90 | 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()) | |
| 91 | 92 | t.Cleanup(srv.Close) |
| 92 | 93 | return srv, "tok" |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 | 97 |
| --- 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 |
+3
-1
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | 7 | "net/http" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 10 | 11 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 11 | 12 | ) |
| 12 | 13 | |
| 13 | 14 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 14 | 15 | type chatBridge interface { |
| @@ -118,11 +119,12 @@ | ||
| 118 | 119 | |
| 119 | 120 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | 121 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | 122 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | 123 | 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)) { | |
| 124 | 126 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 125 | 127 | return |
| 126 | 128 | } |
| 127 | 129 | |
| 128 | 130 | channel := "#" + r.PathValue("channel") |
| 129 | 131 |
| --- 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 |
+3
-2
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "log/slog" |
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | 16 | ) |
| 16 | 17 | |
| 17 | 18 | type stubChatBridge struct { |
| @@ -42,11 +43,11 @@ | ||
| 42 | 43 | t.Helper() |
| 43 | 44 | |
| 44 | 45 | bridgeStub := &stubChatBridge{} |
| 45 | 46 | reg := registry.New(nil, []byte("test-signing-key")) |
| 46 | 47 | 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()) | |
| 48 | 49 | defer srv.Close() |
| 49 | 50 | |
| 50 | 51 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 51 | 52 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 52 | 53 | if err != nil { |
| @@ -75,11 +76,11 @@ | ||
| 75 | 76 | t.Helper() |
| 76 | 77 | |
| 77 | 78 | bridgeStub := &stubChatBridge{} |
| 78 | 79 | reg := registry.New(nil, []byte("test-signing-key")) |
| 79 | 80 | 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()) | |
| 81 | 82 | defer srv.Close() |
| 82 | 83 | |
| 83 | 84 | body, _ := json.Marshal(map[string]string{}) |
| 84 | 85 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 85 | 86 | if err != nil { |
| 86 | 87 |
| --- 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 @@ | ||
| 9 | 9 | "net/http/httptest" |
| 10 | 10 | "path/filepath" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | ) |
| 17 | 18 | |
| 18 | 19 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | ||
| 25 | 26 | cfg.Ergo.DataDir = dir |
| 26 | 27 | |
| 27 | 28 | store := NewConfigStore(path, cfg) |
| 28 | 29 | reg := registry.New(nil, []byte("key")) |
| 29 | 30 | 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()) | |
| 31 | 32 | t.Cleanup(srv.Close) |
| 32 | 33 | return srv, store |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | func TestHandleGetConfig(t *testing.T) { |
| 36 | 37 |
| --- 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 |
+7
-5
| --- internal/api/login.go | ||
| +++ internal/api/login.go | ||
| @@ -93,15 +93,17 @@ | ||
| 93 | 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | 95 | return |
| 96 | 96 | } |
| 97 | 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 | |
| 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 | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | writeJSON(w, http.StatusOK, map[string]string{ |
| 106 | 108 | "token": token, |
| 107 | 109 | "username": req.Username, |
| 108 | 110 |
| --- 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 |
+2
-2
| --- internal/api/login_test.go | ||
| +++ internal/api/login_test.go | ||
| @@ -28,18 +28,18 @@ | ||
| 28 | 28 | admins := newAdminStore(t) |
| 29 | 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | 30 | t.Fatalf("Add admin: %v", err) |
| 31 | 31 | } |
| 32 | 32 | 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) | |
| 34 | 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | 38 | // When admins is nil, login returns 404. |
| 39 | 39 | 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) | |
| 41 | 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | 42 | defer ts.Close() |
| 43 | 43 | |
| 44 | 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | 45 | defer resp.Body.Close() |
| 46 | 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, 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 |
+38
-2
| --- internal/api/middleware.go | ||
| +++ internal/api/middleware.go | ||
| @@ -1,26 +1,62 @@ | ||
| 1 | 1 | package api |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "strings" |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 6 | 9 | ) |
| 7 | 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. | |
| 8 | 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | 25 | token := bearerToken(r) |
| 11 | 26 | if token == "" { |
| 12 | 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | 28 | return |
| 14 | 29 | } |
| 15 | - if _, ok := s.tokens[token]; !ok { | |
| 30 | + key := s.apiKeys.Lookup(token) | |
| 31 | + if key == nil { | |
| 16 | 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 17 | 33 | return |
| 18 | 34 | } |
| 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)) | |
| 20 | 40 | }) |
| 21 | 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 | +} | |
| 22 | 58 | |
| 23 | 59 | func bearerToken(r *http.Request) string { |
| 24 | 60 | auth := r.Header.Get("Authorization") |
| 25 | 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 26 | 62 | if !found { |
| 27 | 63 |
| --- 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 |
+76
-60
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -7,18 +7,19 @@ | ||
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | 9 | "log/slog" |
| 10 | 10 | "net/http" |
| 11 | 11 | |
| 12 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 12 | 13 | "github.com/conflicthq/scuttlebot/internal/config" |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | ) |
| 15 | 16 | |
| 16 | 17 | // Server is the scuttlebot HTTP API server. |
| 17 | 18 | type Server struct { |
| 18 | 19 | registry *registry.Registry |
| 19 | - tokens map[string]struct{} | |
| 20 | + apiKeys *auth.APIKeyStore | |
| 20 | 21 | log *slog.Logger |
| 21 | 22 | bridge chatBridge // nil if bridge is disabled |
| 22 | 23 | policies *PolicyStore // nil if not configured |
| 23 | 24 | admins adminStore // nil if not configured |
| 24 | 25 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| @@ -31,18 +32,14 @@ | ||
| 31 | 32 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 32 | 33 | // Pass nil for admins to disable admin authentication endpoints. |
| 33 | 34 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 34 | 35 | // Pass nil for topo to disable topology provisioning endpoints. |
| 35 | 36 | // 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 { | |
| 41 | 38 | return &Server{ |
| 42 | 39 | registry: reg, |
| 43 | - tokens: tokenSet, | |
| 40 | + apiKeys: apiKeys, | |
| 44 | 41 | log: log, |
| 45 | 42 | bridge: b, |
| 46 | 43 | policies: ps, |
| 47 | 44 | admins: admins, |
| 48 | 45 | llmCfg: llmCfg, |
| @@ -53,65 +50,84 @@ | ||
| 53 | 50 | } |
| 54 | 51 | } |
| 55 | 52 | |
| 56 | 53 | // Handler returns the HTTP handler with all routes registered. |
| 57 | 54 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 55 | +// Scoped routes additionally check the API key's scopes. | |
| 58 | 56 | func (s *Server) Handler() http.Handler { |
| 59 | 57 | 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)) | |
| 113 | 129 | |
| 114 | 130 | outer := http.NewServeMux() |
| 115 | 131 | outer.HandleFunc("POST /login", s.handleLogin) |
| 116 | 132 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 117 | 133 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 118 | 134 |
| --- 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 |
| --- 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 |
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -580,10 +580,40 @@ | ||
| 580 | 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | 581 | </form> |
| 582 | 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | 583 | </div> |
| 584 | 584 | </div> |
| 585 | + | |
| 586 | + <!-- api keys --> | |
| 587 | + <div class="card" id="card-apikeys"> | |
| 588 | + <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div> | |
| 589 | + <div id="apikeys-list-container"></div> | |
| 590 | + <div class="card-body" style="border-top:1px solid #21262d"> | |
| 591 | + <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p> | |
| 592 | + <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px"> | |
| 593 | + <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> | |
| 594 | + <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div> | |
| 595 | + <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div> | |
| 596 | + </div> | |
| 597 | + <div> | |
| 598 | + <label style="margin-bottom:6px;display:block">scopes</label> | |
| 599 | + <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px"> | |
| 600 | + <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label> | |
| 601 | + <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label> | |
| 602 | + <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label> | |
| 603 | + <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label> | |
| 604 | + <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label> | |
| 605 | + <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label> | |
| 606 | + <label><input type="checkbox" value="config" class="apikey-scope"> config</label> | |
| 607 | + <label><input type="checkbox" value="read" class="apikey-scope"> read</label> | |
| 608 | + </div> | |
| 609 | + </div> | |
| 610 | + <button type="submit" class="primary sm" style="align-self:flex-start">create key</button> | |
| 611 | + </form> | |
| 612 | + <div id="add-apikey-result" style="margin-top:10px"></div> | |
| 613 | + </div> | |
| 614 | + </div> | |
| 585 | 615 | |
| 586 | 616 | <!-- tls --> |
| 587 | 617 | <div class="card" id="card-tls"> |
| 588 | 618 | <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div> |
| 589 | 619 | <div class="card-body"> |
| @@ -2544,10 +2574,73 @@ | ||
| 2544 | 2574 | try { |
| 2545 | 2575 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2546 | 2576 | alert('Password updated.'); |
| 2547 | 2577 | } catch(e) { alert('Failed: ' + e.message); } |
| 2548 | 2578 | } |
| 2579 | + | |
| 2580 | +// --- API keys --- | |
| 2581 | +async function loadAPIKeys() { | |
| 2582 | + try { | |
| 2583 | + const keys = await api('GET', '/v1/api-keys'); | |
| 2584 | + renderAPIKeys(keys || []); | |
| 2585 | + } catch(e) { | |
| 2586 | + document.getElementById('apikeys-list-container').innerHTML = ''; | |
| 2587 | + } | |
| 2588 | +} | |
| 2589 | + | |
| 2590 | +function renderAPIKeys(keys) { | |
| 2591 | + const el = document.getElementById('apikeys-list-container'); | |
| 2592 | + if (!keys.length) { el.innerHTML = ''; return; } | |
| 2593 | + const rows = keys.map(k => { | |
| 2594 | + const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>'; | |
| 2595 | + const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' '); | |
| 2596 | + const lastUsed = k.last_used ? fmtTime(k.last_used) : '—'; | |
| 2597 | + const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : ''; | |
| 2598 | + return `<tr> | |
| 2599 | + <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td> | |
| 2600 | + <td>${scopes}</td> | |
| 2601 | + <td style="font-size:12px">${status}</td> | |
| 2602 | + <td style="color:#8b949e;font-size:12px">${lastUsed}</td> | |
| 2603 | + <td><div class="actions">${revokeBtn}</div></td> | |
| 2604 | + </tr>`; | |
| 2605 | + }).join(''); | |
| 2606 | + el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 2607 | +} | |
| 2608 | + | |
| 2609 | +async function createAPIKey(e) { | |
| 2610 | + e.preventDefault(); | |
| 2611 | + const name = document.getElementById('new-apikey-name').value.trim(); | |
| 2612 | + const expires = document.getElementById('new-apikey-expires').value.trim(); | |
| 2613 | + const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value); | |
| 2614 | + const resultEl = document.getElementById('add-apikey-result'); | |
| 2615 | + if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; } | |
| 2616 | + if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; } | |
| 2617 | + try { | |
| 2618 | + const body = { name, scopes }; | |
| 2619 | + if (expires) body.expires_in = expires; | |
| 2620 | + const result = await api('POST', '/v1/api-keys', body); | |
| 2621 | + resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px"> | |
| 2622 | + <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div> | |
| 2623 | + <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div> | |
| 2624 | + <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code> | |
| 2625 | + </div>`; | |
| 2626 | + document.getElementById('new-apikey-name').value = ''; | |
| 2627 | + document.getElementById('new-apikey-expires').value = ''; | |
| 2628 | + document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false); | |
| 2629 | + loadAPIKeys(); | |
| 2630 | + } catch(e) { | |
| 2631 | + resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`; | |
| 2632 | + } | |
| 2633 | +} | |
| 2634 | + | |
| 2635 | +async function revokeAPIKey(id) { | |
| 2636 | + if (!confirm('Revoke this API key? This cannot be undone.')) return; | |
| 2637 | + try { | |
| 2638 | + await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`); | |
| 2639 | + loadAPIKeys(); | |
| 2640 | + } catch(e) { alert('Failed: ' + e.message); } | |
| 2641 | +} | |
| 2549 | 2642 | |
| 2550 | 2643 | // --- AI / LLM tab --- |
| 2551 | 2644 | async function loadAI() { |
| 2552 | 2645 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2553 | 2646 | } |
| @@ -2917,10 +3010,11 @@ | ||
| 2917 | 3010 | renderBehaviors(s.policies.behaviors || []); |
| 2918 | 3011 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2919 | 3012 | renderBridgePolicy(s.policies.bridge || {}); |
| 2920 | 3013 | renderLoggingPolicy(s.policies.logging || {}); |
| 2921 | 3014 | loadAdmins(); |
| 3015 | + loadAPIKeys(); | |
| 2922 | 3016 | loadConfigCards(); |
| 2923 | 3017 | } catch(e) { |
| 2924 | 3018 | document.getElementById('tls-badge').textContent = 'error'; |
| 2925 | 3019 | } |
| 2926 | 3020 | } |
| 2927 | 3021 | |
| 2928 | 3022 | ADDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -580,10 +580,40 @@ | |
| 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | </form> |
| 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | </div> |
| 584 | </div> |
| 585 | |
| 586 | <!-- tls --> |
| 587 | <div class="card" id="card-tls"> |
| 588 | <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div> |
| 589 | <div class="card-body"> |
| @@ -2544,10 +2574,73 @@ | |
| 2544 | try { |
| 2545 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2546 | alert('Password updated.'); |
| 2547 | } catch(e) { alert('Failed: ' + e.message); } |
| 2548 | } |
| 2549 | |
| 2550 | // --- AI / LLM tab --- |
| 2551 | async function loadAI() { |
| 2552 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2553 | } |
| @@ -2917,10 +3010,11 @@ | |
| 2917 | renderBehaviors(s.policies.behaviors || []); |
| 2918 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2919 | renderBridgePolicy(s.policies.bridge || {}); |
| 2920 | renderLoggingPolicy(s.policies.logging || {}); |
| 2921 | loadAdmins(); |
| 2922 | loadConfigCards(); |
| 2923 | } catch(e) { |
| 2924 | document.getElementById('tls-badge').textContent = 'error'; |
| 2925 | } |
| 2926 | } |
| 2927 | |
| 2928 | DDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -580,10 +580,40 @@ | |
| 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | </form> |
| 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | </div> |
| 584 | </div> |
| 585 | |
| 586 | <!-- api keys --> |
| 587 | <div class="card" id="card-apikeys"> |
| 588 | <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div> |
| 589 | <div id="apikeys-list-container"></div> |
| 590 | <div class="card-body" style="border-top:1px solid #21262d"> |
| 591 | <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p> |
| 592 | <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px"> |
| 593 | <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> |
| 594 | <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div> |
| 595 | <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div> |
| 596 | </div> |
| 597 | <div> |
| 598 | <label style="margin-bottom:6px;display:block">scopes</label> |
| 599 | <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px"> |
| 600 | <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label> |
| 601 | <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label> |
| 602 | <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label> |
| 603 | <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label> |
| 604 | <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label> |
| 605 | <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label> |
| 606 | <label><input type="checkbox" value="config" class="apikey-scope"> config</label> |
| 607 | <label><input type="checkbox" value="read" class="apikey-scope"> read</label> |
| 608 | </div> |
| 609 | </div> |
| 610 | <button type="submit" class="primary sm" style="align-self:flex-start">create key</button> |
| 611 | </form> |
| 612 | <div id="add-apikey-result" style="margin-top:10px"></div> |
| 613 | </div> |
| 614 | </div> |
| 615 | |
| 616 | <!-- tls --> |
| 617 | <div class="card" id="card-tls"> |
| 618 | <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div> |
| 619 | <div class="card-body"> |
| @@ -2544,10 +2574,73 @@ | |
| 2574 | try { |
| 2575 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2576 | alert('Password updated.'); |
| 2577 | } catch(e) { alert('Failed: ' + e.message); } |
| 2578 | } |
| 2579 | |
| 2580 | // --- API keys --- |
| 2581 | async function loadAPIKeys() { |
| 2582 | try { |
| 2583 | const keys = await api('GET', '/v1/api-keys'); |
| 2584 | renderAPIKeys(keys || []); |
| 2585 | } catch(e) { |
| 2586 | document.getElementById('apikeys-list-container').innerHTML = ''; |
| 2587 | } |
| 2588 | } |
| 2589 | |
| 2590 | function renderAPIKeys(keys) { |
| 2591 | const el = document.getElementById('apikeys-list-container'); |
| 2592 | if (!keys.length) { el.innerHTML = ''; return; } |
| 2593 | const rows = keys.map(k => { |
| 2594 | const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>'; |
| 2595 | const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' '); |
| 2596 | const lastUsed = k.last_used ? fmtTime(k.last_used) : '—'; |
| 2597 | const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : ''; |
| 2598 | return `<tr> |
| 2599 | <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td> |
| 2600 | <td>${scopes}</td> |
| 2601 | <td style="font-size:12px">${status}</td> |
| 2602 | <td style="color:#8b949e;font-size:12px">${lastUsed}</td> |
| 2603 | <td><div class="actions">${revokeBtn}</div></td> |
| 2604 | </tr>`; |
| 2605 | }).join(''); |
| 2606 | el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; |
| 2607 | } |
| 2608 | |
| 2609 | async function createAPIKey(e) { |
| 2610 | e.preventDefault(); |
| 2611 | const name = document.getElementById('new-apikey-name').value.trim(); |
| 2612 | const expires = document.getElementById('new-apikey-expires').value.trim(); |
| 2613 | const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value); |
| 2614 | const resultEl = document.getElementById('add-apikey-result'); |
| 2615 | if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; } |
| 2616 | if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; } |
| 2617 | try { |
| 2618 | const body = { name, scopes }; |
| 2619 | if (expires) body.expires_in = expires; |
| 2620 | const result = await api('POST', '/v1/api-keys', body); |
| 2621 | resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px"> |
| 2622 | <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div> |
| 2623 | <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div> |
| 2624 | <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code> |
| 2625 | </div>`; |
| 2626 | document.getElementById('new-apikey-name').value = ''; |
| 2627 | document.getElementById('new-apikey-expires').value = ''; |
| 2628 | document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false); |
| 2629 | loadAPIKeys(); |
| 2630 | } catch(e) { |
| 2631 | resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`; |
| 2632 | } |
| 2633 | } |
| 2634 | |
| 2635 | async function revokeAPIKey(id) { |
| 2636 | if (!confirm('Revoke this API key? This cannot be undone.')) return; |
| 2637 | try { |
| 2638 | await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`); |
| 2639 | loadAPIKeys(); |
| 2640 | } catch(e) { alert('Failed: ' + e.message); } |
| 2641 | } |
| 2642 | |
| 2643 | // --- AI / LLM tab --- |
| 2644 | async function loadAI() { |
| 2645 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2646 | } |
| @@ -2917,10 +3010,11 @@ | |
| 3010 | renderBehaviors(s.policies.behaviors || []); |
| 3011 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 3012 | renderBridgePolicy(s.policies.bridge || {}); |
| 3013 | renderLoggingPolicy(s.policies.logging || {}); |
| 3014 | loadAdmins(); |
| 3015 | loadAPIKeys(); |
| 3016 | loadConfigCards(); |
| 3017 | } catch(e) { |
| 3018 | document.getElementById('tls-badge').textContent = 'error'; |
| 3019 | } |
| 3020 | } |
| 3021 | |
| 3022 | DDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -580,10 +580,40 @@ | ||
| 580 | 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | 581 | </form> |
| 582 | 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | 583 | </div> |
| 584 | 584 | </div> |
| 585 | + | |
| 586 | + <!-- api keys --> | |
| 587 | + <div class="card" id="card-apikeys"> | |
| 588 | + <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div> | |
| 589 | + <div id="apikeys-list-container"></div> | |
| 590 | + <div class="card-body" style="border-top:1px solid #21262d"> | |
| 591 | + <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p> | |
| 592 | + <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px"> | |
| 593 | + <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> | |
| 594 | + <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div> | |
| 595 | + <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div> | |
| 596 | + </div> | |
| 597 | + <div> | |
| 598 | + <label style="margin-bottom:6px;display:block">scopes</label> | |
| 599 | + <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px"> | |
| 600 | + <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label> | |
| 601 | + <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label> | |
| 602 | + <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label> | |
| 603 | + <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label> | |
| 604 | + <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label> | |
| 605 | + <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label> | |
| 606 | + <label><input type="checkbox" value="config" class="apikey-scope"> config</label> | |
| 607 | + <label><input type="checkbox" value="read" class="apikey-scope"> read</label> | |
| 608 | + </div> | |
| 609 | + </div> | |
| 610 | + <button type="submit" class="primary sm" style="align-self:flex-start">create key</button> | |
| 611 | + </form> | |
| 612 | + <div id="add-apikey-result" style="margin-top:10px"></div> | |
| 613 | + </div> | |
| 614 | + </div> | |
| 585 | 615 | |
| 586 | 616 | <!-- tls --> |
| 587 | 617 | <div class="card" id="card-tls"> |
| 588 | 618 | <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div> |
| 589 | 619 | <div class="card-body"> |
| @@ -2544,10 +2574,73 @@ | ||
| 2544 | 2574 | try { |
| 2545 | 2575 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2546 | 2576 | alert('Password updated.'); |
| 2547 | 2577 | } catch(e) { alert('Failed: ' + e.message); } |
| 2548 | 2578 | } |
| 2579 | + | |
| 2580 | +// --- API keys --- | |
| 2581 | +async function loadAPIKeys() { | |
| 2582 | + try { | |
| 2583 | + const keys = await api('GET', '/v1/api-keys'); | |
| 2584 | + renderAPIKeys(keys || []); | |
| 2585 | + } catch(e) { | |
| 2586 | + document.getElementById('apikeys-list-container').innerHTML = ''; | |
| 2587 | + } | |
| 2588 | +} | |
| 2589 | + | |
| 2590 | +function renderAPIKeys(keys) { | |
| 2591 | + const el = document.getElementById('apikeys-list-container'); | |
| 2592 | + if (!keys.length) { el.innerHTML = ''; return; } | |
| 2593 | + const rows = keys.map(k => { | |
| 2594 | + const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>'; | |
| 2595 | + const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' '); | |
| 2596 | + const lastUsed = k.last_used ? fmtTime(k.last_used) : '—'; | |
| 2597 | + const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : ''; | |
| 2598 | + return `<tr> | |
| 2599 | + <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td> | |
| 2600 | + <td>${scopes}</td> | |
| 2601 | + <td style="font-size:12px">${status}</td> | |
| 2602 | + <td style="color:#8b949e;font-size:12px">${lastUsed}</td> | |
| 2603 | + <td><div class="actions">${revokeBtn}</div></td> | |
| 2604 | + </tr>`; | |
| 2605 | + }).join(''); | |
| 2606 | + el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 2607 | +} | |
| 2608 | + | |
| 2609 | +async function createAPIKey(e) { | |
| 2610 | + e.preventDefault(); | |
| 2611 | + const name = document.getElementById('new-apikey-name').value.trim(); | |
| 2612 | + const expires = document.getElementById('new-apikey-expires').value.trim(); | |
| 2613 | + const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value); | |
| 2614 | + const resultEl = document.getElementById('add-apikey-result'); | |
| 2615 | + if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; } | |
| 2616 | + if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; } | |
| 2617 | + try { | |
| 2618 | + const body = { name, scopes }; | |
| 2619 | + if (expires) body.expires_in = expires; | |
| 2620 | + const result = await api('POST', '/v1/api-keys', body); | |
| 2621 | + resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px"> | |
| 2622 | + <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div> | |
| 2623 | + <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div> | |
| 2624 | + <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code> | |
| 2625 | + </div>`; | |
| 2626 | + document.getElementById('new-apikey-name').value = ''; | |
| 2627 | + document.getElementById('new-apikey-expires').value = ''; | |
| 2628 | + document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false); | |
| 2629 | + loadAPIKeys(); | |
| 2630 | + } catch(e) { | |
| 2631 | + resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`; | |
| 2632 | + } | |
| 2633 | +} | |
| 2634 | + | |
| 2635 | +async function revokeAPIKey(id) { | |
| 2636 | + if (!confirm('Revoke this API key? This cannot be undone.')) return; | |
| 2637 | + try { | |
| 2638 | + await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`); | |
| 2639 | + loadAPIKeys(); | |
| 2640 | + } catch(e) { alert('Failed: ' + e.message); } | |
| 2641 | +} | |
| 2549 | 2642 | |
| 2550 | 2643 | // --- AI / LLM tab --- |
| 2551 | 2644 | async function loadAI() { |
| 2552 | 2645 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2553 | 2646 | } |
| @@ -2917,10 +3010,11 @@ | ||
| 2917 | 3010 | renderBehaviors(s.policies.behaviors || []); |
| 2918 | 3011 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2919 | 3012 | renderBridgePolicy(s.policies.bridge || {}); |
| 2920 | 3013 | renderLoggingPolicy(s.policies.logging || {}); |
| 2921 | 3014 | loadAdmins(); |
| 3015 | + loadAPIKeys(); | |
| 2922 | 3016 | loadConfigCards(); |
| 2923 | 3017 | } catch(e) { |
| 2924 | 3018 | document.getElementById('tls-badge').textContent = 'error'; |
| 2925 | 3019 | } |
| 2926 | 3020 | } |
| 2927 | 3021 | |
| 2928 | 3022 | ADDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -580,10 +580,40 @@ | |
| 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | </form> |
| 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | </div> |
| 584 | </div> |
| 585 | |
| 586 | <!-- tls --> |
| 587 | <div class="card" id="card-tls"> |
| 588 | <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div> |
| 589 | <div class="card-body"> |
| @@ -2544,10 +2574,73 @@ | |
| 2544 | try { |
| 2545 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2546 | alert('Password updated.'); |
| 2547 | } catch(e) { alert('Failed: ' + e.message); } |
| 2548 | } |
| 2549 | |
| 2550 | // --- AI / LLM tab --- |
| 2551 | async function loadAI() { |
| 2552 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2553 | } |
| @@ -2917,10 +3010,11 @@ | |
| 2917 | renderBehaviors(s.policies.behaviors || []); |
| 2918 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2919 | renderBridgePolicy(s.policies.bridge || {}); |
| 2920 | renderLoggingPolicy(s.policies.logging || {}); |
| 2921 | loadAdmins(); |
| 2922 | loadConfigCards(); |
| 2923 | } catch(e) { |
| 2924 | document.getElementById('tls-badge').textContent = 'error'; |
| 2925 | } |
| 2926 | } |
| 2927 | |
| 2928 | DDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -580,10 +580,40 @@ | |
| 580 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | </form> |
| 582 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | </div> |
| 584 | </div> |
| 585 | |
| 586 | <!-- api keys --> |
| 587 | <div class="card" id="card-apikeys"> |
| 588 | <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div> |
| 589 | <div id="apikeys-list-container"></div> |
| 590 | <div class="card-body" style="border-top:1px solid #21262d"> |
| 591 | <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p> |
| 592 | <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px"> |
| 593 | <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> |
| 594 | <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div> |
| 595 | <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div> |
| 596 | </div> |
| 597 | <div> |
| 598 | <label style="margin-bottom:6px;display:block">scopes</label> |
| 599 | <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px"> |
| 600 | <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label> |
| 601 | <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label> |
| 602 | <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label> |
| 603 | <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label> |
| 604 | <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label> |
| 605 | <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label> |
| 606 | <label><input type="checkbox" value="config" class="apikey-scope"> config</label> |
| 607 | <label><input type="checkbox" value="read" class="apikey-scope"> read</label> |
| 608 | </div> |
| 609 | </div> |
| 610 | <button type="submit" class="primary sm" style="align-self:flex-start">create key</button> |
| 611 | </form> |
| 612 | <div id="add-apikey-result" style="margin-top:10px"></div> |
| 613 | </div> |
| 614 | </div> |
| 615 | |
| 616 | <!-- tls --> |
| 617 | <div class="card" id="card-tls"> |
| 618 | <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div> |
| 619 | <div class="card-body"> |
| @@ -2544,10 +2574,73 @@ | |
| 2574 | try { |
| 2575 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2576 | alert('Password updated.'); |
| 2577 | } catch(e) { alert('Failed: ' + e.message); } |
| 2578 | } |
| 2579 | |
| 2580 | // --- API keys --- |
| 2581 | async function loadAPIKeys() { |
| 2582 | try { |
| 2583 | const keys = await api('GET', '/v1/api-keys'); |
| 2584 | renderAPIKeys(keys || []); |
| 2585 | } catch(e) { |
| 2586 | document.getElementById('apikeys-list-container').innerHTML = ''; |
| 2587 | } |
| 2588 | } |
| 2589 | |
| 2590 | function renderAPIKeys(keys) { |
| 2591 | const el = document.getElementById('apikeys-list-container'); |
| 2592 | if (!keys.length) { el.innerHTML = ''; return; } |
| 2593 | const rows = keys.map(k => { |
| 2594 | const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>'; |
| 2595 | const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' '); |
| 2596 | const lastUsed = k.last_used ? fmtTime(k.last_used) : '—'; |
| 2597 | const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : ''; |
| 2598 | return `<tr> |
| 2599 | <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td> |
| 2600 | <td>${scopes}</td> |
| 2601 | <td style="font-size:12px">${status}</td> |
| 2602 | <td style="color:#8b949e;font-size:12px">${lastUsed}</td> |
| 2603 | <td><div class="actions">${revokeBtn}</div></td> |
| 2604 | </tr>`; |
| 2605 | }).join(''); |
| 2606 | el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; |
| 2607 | } |
| 2608 | |
| 2609 | async function createAPIKey(e) { |
| 2610 | e.preventDefault(); |
| 2611 | const name = document.getElementById('new-apikey-name').value.trim(); |
| 2612 | const expires = document.getElementById('new-apikey-expires').value.trim(); |
| 2613 | const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value); |
| 2614 | const resultEl = document.getElementById('add-apikey-result'); |
| 2615 | if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; } |
| 2616 | if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; } |
| 2617 | try { |
| 2618 | const body = { name, scopes }; |
| 2619 | if (expires) body.expires_in = expires; |
| 2620 | const result = await api('POST', '/v1/api-keys', body); |
| 2621 | resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px"> |
| 2622 | <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div> |
| 2623 | <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div> |
| 2624 | <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code> |
| 2625 | </div>`; |
| 2626 | document.getElementById('new-apikey-name').value = ''; |
| 2627 | document.getElementById('new-apikey-expires').value = ''; |
| 2628 | document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false); |
| 2629 | loadAPIKeys(); |
| 2630 | } catch(e) { |
| 2631 | resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`; |
| 2632 | } |
| 2633 | } |
| 2634 | |
| 2635 | async function revokeAPIKey(id) { |
| 2636 | if (!confirm('Revoke this API key? This cannot be undone.')) return; |
| 2637 | try { |
| 2638 | await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`); |
| 2639 | loadAPIKeys(); |
| 2640 | } catch(e) { alert('Failed: ' + e.message); } |
| 2641 | } |
| 2642 | |
| 2643 | // --- AI / LLM tab --- |
| 2644 | async function loadAI() { |
| 2645 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2646 | } |
| @@ -2917,10 +3010,11 @@ | |
| 3010 | renderBehaviors(s.policies.behaviors || []); |
| 3011 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 3012 | renderBridgePolicy(s.policies.bridge || {}); |
| 3013 | renderLoggingPolicy(s.policies.logging || {}); |
| 3014 | loadAdmins(); |
| 3015 | loadAPIKeys(); |
| 3016 | loadConfigCards(); |
| 3017 | } catch(e) { |
| 3018 | document.getElementById('tls-badge').textContent = 'error'; |
| 3019 | } |
| 3020 | } |
| 3021 | |
| 3022 | DDED internal/auth/apikeys.go |
+288
| --- a/internal/auth/apikeys.go | ||
| +++ b/internal/auth/apikeys.go | ||
| @@ -0,0 +1,288 @@ | ||
| 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: []Scope{ScopeAdmin}, | |
| 212 | + CreatedAt: time.Now().UTC(), | |
| 213 | + Active: true, | |
| 214 | + }}} | |
| 215 | + return s | |
| 216 | +} | |
| 217 | + | |
| 218 | +// IsEmpty reports whether there are no keys. | |
| 219 | +func (s *APIKeyStore) IsEmpty() bool { | |
| 220 | + s.mu.RLock() | |
| 221 | + defer s.mu.RUnlock() | |
| 222 | + return len(s.data) == 0 | |
| 223 | +} | |
| 224 | + | |
| 225 | +func (s *APIKeyStore) load() error { | |
| 226 | + raw, err := os.ReadFile(s.path) | |
| 227 | + if os.IsNotExist(err) { | |
| 228 | + return nil | |
| 229 | + } | |
| 230 | + if err != nil { | |
| 231 | + return fmt.Errorf("apikeys: read %s: %w", s.path, err) | |
| 232 | + } | |
| 233 | + if err := json.Unmarshal(raw, &s.data); err != nil { | |
| 234 | + return fmt.Errorf("apikeys: parse: %w", err) | |
| 235 | + } | |
| 236 | + return nil | |
| 237 | +} | |
| 238 | + | |
| 239 | +func (s *APIKeyStore) save() error { | |
| 240 | + if s.path == "" { | |
| 241 | + return nil // in-memory only (tests) | |
| 242 | + } | |
| 243 | + raw, err := json.MarshalIndent(s.data, "", " ") | |
| 244 | + if err != nil { | |
| 245 | + return err | |
| 246 | + } | |
| 247 | + return os.WriteFile(s.path, raw, 0600) | |
| 248 | +} | |
| 249 | + | |
| 250 | +func hashToken(token string) string { | |
| 251 | + h := sha256.Sum256([]byte(token)) | |
| 252 | + return hex.EncodeToString(h[:]) | |
| 253 | +} | |
| 254 | + | |
| 255 | +func genToken() (string, error) { | |
| 256 | + b := make([]byte, 32) | |
| 257 | + if _, err := rand.Read(b); err != nil { | |
| 258 | + return "", err | |
| 259 | + } | |
| 260 | + return hex.EncodeToString(b), nil | |
| 261 | +} | |
| 262 | + | |
| 263 | +func newULID() string { | |
| 264 | + entropy := ulid.Monotonic(rand.Reader, 0) | |
| 265 | + return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String() | |
| 266 | +} | |
| 267 | + | |
| 268 | +// ParseScopes parses a comma-separated scope string into a slice. | |
| 269 | +// Returns an error if any scope is unrecognised. | |
| 270 | +func ParseScopes(s string) ([]Scope, error) { | |
| 271 | + parts := strings.Split(s, ",") | |
| 272 | + scopes := make([]Scope, 0, len(parts)) | |
| 273 | + for _, p := range parts { | |
| 274 | + p = strings.TrimSpace(p) | |
| 275 | + if p == "" { | |
| 276 | + continue | |
| 277 | + } | |
| 278 | + scope := Scope(p) | |
| 279 | + if !ValidScopes[scope] { | |
| 280 | + return nil, fmt.Errorf("unknown scope %q", p) | |
| 281 | + } | |
| 282 | + scopes = append(scopes, scope) | |
| 283 | + } | |
| 284 | + if len(scopes) == 0 { | |
| 285 | + return nil, fmt.Errorf("at least one scope is required") | |
| 286 | + } | |
| 287 | + return scopes, nil | |
| 288 | +} |
| --- a/internal/auth/apikeys.go | |
| +++ b/internal/auth/apikeys.go | |
| @@ -0,0 +1,288 @@ | |
| --- a/internal/auth/apikeys.go | |
| +++ b/internal/auth/apikeys.go | |
| @@ -0,0 +1,288 @@ | |
| 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: []Scope{ScopeAdmin}, |
| 212 | CreatedAt: time.Now().UTC(), |
| 213 | Active: true, |
| 214 | }}} |
| 215 | return s |
| 216 | } |
| 217 | |
| 218 | // IsEmpty reports whether there are no keys. |
| 219 | func (s *APIKeyStore) IsEmpty() bool { |
| 220 | s.mu.RLock() |
| 221 | defer s.mu.RUnlock() |
| 222 | return len(s.data) == 0 |
| 223 | } |
| 224 | |
| 225 | func (s *APIKeyStore) load() error { |
| 226 | raw, err := os.ReadFile(s.path) |
| 227 | if os.IsNotExist(err) { |
| 228 | return nil |
| 229 | } |
| 230 | if err != nil { |
| 231 | return fmt.Errorf("apikeys: read %s: %w", s.path, err) |
| 232 | } |
| 233 | if err := json.Unmarshal(raw, &s.data); err != nil { |
| 234 | return fmt.Errorf("apikeys: parse: %w", err) |
| 235 | } |
| 236 | return nil |
| 237 | } |
| 238 | |
| 239 | func (s *APIKeyStore) save() error { |
| 240 | if s.path == "" { |
| 241 | return nil // in-memory only (tests) |
| 242 | } |
| 243 | raw, err := json.MarshalIndent(s.data, "", " ") |
| 244 | if err != nil { |
| 245 | return err |
| 246 | } |
| 247 | return os.WriteFile(s.path, raw, 0600) |
| 248 | } |
| 249 | |
| 250 | func hashToken(token string) string { |
| 251 | h := sha256.Sum256([]byte(token)) |
| 252 | return hex.EncodeToString(h[:]) |
| 253 | } |
| 254 | |
| 255 | func genToken() (string, error) { |
| 256 | b := make([]byte, 32) |
| 257 | if _, err := rand.Read(b); err != nil { |
| 258 | return "", err |
| 259 | } |
| 260 | return hex.EncodeToString(b), nil |
| 261 | } |
| 262 | |
| 263 | func newULID() string { |
| 264 | entropy := ulid.Monotonic(rand.Reader, 0) |
| 265 | return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String() |
| 266 | } |
| 267 | |
| 268 | // ParseScopes parses a comma-separated scope string into a slice. |
| 269 | // Returns an error if any scope is unrecognised. |
| 270 | func ParseScopes(s string) ([]Scope, error) { |
| 271 | parts := strings.Split(s, ",") |
| 272 | scopes := make([]Scope, 0, len(parts)) |
| 273 | for _, p := range parts { |
| 274 | p = strings.TrimSpace(p) |
| 275 | if p == "" { |
| 276 | continue |
| 277 | } |
| 278 | scope := Scope(p) |
| 279 | if !ValidScopes[scope] { |
| 280 | return nil, fmt.Errorf("unknown scope %q", p) |
| 281 | } |
| 282 | scopes = append(scopes, scope) |
| 283 | } |
| 284 | if len(scopes) == 0 { |
| 285 | return nil, fmt.Errorf("at least one scope is required") |
| 286 | } |
| 287 | return scopes, nil |
| 288 | } |
+9
-8
| --- internal/mcp/mcp.go | ||
| +++ internal/mcp/mcp.go | ||
| @@ -52,31 +52,32 @@ | ||
| 52 | 52 | type ChannelInfo struct { |
| 53 | 53 | Name string `json:"name"` |
| 54 | 54 | Topic string `json:"topic,omitempty"` |
| 55 | 55 | Count int `json:"count"` |
| 56 | 56 | } |
| 57 | + | |
| 58 | +// TokenValidator validates API tokens. | |
| 59 | +type TokenValidator interface { | |
| 60 | + ValidToken(token string) bool | |
| 61 | +} | |
| 57 | 62 | |
| 58 | 63 | // Server is the MCP server. |
| 59 | 64 | type Server struct { |
| 60 | 65 | registry *registry.Registry |
| 61 | 66 | channels ChannelLister |
| 62 | 67 | sender Sender // optional — send_message returns error if nil |
| 63 | 68 | history HistoryQuerier // optional — get_history returns error if nil |
| 64 | - tokens map[string]struct{} | |
| 69 | + tokens TokenValidator | |
| 65 | 70 | log *slog.Logger |
| 66 | 71 | } |
| 67 | 72 | |
| 68 | 73 | // 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 { | |
| 74 | 75 | return &Server{ |
| 75 | 76 | registry: reg, |
| 76 | 77 | channels: channels, |
| 77 | - tokens: t, | |
| 78 | + tokens: tokens, | |
| 78 | 79 | log: log, |
| 79 | 80 | } |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | 83 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | ||
| 101 | 102 | // --- Auth --- |
| 102 | 103 | |
| 103 | 104 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 104 | 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | 106 | token := bearerToken(r) |
| 106 | - if _, ok := s.tokens[token]; !ok { | |
| 107 | + if !s.tokens.ValidToken(token) { | |
| 107 | 108 | writeRPCError(w, nil, -32001, "unauthorized") |
| 108 | 109 | return |
| 109 | 110 | } |
| 110 | 111 | next.ServeHTTP(w, r) |
| 111 | 112 | }) |
| 112 | 113 |
| --- 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 |
+8
-1
| --- internal/mcp/mcp_test.go | ||
| +++ internal/mcp/mcp_test.go | ||
| @@ -19,10 +19,17 @@ | ||
| 19 | 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | 20 | |
| 21 | 21 | const testToken = "test-mcp-token" |
| 22 | 22 | |
| 23 | 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 | +} | |
| 24 | 31 | |
| 25 | 32 | type mockProvisioner struct { |
| 26 | 33 | mu sync.Mutex |
| 27 | 34 | accounts map[string]string |
| 28 | 35 | } |
| @@ -93,11 +100,11 @@ | ||
| 93 | 100 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 94 | 101 | "#fleet": { |
| 95 | 102 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 96 | 103 | }, |
| 97 | 104 | }} |
| 98 | - srv := mcp.New(reg, channels, []string{testToken}, testLog). | |
| 105 | + srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog). | |
| 99 | 106 | WithSender(sender). |
| 100 | 107 | WithHistory(hist) |
| 101 | 108 | return httptest.NewServer(srv.Handler()) |
| 102 | 109 | } |
| 103 | 110 | |
| 104 | 111 |
| --- 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 |