ScuttleBot
Merge branch 'main' into feature/119-ircv3-tags Resolved conflicts in bridge.go (kept both +B bot mode and RELAYMSG detection) and sessionrelay/irc.go (kept RELAYMSG sender-suffix stripping from main).
Commit
37b1d9e5f52ed4d54d8eb55a71dda396e4fca07c769e9c255f51e8cb8e8ce189
Parent
1f324c2904df4b2…
30 files changed
+30
-7
+20
+99
+3
-1
+2
-1
+125
+11
-3
+5
-2
+3
-1
+3
-2
+2
-1
+7
-5
+2
-2
+38
-2
+18
-5
+76
-60
+300
-10
+288
+27
-4
+27
-4
+6
+3
-1
+7
+9
-8
+8
-1
+10
+62
-11
+7
+7
-1
+7
-1
~
cmd/scuttlebot/main.go
~
cmd/scuttlectl/internal/apiclient/apiclient.go
~
cmd/scuttlectl/main.go
~
deploy/compose/ergo/ircd.yaml.tmpl
~
internal/api/api_test.go
~
internal/api/apikeys.go
~
internal/api/channels_topology.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/policies.go
~
internal/api/server.go
~
internal/api/ui/index.html
~
internal/auth/apikeys.go
~
internal/bots/bridge/bridge.go
~
internal/bots/bridge/bridge.go
~
internal/config/config.go
~
internal/ergo/ircdconfig.go
~
internal/ergo/manager.go
~
internal/mcp/mcp.go
~
internal/mcp/mcp_test.go
~
internal/topology/policy.go
~
internal/topology/topology.go
~
pkg/ircagent/ircagent.go
~
pkg/sessionrelay/irc.go
~
pkg/sessionrelay/irc.go
+30
-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 == "" { |
| @@ -204,10 +217,11 @@ | ||
| 204 | 217 | Name: sc.Name, |
| 205 | 218 | Topic: sc.Topic, |
| 206 | 219 | Ops: sc.Ops, |
| 207 | 220 | Voice: sc.Voice, |
| 208 | 221 | Autojoin: sc.Autojoin, |
| 222 | + Modes: sc.Modes, | |
| 209 | 223 | }) |
| 210 | 224 | } |
| 211 | 225 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 212 | 226 | log.Error("topology provision failed", "err", err) |
| 213 | 227 | } |
| @@ -328,19 +342,28 @@ | ||
| 328 | 342 | staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
| 329 | 343 | for _, sc := range updated.Topology.Channels { |
| 330 | 344 | staticChannels = append(staticChannels, topology.ChannelConfig{ |
| 331 | 345 | Name: sc.Name, Topic: sc.Topic, |
| 332 | 346 | Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
| 347 | + Modes: sc.Modes, | |
| 333 | 348 | }) |
| 334 | 349 | } |
| 335 | 350 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 336 | 351 | log.Error("topology hot-reload failed", "err", err) |
| 337 | 352 | } |
| 338 | 353 | } |
| 339 | 354 | // Hot-reload bridge web TTL. |
| 340 | 355 | if bridgeBot != nil { |
| 341 | 356 | bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute) |
| 357 | + } | |
| 358 | + // Regenerate ircd.yaml and rehash Ergo on config changes. | |
| 359 | + if ergoMgr != nil { | |
| 360 | + if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil { | |
| 361 | + log.Error("ergo config hot-reload failed", "err", err) | |
| 362 | + } else { | |
| 363 | + log.Info("ergo config reloaded") | |
| 364 | + } | |
| 342 | 365 | } |
| 343 | 366 | }) |
| 344 | 367 | |
| 345 | 368 | // Start HTTP REST API server. |
| 346 | 369 | var llmCfg *config.LLMConfig |
| @@ -352,11 +375,11 @@ | ||
| 352 | 375 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 353 | 376 | var topoIface api.TopologyManager |
| 354 | 377 | if topoMgr != nil { |
| 355 | 378 | topoIface = topoMgr |
| 356 | 379 | } |
| 357 | - apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 380 | + apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 358 | 381 | handler := apiSrv.Handler() |
| 359 | 382 | |
| 360 | 383 | var httpServer, tlsServer *http.Server |
| 361 | 384 | |
| 362 | 385 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +441,11 @@ | ||
| 418 | 441 | } |
| 419 | 442 | }() |
| 420 | 443 | } |
| 421 | 444 | |
| 422 | 445 | // Start MCP server. |
| 423 | - mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) | |
| 446 | + mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) | |
| 424 | 447 | mcpServer := &http.Server{ |
| 425 | 448 | Addr: cfg.MCPAddr, |
| 426 | 449 | Handler: mcpSrv.Handler(), |
| 427 | 450 | } |
| 428 | 451 | go func() { |
| 429 | 452 |
| --- 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 == "" { |
| @@ -204,10 +217,11 @@ | |
| 204 | Name: sc.Name, |
| 205 | Topic: sc.Topic, |
| 206 | Ops: sc.Ops, |
| 207 | Voice: sc.Voice, |
| 208 | Autojoin: sc.Autojoin, |
| 209 | }) |
| 210 | } |
| 211 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 212 | log.Error("topology provision failed", "err", err) |
| 213 | } |
| @@ -328,19 +342,28 @@ | |
| 328 | staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
| 329 | for _, sc := range updated.Topology.Channels { |
| 330 | staticChannels = append(staticChannels, topology.ChannelConfig{ |
| 331 | Name: sc.Name, Topic: sc.Topic, |
| 332 | Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
| 333 | }) |
| 334 | } |
| 335 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 336 | log.Error("topology hot-reload failed", "err", err) |
| 337 | } |
| 338 | } |
| 339 | // Hot-reload bridge web TTL. |
| 340 | if bridgeBot != nil { |
| 341 | bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute) |
| 342 | } |
| 343 | }) |
| 344 | |
| 345 | // Start HTTP REST API server. |
| 346 | var llmCfg *config.LLMConfig |
| @@ -352,11 +375,11 @@ | |
| 352 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 353 | var topoIface api.TopologyManager |
| 354 | if topoMgr != nil { |
| 355 | topoIface = topoMgr |
| 356 | } |
| 357 | apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 358 | handler := apiSrv.Handler() |
| 359 | |
| 360 | var httpServer, tlsServer *http.Server |
| 361 | |
| 362 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +441,11 @@ | |
| 418 | } |
| 419 | }() |
| 420 | } |
| 421 | |
| 422 | // Start MCP server. |
| 423 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) |
| 424 | mcpServer := &http.Server{ |
| 425 | Addr: cfg.MCPAddr, |
| 426 | Handler: mcpSrv.Handler(), |
| 427 | } |
| 428 | go func() { |
| 429 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -138,18 +138,31 @@ | |
| 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | log.Error("registry load", "err", err) |
| 140 | os.Exit(1) |
| 141 | } |
| 142 | |
| 143 | // API key store — per-consumer tokens with scoped permissions. |
| 144 | apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json")) |
| 145 | if err != nil { |
| 146 | log.Error("api key store", "err", err) |
| 147 | os.Exit(1) |
| 148 | } |
| 149 | // Migrate legacy api_token into key store on first run. |
| 150 | if apiKeyStore.IsEmpty() { |
| 151 | apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) |
| 152 | if err != nil { |
| 153 | log.Error("api token", "err", err) |
| 154 | os.Exit(1) |
| 155 | } |
| 156 | if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil { |
| 157 | log.Error("migrate api token to key store", "err", err) |
| 158 | os.Exit(1) |
| 159 | } |
| 160 | log.Info("migrated api_token to api_keys.json", "token", apiToken) |
| 161 | } else { |
| 162 | log.Info("api key store loaded", "keys", len(apiKeyStore.List())) |
| 163 | } |
| 164 | |
| 165 | // Start bridge bot (powers the web chat UI). |
| 166 | var bridgeBot *bridge.Bot |
| 167 | if cfg.Bridge.Enabled { |
| 168 | if cfg.Bridge.Password == "" { |
| @@ -204,10 +217,11 @@ | |
| 217 | Name: sc.Name, |
| 218 | Topic: sc.Topic, |
| 219 | Ops: sc.Ops, |
| 220 | Voice: sc.Voice, |
| 221 | Autojoin: sc.Autojoin, |
| 222 | Modes: sc.Modes, |
| 223 | }) |
| 224 | } |
| 225 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 226 | log.Error("topology provision failed", "err", err) |
| 227 | } |
| @@ -328,19 +342,28 @@ | |
| 342 | staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
| 343 | for _, sc := range updated.Topology.Channels { |
| 344 | staticChannels = append(staticChannels, topology.ChannelConfig{ |
| 345 | Name: sc.Name, Topic: sc.Topic, |
| 346 | Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
| 347 | Modes: sc.Modes, |
| 348 | }) |
| 349 | } |
| 350 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 351 | log.Error("topology hot-reload failed", "err", err) |
| 352 | } |
| 353 | } |
| 354 | // Hot-reload bridge web TTL. |
| 355 | if bridgeBot != nil { |
| 356 | bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute) |
| 357 | } |
| 358 | // Regenerate ircd.yaml and rehash Ergo on config changes. |
| 359 | if ergoMgr != nil { |
| 360 | if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil { |
| 361 | log.Error("ergo config hot-reload failed", "err", err) |
| 362 | } else { |
| 363 | log.Info("ergo config reloaded") |
| 364 | } |
| 365 | } |
| 366 | }) |
| 367 | |
| 368 | // Start HTTP REST API server. |
| 369 | var llmCfg *config.LLMConfig |
| @@ -352,11 +375,11 @@ | |
| 375 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 376 | var topoIface api.TopologyManager |
| 377 | if topoMgr != nil { |
| 378 | topoIface = topoMgr |
| 379 | } |
| 380 | apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 381 | handler := apiSrv.Handler() |
| 382 | |
| 383 | var httpServer, tlsServer *http.Server |
| 384 | |
| 385 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +441,11 @@ | |
| 441 | } |
| 442 | }() |
| 443 | } |
| 444 | |
| 445 | // Start MCP server. |
| 446 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) |
| 447 | mcpServer := &http.Server{ |
| 448 | Addr: cfg.MCPAddr, |
| 449 | Handler: mcpSrv.Handler(), |
| 450 | } |
| 451 | go func() { |
| 452 |
| --- 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 |
| --- deploy/compose/ergo/ircd.yaml.tmpl | ||
| +++ deploy/compose/ergo/ircd.yaml.tmpl | ||
| @@ -13,11 +13,13 @@ | ||
| 13 | 13 | enforce-utf8: true |
| 14 | 14 | lookup-hostnames: false |
| 15 | 15 | forward-confirm-hostnames: false |
| 16 | 16 | check-ident: false |
| 17 | 17 | relaymsg: |
| 18 | - enabled: false | |
| 18 | + enabled: true | |
| 19 | + separators: / | |
| 20 | + available-to-chanops: false | |
| 19 | 21 | ip-cloaking: |
| 20 | 22 | enabled: false |
| 21 | 23 | max-sendq: "1M" |
| 22 | 24 | ip-limits: |
| 23 | 25 | count-exempted: true |
| 24 | 26 |
| --- deploy/compose/ergo/ircd.yaml.tmpl | |
| +++ deploy/compose/ergo/ircd.yaml.tmpl | |
| @@ -13,11 +13,13 @@ | |
| 13 | enforce-utf8: true |
| 14 | lookup-hostnames: false |
| 15 | forward-confirm-hostnames: false |
| 16 | check-ident: false |
| 17 | relaymsg: |
| 18 | enabled: false |
| 19 | ip-cloaking: |
| 20 | enabled: false |
| 21 | max-sendq: "1M" |
| 22 | ip-limits: |
| 23 | count-exempted: true |
| 24 |
| --- deploy/compose/ergo/ircd.yaml.tmpl | |
| +++ deploy/compose/ergo/ircd.yaml.tmpl | |
| @@ -13,11 +13,13 @@ | |
| 13 | enforce-utf8: true |
| 14 | lookup-hostnames: false |
| 15 | forward-confirm-hostnames: false |
| 16 | check-ident: false |
| 17 | relaymsg: |
| 18 | enabled: true |
| 19 | separators: / |
| 20 | available-to-chanops: false |
| 21 | ip-cloaking: |
| 22 | enabled: false |
| 23 | max-sendq: "1M" |
| 24 | ip-limits: |
| 25 | count-exempted: true |
| 26 |
+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 | } |
+11
-3
| --- internal/api/channels_topology.go | ||
| +++ internal/api/channels_topology.go | ||
| @@ -14,10 +14,11 @@ | ||
| 14 | 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | 15 | DropChannel(channel string) |
| 16 | 16 | Policy() *topology.Policy |
| 17 | 17 | GrantAccess(nick, channel, level string) |
| 18 | 18 | RevokeAccess(nick, channel string) |
| 19 | + ListChannels() []topology.ChannelInfo | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | type provisionChannelRequest struct { |
| 22 | 23 | Name string `json:"name"` |
| 23 | 24 | Topic string `json:"topic,omitempty"` |
| @@ -51,22 +52,27 @@ | ||
| 51 | 52 | return |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | 55 | policy := s.topoMgr.Policy() |
| 55 | 56 | |
| 56 | - // Merge autojoin from policy if the caller didn't specify any. | |
| 57 | + // Merge autojoin and modes from policy if the caller didn't specify any. | |
| 57 | 58 | autojoin := req.Autojoin |
| 58 | 59 | if len(autojoin) == 0 && policy != nil { |
| 59 | 60 | autojoin = policy.AutojoinFor(req.Name) |
| 60 | 61 | } |
| 62 | + var modes []string | |
| 63 | + if policy != nil { | |
| 64 | + modes = policy.ModesFor(req.Name) | |
| 65 | + } | |
| 61 | 66 | |
| 62 | 67 | ch := topology.ChannelConfig{ |
| 63 | 68 | Name: req.Name, |
| 64 | 69 | Topic: req.Topic, |
| 65 | 70 | Ops: req.Ops, |
| 66 | 71 | Voice: req.Voice, |
| 67 | 72 | Autojoin: autojoin, |
| 73 | + Modes: modes, | |
| 68 | 74 | } |
| 69 | 75 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 70 | 76 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 71 | 77 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 72 | 78 | return |
| @@ -91,12 +97,13 @@ | ||
| 91 | 97 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 92 | 98 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 93 | 99 | } |
| 94 | 100 | |
| 95 | 101 | type topologyResponse struct { |
| 96 | - StaticChannels []string `json:"static_channels"` | |
| 97 | - Types []channelTypeInfo `json:"types"` | |
| 102 | + StaticChannels []string `json:"static_channels"` | |
| 103 | + Types []channelTypeInfo `json:"types"` | |
| 104 | + ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"` | |
| 98 | 105 | } |
| 99 | 106 | |
| 100 | 107 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 101 | 108 | // Drops the ChanServ registration of an ephemeral channel. |
| 102 | 109 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -146,7 +153,8 @@ | ||
| 146 | 153 | } |
| 147 | 154 | |
| 148 | 155 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 149 | 156 | StaticChannels: staticNames, |
| 150 | 157 | Types: typeInfos, |
| 158 | + ActiveChannels: s.topoMgr.ListChannels(), | |
| 151 | 159 | }) |
| 152 | 160 | } |
| 153 | 161 |
| --- internal/api/channels_topology.go | |
| +++ internal/api/channels_topology.go | |
| @@ -14,10 +14,11 @@ | |
| 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | DropChannel(channel string) |
| 16 | Policy() *topology.Policy |
| 17 | GrantAccess(nick, channel, level string) |
| 18 | RevokeAccess(nick, channel string) |
| 19 | } |
| 20 | |
| 21 | type provisionChannelRequest struct { |
| 22 | Name string `json:"name"` |
| 23 | Topic string `json:"topic,omitempty"` |
| @@ -51,22 +52,27 @@ | |
| 51 | return |
| 52 | } |
| 53 | |
| 54 | policy := s.topoMgr.Policy() |
| 55 | |
| 56 | // Merge autojoin from policy if the caller didn't specify any. |
| 57 | autojoin := req.Autojoin |
| 58 | if len(autojoin) == 0 && policy != nil { |
| 59 | autojoin = policy.AutojoinFor(req.Name) |
| 60 | } |
| 61 | |
| 62 | ch := topology.ChannelConfig{ |
| 63 | Name: req.Name, |
| 64 | Topic: req.Topic, |
| 65 | Ops: req.Ops, |
| 66 | Voice: req.Voice, |
| 67 | Autojoin: autojoin, |
| 68 | } |
| 69 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 70 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 71 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 72 | return |
| @@ -91,12 +97,13 @@ | |
| 91 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 92 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 93 | } |
| 94 | |
| 95 | type topologyResponse struct { |
| 96 | StaticChannels []string `json:"static_channels"` |
| 97 | Types []channelTypeInfo `json:"types"` |
| 98 | } |
| 99 | |
| 100 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 101 | // Drops the ChanServ registration of an ephemeral channel. |
| 102 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -146,7 +153,8 @@ | |
| 146 | } |
| 147 | |
| 148 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 149 | StaticChannels: staticNames, |
| 150 | Types: typeInfos, |
| 151 | }) |
| 152 | } |
| 153 |
| --- internal/api/channels_topology.go | |
| +++ internal/api/channels_topology.go | |
| @@ -14,10 +14,11 @@ | |
| 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | DropChannel(channel string) |
| 16 | Policy() *topology.Policy |
| 17 | GrantAccess(nick, channel, level string) |
| 18 | RevokeAccess(nick, channel string) |
| 19 | ListChannels() []topology.ChannelInfo |
| 20 | } |
| 21 | |
| 22 | type provisionChannelRequest struct { |
| 23 | Name string `json:"name"` |
| 24 | Topic string `json:"topic,omitempty"` |
| @@ -51,22 +52,27 @@ | |
| 52 | return |
| 53 | } |
| 54 | |
| 55 | policy := s.topoMgr.Policy() |
| 56 | |
| 57 | // Merge autojoin and modes from policy if the caller didn't specify any. |
| 58 | autojoin := req.Autojoin |
| 59 | if len(autojoin) == 0 && policy != nil { |
| 60 | autojoin = policy.AutojoinFor(req.Name) |
| 61 | } |
| 62 | var modes []string |
| 63 | if policy != nil { |
| 64 | modes = policy.ModesFor(req.Name) |
| 65 | } |
| 66 | |
| 67 | ch := topology.ChannelConfig{ |
| 68 | Name: req.Name, |
| 69 | Topic: req.Topic, |
| 70 | Ops: req.Ops, |
| 71 | Voice: req.Voice, |
| 72 | Autojoin: autojoin, |
| 73 | Modes: modes, |
| 74 | } |
| 75 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 76 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 77 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 78 | return |
| @@ -91,12 +97,13 @@ | |
| 97 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 98 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 99 | } |
| 100 | |
| 101 | type topologyResponse struct { |
| 102 | StaticChannels []string `json:"static_channels"` |
| 103 | Types []channelTypeInfo `json:"types"` |
| 104 | ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"` |
| 105 | } |
| 106 | |
| 107 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 108 | // Drops the ChanServ registration of an ephemeral channel. |
| 109 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -146,7 +153,8 @@ | |
| 153 | } |
| 154 | |
| 155 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 156 | StaticChannels: staticNames, |
| 157 | Types: typeInfos, |
| 158 | ActiveChannels: s.topoMgr.ListChannels(), |
| 159 | }) |
| 160 | } |
| 161 |
| --- 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 | |
| @@ -48,10 +49,12 @@ | ||
| 48 | 49 | |
| 49 | 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 50 | 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 51 | 52 | } |
| 52 | 53 | |
| 54 | +func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } | |
| 55 | + | |
| 53 | 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 54 | 57 | type stubProvisioner struct { |
| 55 | 58 | accounts map[string]string |
| 56 | 59 | } |
| 57 | 60 | |
| @@ -74,11 +77,11 @@ | ||
| 74 | 77 | |
| 75 | 78 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | 79 | t.Helper() |
| 77 | 80 | reg := registry.New(nil, []byte("key")) |
| 78 | 81 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 79 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 82 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 80 | 83 | t.Cleanup(srv.Close) |
| 81 | 84 | return srv, "tok" |
| 82 | 85 | } |
| 83 | 86 | |
| 84 | 87 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +88,11 @@ | ||
| 85 | 88 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | 89 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | 90 | t.Helper() |
| 88 | 91 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | 92 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 90 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 93 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 91 | 94 | t.Cleanup(srv.Close) |
| 92 | 95 | return srv, "tok" |
| 93 | 96 | } |
| 94 | 97 | |
| 95 | 98 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 | 99 |
| --- 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 | |
| @@ -48,10 +49,12 @@ | |
| 48 | |
| 49 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 50 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 51 | } |
| 52 | |
| 53 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 54 | type stubProvisioner struct { |
| 55 | accounts map[string]string |
| 56 | } |
| 57 | |
| @@ -74,11 +77,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 +88,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 | |
| @@ -48,10 +49,12 @@ | |
| 49 | |
| 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 52 | } |
| 53 | |
| 54 | func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } |
| 55 | |
| 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 57 | type stubProvisioner struct { |
| 58 | accounts map[string]string |
| 59 | } |
| 60 | |
| @@ -74,11 +77,11 @@ | |
| 77 | |
| 78 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 79 | t.Helper() |
| 80 | reg := registry.New(nil, []byte("key")) |
| 81 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 82 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 83 | t.Cleanup(srv.Close) |
| 84 | return srv, "tok" |
| 85 | } |
| 86 | |
| 87 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +88,11 @@ | |
| 88 | // real registry backed by stubProvisioner, so agent registration works. |
| 89 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 90 | t.Helper() |
| 91 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 92 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 93 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 94 | t.Cleanup(srv.Close) |
| 95 | return srv, "tok" |
| 96 | } |
| 97 | |
| 98 | func TestHandleProvisionChannel(t *testing.T) { |
| 99 |
+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 |
+18
-5
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -68,18 +68,31 @@ | ||
| 68 | 68 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | 69 | Allow []string `json:"allow,omitempty"` |
| 70 | 70 | Block []string `json:"block,omitempty"` |
| 71 | 71 | Default bool `json:"default,omitempty"` |
| 72 | 72 | } |
| 73 | + | |
| 74 | +// ROETemplate is a rules-of-engagement template. | |
| 75 | +type ROETemplate struct { | |
| 76 | + Name string `json:"name"` | |
| 77 | + Description string `json:"description,omitempty"` | |
| 78 | + Channels []string `json:"channels,omitempty"` | |
| 79 | + Permissions []string `json:"permissions,omitempty"` | |
| 80 | + RateLimit struct { | |
| 81 | + MessagesPerSecond float64 `json:"messages_per_second,omitempty"` | |
| 82 | + Burst int `json:"burst,omitempty"` | |
| 83 | + } `json:"rate_limit,omitempty"` | |
| 84 | +} | |
| 73 | 85 | |
| 74 | 86 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | 87 | type Policies struct { |
| 76 | - Behaviors []BehaviorConfig `json:"behaviors"` | |
| 77 | - AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 78 | - Bridge BridgePolicy `json:"bridge"` | |
| 79 | - Logging LoggingPolicy `json:"logging"` | |
| 80 | - LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 88 | + Behaviors []BehaviorConfig `json:"behaviors"` | |
| 89 | + AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 90 | + Bridge BridgePolicy `json:"bridge"` | |
| 91 | + Logging LoggingPolicy `json:"logging"` | |
| 92 | + LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 93 | + ROETemplates []ROETemplate `json:"roe_templates,omitempty"` | |
| 81 | 94 | } |
| 82 | 95 | |
| 83 | 96 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | 97 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | 98 | { |
| 86 | 99 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -68,18 +68,31 @@ | |
| 68 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | Allow []string `json:"allow,omitempty"` |
| 70 | Block []string `json:"block,omitempty"` |
| 71 | Default bool `json:"default,omitempty"` |
| 72 | } |
| 73 | |
| 74 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | type Policies struct { |
| 76 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 77 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 78 | Bridge BridgePolicy `json:"bridge"` |
| 79 | Logging LoggingPolicy `json:"logging"` |
| 80 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 81 | } |
| 82 | |
| 83 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | { |
| 86 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -68,18 +68,31 @@ | |
| 68 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | Allow []string `json:"allow,omitempty"` |
| 70 | Block []string `json:"block,omitempty"` |
| 71 | Default bool `json:"default,omitempty"` |
| 72 | } |
| 73 | |
| 74 | // ROETemplate is a rules-of-engagement template. |
| 75 | type ROETemplate struct { |
| 76 | Name string `json:"name"` |
| 77 | Description string `json:"description,omitempty"` |
| 78 | Channels []string `json:"channels,omitempty"` |
| 79 | Permissions []string `json:"permissions,omitempty"` |
| 80 | RateLimit struct { |
| 81 | MessagesPerSecond float64 `json:"messages_per_second,omitempty"` |
| 82 | Burst int `json:"burst,omitempty"` |
| 83 | } `json:"rate_limit,omitempty"` |
| 84 | } |
| 85 | |
| 86 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 87 | type Policies struct { |
| 88 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 89 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 90 | Bridge BridgePolicy `json:"bridge"` |
| 91 | Logging LoggingPolicy `json:"logging"` |
| 92 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 93 | ROETemplates []ROETemplate `json:"roe_templates,omitempty"` |
| 94 | } |
| 95 | |
| 96 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 97 | var defaultBehaviors = []BehaviorConfig{ |
| 98 | { |
| 99 |
+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 |
+300
-10
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -485,10 +485,40 @@ | ||
| 485 | 485 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | 486 | </div> |
| 487 | 487 | </div> |
| 488 | 488 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | 489 | </div> |
| 490 | + | |
| 491 | + <!-- topology panel --> | |
| 492 | + <div class="card" id="card-topology"> | |
| 493 | + <div class="card-header" onclick="toggleCard('card-topology',event)"> | |
| 494 | + <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span> | |
| 495 | + <div class="spacer"></div> | |
| 496 | + <div style="display:flex;gap:6px;align-items:center"> | |
| 497 | + <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off"> | |
| 498 | + <button class="sm primary" onclick="provisionChannel()">provision</button> | |
| 499 | + </div> | |
| 500 | + </div> | |
| 501 | + <div class="card-body" style="padding:0"> | |
| 502 | + <div id="topology-types"></div> | |
| 503 | + <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div> | |
| 504 | + </div> | |
| 505 | + </div> | |
| 506 | + | |
| 507 | + <!-- ROE templates --> | |
| 508 | + <div class="card" id="card-roe"> | |
| 509 | + <div class="card-header" onclick="toggleCard('card-roe',event)"> | |
| 510 | + <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span> | |
| 511 | + <div class="spacer"></div> | |
| 512 | + <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button> | |
| 513 | + </div> | |
| 514 | + <div class="card-body"> | |
| 515 | + <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p> | |
| 516 | + <div id="roe-list"></div> | |
| 517 | + <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button> | |
| 518 | + </div> | |
| 519 | + </div> | |
| 490 | 520 | </div> |
| 491 | 521 | </div> |
| 492 | 522 | |
| 493 | 523 | <!-- CHAT --> |
| 494 | 524 | <div class="tab-pane" id="pane-chat"> |
| @@ -580,10 +610,40 @@ | ||
| 580 | 610 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | 611 | </form> |
| 582 | 612 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | 613 | </div> |
| 584 | 614 | </div> |
| 615 | + | |
| 616 | + <!-- api keys --> | |
| 617 | + <div class="card" id="card-apikeys"> | |
| 618 | + <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> | |
| 619 | + <div id="apikeys-list-container"></div> | |
| 620 | + <div class="card-body" style="border-top:1px solid #21262d"> | |
| 621 | + <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> | |
| 622 | + <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px"> | |
| 623 | + <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> | |
| 624 | + <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> | |
| 625 | + <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> | |
| 626 | + </div> | |
| 627 | + <div> | |
| 628 | + <label style="margin-bottom:6px;display:block">scopes</label> | |
| 629 | + <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px"> | |
| 630 | + <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label> | |
| 631 | + <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label> | |
| 632 | + <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label> | |
| 633 | + <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label> | |
| 634 | + <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label> | |
| 635 | + <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label> | |
| 636 | + <label><input type="checkbox" value="config" class="apikey-scope"> config</label> | |
| 637 | + <label><input type="checkbox" value="read" class="apikey-scope"> read</label> | |
| 638 | + </div> | |
| 639 | + </div> | |
| 640 | + <button type="submit" class="primary sm" style="align-self:flex-start">create key</button> | |
| 641 | + </form> | |
| 642 | + <div id="add-apikey-result" style="margin-top:10px"></div> | |
| 643 | + </div> | |
| 644 | + </div> | |
| 585 | 645 | |
| 586 | 646 | <!-- tls --> |
| 587 | 647 | <div class="card" id="card-tls"> |
| 588 | 648 | <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 | 649 | <div class="card-body"> |
| @@ -798,10 +858,31 @@ | ||
| 798 | 858 | </div> |
| 799 | 859 | <div class="setting-row"> |
| 800 | 860 | <div class="setting-label">IRC address</div> |
| 801 | 861 | <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div> |
| 802 | 862 | <input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px"> |
| 863 | + </div> | |
| 864 | + <div class="setting-row"> | |
| 865 | + <div class="setting-label">require SASL</div> | |
| 866 | + <div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div> | |
| 867 | + <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> | |
| 868 | + <input type="checkbox" id="ergo-require-sasl"> | |
| 869 | + <span style="font-size:12px">enforce SASL</span> | |
| 870 | + </label> | |
| 871 | + </div> | |
| 872 | + <div class="setting-row"> | |
| 873 | + <div class="setting-label">default channel modes</div> | |
| 874 | + <div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div> | |
| 875 | + <input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px"> | |
| 876 | + </div> | |
| 877 | + <div class="setting-row"> | |
| 878 | + <div class="setting-label">message history</div> | |
| 879 | + <div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div> | |
| 880 | + <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> | |
| 881 | + <input type="checkbox" id="ergo-history-enabled"> | |
| 882 | + <span style="font-size:12px">enabled</span> | |
| 883 | + </label> | |
| 803 | 884 | </div> |
| 804 | 885 | <div class="setting-row"> |
| 805 | 886 | <div class="setting-label">external mode</div> |
| 806 | 887 | <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div> |
| 807 | 888 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| @@ -1726,10 +1807,19 @@ | ||
| 1726 | 1807 | allChannels = (data.channels || []).sort(); |
| 1727 | 1808 | renderChanList(); |
| 1728 | 1809 | } catch(e) { |
| 1729 | 1810 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1730 | 1811 | } |
| 1812 | + loadTopology(); | |
| 1813 | + // Load ROE templates from policies for the ROE card. | |
| 1814 | + try { | |
| 1815 | + const s = await api('GET', '/v1/settings'); | |
| 1816 | + if (s && s.policies) { | |
| 1817 | + currentPolicies = s.policies; | |
| 1818 | + renderROETemplates(s.policies.roe_templates || []); | |
| 1819 | + } | |
| 1820 | + } catch(e) {} | |
| 1731 | 1821 | } |
| 1732 | 1822 | |
| 1733 | 1823 | function renderChanList() { |
| 1734 | 1824 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1735 | 1825 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1764,10 +1854,138 @@ | ||
| 1764 | 1854 | await loadChanTab(); |
| 1765 | 1855 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1766 | 1856 | } catch(e) { alert('Join failed: '+e.message); } |
| 1767 | 1857 | } |
| 1768 | 1858 | document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); }); |
| 1859 | + | |
| 1860 | +// --- topology panel (#115) + task channels (#114) --- | |
| 1861 | +async function loadTopology() { | |
| 1862 | + try { | |
| 1863 | + const data = await api('GET', '/v1/topology'); | |
| 1864 | + renderTopologyTypes(data.types || []); | |
| 1865 | + renderTopologyActive(data.active_channels || [], data.types || []); | |
| 1866 | + } catch(e) { | |
| 1867 | + document.getElementById('topology-types').innerHTML = ''; | |
| 1868 | + document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>'; | |
| 1869 | + } | |
| 1870 | +} | |
| 1871 | + | |
| 1872 | +function renderTopologyTypes(types) { | |
| 1873 | + if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; } | |
| 1874 | + const rows = types.map(t => { | |
| 1875 | + const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—'; | |
| 1876 | + const tags = []; | |
| 1877 | + if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>'); | |
| 1878 | + if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`); | |
| 1879 | + return `<tr> | |
| 1880 | + <td><strong>${esc(t.name)}</strong></td> | |
| 1881 | + <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td> | |
| 1882 | + <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td> | |
| 1883 | + <td style="font-size:12px">${ttl}</td> | |
| 1884 | + <td>${tags.join(' ')}</td> | |
| 1885 | + </tr>`; | |
| 1886 | + }).join(''); | |
| 1887 | + document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 1888 | +} | |
| 1889 | + | |
| 1890 | +function renderTopologyActive(channels, types) { | |
| 1891 | + const el = document.getElementById('topology-active'); | |
| 1892 | + const tasks = channels.filter(c => c.ephemeral || c.type === 'task'); | |
| 1893 | + if (!tasks.length) { | |
| 1894 | + el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>'; | |
| 1895 | + return; | |
| 1896 | + } | |
| 1897 | + const rows = tasks.map(c => { | |
| 1898 | + const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—'; | |
| 1899 | + const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—'; | |
| 1900 | + return `<tr> | |
| 1901 | + <td><strong>${esc(c.name)}</strong></td> | |
| 1902 | + <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td> | |
| 1903 | + <td style="font-size:12px">${age}</td> | |
| 1904 | + <td style="font-size:12px">${ttl}</td> | |
| 1905 | + <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td> | |
| 1906 | + </tr>`; | |
| 1907 | + }).join(''); | |
| 1908 | + el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; | |
| 1909 | +} | |
| 1910 | + | |
| 1911 | +function timeSince(date) { | |
| 1912 | + const s = Math.floor((new Date() - date) / 1000); | |
| 1913 | + if (s < 60) return s + 's'; | |
| 1914 | + if (s < 3600) return Math.floor(s/60) + 'm'; | |
| 1915 | + if (s < 86400) return Math.floor(s/3600) + 'h'; | |
| 1916 | + return Math.floor(s/86400) + 'd'; | |
| 1917 | +} | |
| 1918 | + | |
| 1919 | +async function provisionChannel() { | |
| 1920 | + let ch = document.getElementById('provision-channel-input').value.trim(); | |
| 1921 | + if (!ch) return; | |
| 1922 | + if (!ch.startsWith('#')) ch = '#' + ch; | |
| 1923 | + try { | |
| 1924 | + await api('POST', '/v1/channels', {name: ch}); | |
| 1925 | + document.getElementById('provision-channel-input').value = ''; | |
| 1926 | + loadTopology(); | |
| 1927 | + loadChanTab(); | |
| 1928 | + } catch(e) { alert('Provision failed: ' + e.message); } | |
| 1929 | +} | |
| 1930 | + | |
| 1931 | +async function dropChannel(ch) { | |
| 1932 | + if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return; | |
| 1933 | + const slug = ch.replace(/^#/,''); | |
| 1934 | + try { | |
| 1935 | + await api('DELETE', `/v1/topology/channels/${slug}`); | |
| 1936 | + loadTopology(); | |
| 1937 | + loadChanTab(); | |
| 1938 | + } catch(e) { alert('Drop failed: ' + e.message); } | |
| 1939 | +} | |
| 1940 | + | |
| 1941 | +// --- ROE template editor (#118) --- | |
| 1942 | +function renderROETemplates(templates) { | |
| 1943 | + const el = document.getElementById('roe-list'); | |
| 1944 | + if (!templates || !templates.length) { | |
| 1945 | + el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>'; | |
| 1946 | + return; | |
| 1947 | + } | |
| 1948 | + el.innerHTML = templates.map((t, i) => ` | |
| 1949 | + <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117"> | |
| 1950 | + <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px"> | |
| 1951 | + <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)"> | |
| 1952 | + <button class="sm danger" onclick="removeROE(${i})">remove</button> | |
| 1953 | + </div> | |
| 1954 | + <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px"> | |
| 1955 | + <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div> | |
| 1956 | + <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div> | |
| 1957 | + </div> | |
| 1958 | + <div style="display:flex;gap:10px"> | |
| 1959 | + <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div> | |
| 1960 | + <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div> | |
| 1961 | + <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div> | |
| 1962 | + </div> | |
| 1963 | + </div> | |
| 1964 | + `).join(''); | |
| 1965 | +} | |
| 1966 | + | |
| 1967 | +function addROETemplate() { | |
| 1968 | + if (!currentPolicies.roe_templates) currentPolicies.roe_templates = []; | |
| 1969 | + currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []}); | |
| 1970 | + renderROETemplates(currentPolicies.roe_templates); | |
| 1971 | +} | |
| 1972 | +function removeROE(i) { | |
| 1973 | + currentPolicies.roe_templates.splice(i, 1); | |
| 1974 | + renderROETemplates(currentPolicies.roe_templates); | |
| 1975 | +} | |
| 1976 | +function updateROE(i, field, val) { | |
| 1977 | + if (field === 'channels' || field === 'permissions') { | |
| 1978 | + currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean); | |
| 1979 | + } else { | |
| 1980 | + currentPolicies.roe_templates[i][field] = val; | |
| 1981 | + } | |
| 1982 | +} | |
| 1983 | +function updateROERateLimit(i, field, val) { | |
| 1984 | + if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {}; | |
| 1985 | + currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0; | |
| 1986 | +} | |
| 1769 | 1987 | |
| 1770 | 1988 | // --- chat --- |
| 1771 | 1989 | let chatChannel = null, chatSSE = null; |
| 1772 | 1990 | |
| 1773 | 1991 | async function loadChannels() { |
| @@ -1892,14 +2110,16 @@ | ||
| 1892 | 2110 | let _chatUnread = 0; |
| 1893 | 2111 | |
| 1894 | 2112 | function appendMsg(msg, isHistory) { |
| 1895 | 2113 | const area = document.getElementById('chat-msgs'); |
| 1896 | 2114 | |
| 1897 | - // Parse "[nick] text" sent by the bridge bot on behalf of a web user | |
| 2115 | + // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text". | |
| 1898 | 2116 | let displayNick = msg.nick; |
| 1899 | 2117 | let displayText = msg.text; |
| 1900 | - if (msg.nick === 'bridge') { | |
| 2118 | + if (msg.nick && msg.nick.endsWith('/bridge')) { | |
| 2119 | + displayNick = msg.nick.slice(0, -'/bridge'.length); | |
| 2120 | + } else if (msg.nick === 'bridge') { | |
| 1901 | 2121 | const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/); |
| 1902 | 2122 | if (m) { displayNick = m[1]; displayText = m[2]; } |
| 1903 | 2123 | } |
| 1904 | 2124 | |
| 1905 | 2125 | const atMs = new Date(msg.at).getTime(); |
| @@ -2542,10 +2762,73 @@ | ||
| 2542 | 2762 | try { |
| 2543 | 2763 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2544 | 2764 | alert('Password updated.'); |
| 2545 | 2765 | } catch(e) { alert('Failed: ' + e.message); } |
| 2546 | 2766 | } |
| 2767 | + | |
| 2768 | +// --- API keys --- | |
| 2769 | +async function loadAPIKeys() { | |
| 2770 | + try { | |
| 2771 | + const keys = await api('GET', '/v1/api-keys'); | |
| 2772 | + renderAPIKeys(keys || []); | |
| 2773 | + } catch(e) { | |
| 2774 | + document.getElementById('apikeys-list-container').innerHTML = ''; | |
| 2775 | + } | |
| 2776 | +} | |
| 2777 | + | |
| 2778 | +function renderAPIKeys(keys) { | |
| 2779 | + const el = document.getElementById('apikeys-list-container'); | |
| 2780 | + if (!keys.length) { el.innerHTML = ''; return; } | |
| 2781 | + const rows = keys.map(k => { | |
| 2782 | + const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>'; | |
| 2783 | + const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' '); | |
| 2784 | + const lastUsed = k.last_used ? fmtTime(k.last_used) : '—'; | |
| 2785 | + const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : ''; | |
| 2786 | + return `<tr> | |
| 2787 | + <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td> | |
| 2788 | + <td>${scopes}</td> | |
| 2789 | + <td style="font-size:12px">${status}</td> | |
| 2790 | + <td style="color:#8b949e;font-size:12px">${lastUsed}</td> | |
| 2791 | + <td><div class="actions">${revokeBtn}</div></td> | |
| 2792 | + </tr>`; | |
| 2793 | + }).join(''); | |
| 2794 | + 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>`; | |
| 2795 | +} | |
| 2796 | + | |
| 2797 | +async function createAPIKey(e) { | |
| 2798 | + e.preventDefault(); | |
| 2799 | + const name = document.getElementById('new-apikey-name').value.trim(); | |
| 2800 | + const expires = document.getElementById('new-apikey-expires').value.trim(); | |
| 2801 | + const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value); | |
| 2802 | + const resultEl = document.getElementById('add-apikey-result'); | |
| 2803 | + if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; } | |
| 2804 | + if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; } | |
| 2805 | + try { | |
| 2806 | + const body = { name, scopes }; | |
| 2807 | + if (expires) body.expires_in = expires; | |
| 2808 | + const result = await api('POST', '/v1/api-keys', body); | |
| 2809 | + resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px"> | |
| 2810 | + <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div> | |
| 2811 | + <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div> | |
| 2812 | + <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code> | |
| 2813 | + </div>`; | |
| 2814 | + document.getElementById('new-apikey-name').value = ''; | |
| 2815 | + document.getElementById('new-apikey-expires').value = ''; | |
| 2816 | + document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false); | |
| 2817 | + loadAPIKeys(); | |
| 2818 | + } catch(e) { | |
| 2819 | + resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`; | |
| 2820 | + } | |
| 2821 | +} | |
| 2822 | + | |
| 2823 | +async function revokeAPIKey(id) { | |
| 2824 | + if (!confirm('Revoke this API key? This cannot be undone.')) return; | |
| 2825 | + try { | |
| 2826 | + await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`); | |
| 2827 | + loadAPIKeys(); | |
| 2828 | + } catch(e) { alert('Failed: ' + e.message); } | |
| 2829 | +} | |
| 2547 | 2830 | |
| 2548 | 2831 | // --- AI / LLM tab --- |
| 2549 | 2832 | async function loadAI() { |
| 2550 | 2833 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2551 | 2834 | } |
| @@ -2915,10 +3198,11 @@ | ||
| 2915 | 3198 | renderBehaviors(s.policies.behaviors || []); |
| 2916 | 3199 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | 3200 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | 3201 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | 3202 | loadAdmins(); |
| 3203 | + loadAPIKeys(); | |
| 2920 | 3204 | loadConfigCards(); |
| 2921 | 3205 | } catch(e) { |
| 2922 | 3206 | document.getElementById('tls-badge').textContent = 'error'; |
| 2923 | 3207 | } |
| 2924 | 3208 | } |
| @@ -3222,14 +3506,17 @@ | ||
| 3222 | 3506 | // general |
| 3223 | 3507 | document.getElementById('general-api-addr').value = cfg.api_addr || ''; |
| 3224 | 3508 | document.getElementById('general-mcp-addr').value = cfg.mcp_addr || ''; |
| 3225 | 3509 | // ergo |
| 3226 | 3510 | const e = cfg.ergo || {}; |
| 3227 | - document.getElementById('ergo-network-name').value = e.network_name || ''; | |
| 3228 | - document.getElementById('ergo-server-name').value = e.server_name || ''; | |
| 3229 | - document.getElementById('ergo-irc-addr').value = e.irc_addr || ''; | |
| 3230 | - document.getElementById('ergo-external').checked = !!e.external; | |
| 3511 | + document.getElementById('ergo-network-name').value = e.network_name || ''; | |
| 3512 | + document.getElementById('ergo-server-name').value = e.server_name || ''; | |
| 3513 | + document.getElementById('ergo-irc-addr').value = e.irc_addr || ''; | |
| 3514 | + document.getElementById('ergo-require-sasl').checked = !!e.require_sasl; | |
| 3515 | + document.getElementById('ergo-default-modes').value = e.default_channel_modes || ''; | |
| 3516 | + document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled); | |
| 3517 | + document.getElementById('ergo-external').checked = !!e.external; | |
| 3231 | 3518 | // tls |
| 3232 | 3519 | const t = cfg.tls || {}; |
| 3233 | 3520 | document.getElementById('tls-domain').value = t.domain || ''; |
| 3234 | 3521 | document.getElementById('tls-email').value = t.email || ''; |
| 3235 | 3522 | document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure; |
| @@ -3302,14 +3589,17 @@ | ||
| 3302 | 3589 | } |
| 3303 | 3590 | |
| 3304 | 3591 | function saveErgoConfig() { |
| 3305 | 3592 | saveConfigPatch({ |
| 3306 | 3593 | ergo: { |
| 3307 | - network_name: document.getElementById('ergo-network-name').value.trim() || undefined, | |
| 3308 | - server_name: document.getElementById('ergo-server-name').value.trim() || undefined, | |
| 3309 | - irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined, | |
| 3310 | - external: document.getElementById('ergo-external').checked, | |
| 3594 | + network_name: document.getElementById('ergo-network-name').value.trim() || undefined, | |
| 3595 | + server_name: document.getElementById('ergo-server-name').value.trim() || undefined, | |
| 3596 | + irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined, | |
| 3597 | + require_sasl: document.getElementById('ergo-require-sasl').checked, | |
| 3598 | + default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined, | |
| 3599 | + history: { enabled: document.getElementById('ergo-history-enabled').checked }, | |
| 3600 | + external: document.getElementById('ergo-external').checked, | |
| 3311 | 3601 | } |
| 3312 | 3602 | }, 'ergo-save-result'); |
| 3313 | 3603 | } |
| 3314 | 3604 | |
| 3315 | 3605 | function saveTLSConfig() { |
| 3316 | 3606 | |
| 3317 | 3607 | ADDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -485,10 +485,40 @@ | |
| 485 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | </div> |
| 487 | </div> |
| 488 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | </div> |
| 490 | </div> |
| 491 | </div> |
| 492 | |
| 493 | <!-- CHAT --> |
| 494 | <div class="tab-pane" id="pane-chat"> |
| @@ -580,10 +610,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"> |
| @@ -798,10 +858,31 @@ | |
| 798 | </div> |
| 799 | <div class="setting-row"> |
| 800 | <div class="setting-label">IRC address</div> |
| 801 | <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div> |
| 802 | <input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px"> |
| 803 | </div> |
| 804 | <div class="setting-row"> |
| 805 | <div class="setting-label">external mode</div> |
| 806 | <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div> |
| 807 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| @@ -1726,10 +1807,19 @@ | |
| 1726 | allChannels = (data.channels || []).sort(); |
| 1727 | renderChanList(); |
| 1728 | } catch(e) { |
| 1729 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1730 | } |
| 1731 | } |
| 1732 | |
| 1733 | function renderChanList() { |
| 1734 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1735 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1764,10 +1854,138 @@ | |
| 1764 | await loadChanTab(); |
| 1765 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1766 | } catch(e) { alert('Join failed: '+e.message); } |
| 1767 | } |
| 1768 | document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); }); |
| 1769 | |
| 1770 | // --- chat --- |
| 1771 | let chatChannel = null, chatSSE = null; |
| 1772 | |
| 1773 | async function loadChannels() { |
| @@ -1892,14 +2110,16 @@ | |
| 1892 | let _chatUnread = 0; |
| 1893 | |
| 1894 | function appendMsg(msg, isHistory) { |
| 1895 | const area = document.getElementById('chat-msgs'); |
| 1896 | |
| 1897 | // Parse "[nick] text" sent by the bridge bot on behalf of a web user |
| 1898 | let displayNick = msg.nick; |
| 1899 | let displayText = msg.text; |
| 1900 | if (msg.nick === 'bridge') { |
| 1901 | const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/); |
| 1902 | if (m) { displayNick = m[1]; displayText = m[2]; } |
| 1903 | } |
| 1904 | |
| 1905 | const atMs = new Date(msg.at).getTime(); |
| @@ -2542,10 +2762,73 @@ | |
| 2542 | try { |
| 2543 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2544 | alert('Password updated.'); |
| 2545 | } catch(e) { alert('Failed: ' + e.message); } |
| 2546 | } |
| 2547 | |
| 2548 | // --- AI / LLM tab --- |
| 2549 | async function loadAI() { |
| 2550 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2551 | } |
| @@ -2915,10 +3198,11 @@ | |
| 2915 | renderBehaviors(s.policies.behaviors || []); |
| 2916 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | loadAdmins(); |
| 2920 | loadConfigCards(); |
| 2921 | } catch(e) { |
| 2922 | document.getElementById('tls-badge').textContent = 'error'; |
| 2923 | } |
| 2924 | } |
| @@ -3222,14 +3506,17 @@ | |
| 3222 | // general |
| 3223 | document.getElementById('general-api-addr').value = cfg.api_addr || ''; |
| 3224 | document.getElementById('general-mcp-addr').value = cfg.mcp_addr || ''; |
| 3225 | // ergo |
| 3226 | const e = cfg.ergo || {}; |
| 3227 | document.getElementById('ergo-network-name').value = e.network_name || ''; |
| 3228 | document.getElementById('ergo-server-name').value = e.server_name || ''; |
| 3229 | document.getElementById('ergo-irc-addr').value = e.irc_addr || ''; |
| 3230 | document.getElementById('ergo-external').checked = !!e.external; |
| 3231 | // tls |
| 3232 | const t = cfg.tls || {}; |
| 3233 | document.getElementById('tls-domain').value = t.domain || ''; |
| 3234 | document.getElementById('tls-email').value = t.email || ''; |
| 3235 | document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure; |
| @@ -3302,14 +3589,17 @@ | |
| 3302 | } |
| 3303 | |
| 3304 | function saveErgoConfig() { |
| 3305 | saveConfigPatch({ |
| 3306 | ergo: { |
| 3307 | network_name: document.getElementById('ergo-network-name').value.trim() || undefined, |
| 3308 | server_name: document.getElementById('ergo-server-name').value.trim() || undefined, |
| 3309 | irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined, |
| 3310 | external: document.getElementById('ergo-external').checked, |
| 3311 | } |
| 3312 | }, 'ergo-save-result'); |
| 3313 | } |
| 3314 | |
| 3315 | function saveTLSConfig() { |
| 3316 | |
| 3317 | DDED internal/auth/apikeys.go |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -485,10 +485,40 @@ | |
| 485 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | </div> |
| 487 | </div> |
| 488 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | </div> |
| 490 | |
| 491 | <!-- topology panel --> |
| 492 | <div class="card" id="card-topology"> |
| 493 | <div class="card-header" onclick="toggleCard('card-topology',event)"> |
| 494 | <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span> |
| 495 | <div class="spacer"></div> |
| 496 | <div style="display:flex;gap:6px;align-items:center"> |
| 497 | <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off"> |
| 498 | <button class="sm primary" onclick="provisionChannel()">provision</button> |
| 499 | </div> |
| 500 | </div> |
| 501 | <div class="card-body" style="padding:0"> |
| 502 | <div id="topology-types"></div> |
| 503 | <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div> |
| 504 | </div> |
| 505 | </div> |
| 506 | |
| 507 | <!-- ROE templates --> |
| 508 | <div class="card" id="card-roe"> |
| 509 | <div class="card-header" onclick="toggleCard('card-roe',event)"> |
| 510 | <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span> |
| 511 | <div class="spacer"></div> |
| 512 | <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button> |
| 513 | </div> |
| 514 | <div class="card-body"> |
| 515 | <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p> |
| 516 | <div id="roe-list"></div> |
| 517 | <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button> |
| 518 | </div> |
| 519 | </div> |
| 520 | </div> |
| 521 | </div> |
| 522 | |
| 523 | <!-- CHAT --> |
| 524 | <div class="tab-pane" id="pane-chat"> |
| @@ -580,10 +610,40 @@ | |
| 610 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 611 | </form> |
| 612 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 613 | </div> |
| 614 | </div> |
| 615 | |
| 616 | <!-- api keys --> |
| 617 | <div class="card" id="card-apikeys"> |
| 618 | <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> |
| 619 | <div id="apikeys-list-container"></div> |
| 620 | <div class="card-body" style="border-top:1px solid #21262d"> |
| 621 | <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> |
| 622 | <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px"> |
| 623 | <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> |
| 624 | <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> |
| 625 | <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> |
| 626 | </div> |
| 627 | <div> |
| 628 | <label style="margin-bottom:6px;display:block">scopes</label> |
| 629 | <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px"> |
| 630 | <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label> |
| 631 | <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label> |
| 632 | <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label> |
| 633 | <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label> |
| 634 | <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label> |
| 635 | <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label> |
| 636 | <label><input type="checkbox" value="config" class="apikey-scope"> config</label> |
| 637 | <label><input type="checkbox" value="read" class="apikey-scope"> read</label> |
| 638 | </div> |
| 639 | </div> |
| 640 | <button type="submit" class="primary sm" style="align-self:flex-start">create key</button> |
| 641 | </form> |
| 642 | <div id="add-apikey-result" style="margin-top:10px"></div> |
| 643 | </div> |
| 644 | </div> |
| 645 | |
| 646 | <!-- tls --> |
| 647 | <div class="card" id="card-tls"> |
| 648 | <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> |
| 649 | <div class="card-body"> |
| @@ -798,10 +858,31 @@ | |
| 858 | </div> |
| 859 | <div class="setting-row"> |
| 860 | <div class="setting-label">IRC address</div> |
| 861 | <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div> |
| 862 | <input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px"> |
| 863 | </div> |
| 864 | <div class="setting-row"> |
| 865 | <div class="setting-label">require SASL</div> |
| 866 | <div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div> |
| 867 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| 868 | <input type="checkbox" id="ergo-require-sasl"> |
| 869 | <span style="font-size:12px">enforce SASL</span> |
| 870 | </label> |
| 871 | </div> |
| 872 | <div class="setting-row"> |
| 873 | <div class="setting-label">default channel modes</div> |
| 874 | <div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div> |
| 875 | <input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px"> |
| 876 | </div> |
| 877 | <div class="setting-row"> |
| 878 | <div class="setting-label">message history</div> |
| 879 | <div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div> |
| 880 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| 881 | <input type="checkbox" id="ergo-history-enabled"> |
| 882 | <span style="font-size:12px">enabled</span> |
| 883 | </label> |
| 884 | </div> |
| 885 | <div class="setting-row"> |
| 886 | <div class="setting-label">external mode</div> |
| 887 | <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div> |
| 888 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| @@ -1726,10 +1807,19 @@ | |
| 1807 | allChannels = (data.channels || []).sort(); |
| 1808 | renderChanList(); |
| 1809 | } catch(e) { |
| 1810 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1811 | } |
| 1812 | loadTopology(); |
| 1813 | // Load ROE templates from policies for the ROE card. |
| 1814 | try { |
| 1815 | const s = await api('GET', '/v1/settings'); |
| 1816 | if (s && s.policies) { |
| 1817 | currentPolicies = s.policies; |
| 1818 | renderROETemplates(s.policies.roe_templates || []); |
| 1819 | } |
| 1820 | } catch(e) {} |
| 1821 | } |
| 1822 | |
| 1823 | function renderChanList() { |
| 1824 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1825 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1764,10 +1854,138 @@ | |
| 1854 | await loadChanTab(); |
| 1855 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1856 | } catch(e) { alert('Join failed: '+e.message); } |
| 1857 | } |
| 1858 | document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); }); |
| 1859 | |
| 1860 | // --- topology panel (#115) + task channels (#114) --- |
| 1861 | async function loadTopology() { |
| 1862 | try { |
| 1863 | const data = await api('GET', '/v1/topology'); |
| 1864 | renderTopologyTypes(data.types || []); |
| 1865 | renderTopologyActive(data.active_channels || [], data.types || []); |
| 1866 | } catch(e) { |
| 1867 | document.getElementById('topology-types').innerHTML = ''; |
| 1868 | document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>'; |
| 1869 | } |
| 1870 | } |
| 1871 | |
| 1872 | function renderTopologyTypes(types) { |
| 1873 | if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; } |
| 1874 | const rows = types.map(t => { |
| 1875 | const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—'; |
| 1876 | const tags = []; |
| 1877 | if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>'); |
| 1878 | if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`); |
| 1879 | return `<tr> |
| 1880 | <td><strong>${esc(t.name)}</strong></td> |
| 1881 | <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td> |
| 1882 | <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td> |
| 1883 | <td style="font-size:12px">${ttl}</td> |
| 1884 | <td>${tags.join(' ')}</td> |
| 1885 | </tr>`; |
| 1886 | }).join(''); |
| 1887 | document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; |
| 1888 | } |
| 1889 | |
| 1890 | function renderTopologyActive(channels, types) { |
| 1891 | const el = document.getElementById('topology-active'); |
| 1892 | const tasks = channels.filter(c => c.ephemeral || c.type === 'task'); |
| 1893 | if (!tasks.length) { |
| 1894 | el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>'; |
| 1895 | return; |
| 1896 | } |
| 1897 | const rows = tasks.map(c => { |
| 1898 | const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—'; |
| 1899 | const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—'; |
| 1900 | return `<tr> |
| 1901 | <td><strong>${esc(c.name)}</strong></td> |
| 1902 | <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td> |
| 1903 | <td style="font-size:12px">${age}</td> |
| 1904 | <td style="font-size:12px">${ttl}</td> |
| 1905 | <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td> |
| 1906 | </tr>`; |
| 1907 | }).join(''); |
| 1908 | el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`; |
| 1909 | } |
| 1910 | |
| 1911 | function timeSince(date) { |
| 1912 | const s = Math.floor((new Date() - date) / 1000); |
| 1913 | if (s < 60) return s + 's'; |
| 1914 | if (s < 3600) return Math.floor(s/60) + 'm'; |
| 1915 | if (s < 86400) return Math.floor(s/3600) + 'h'; |
| 1916 | return Math.floor(s/86400) + 'd'; |
| 1917 | } |
| 1918 | |
| 1919 | async function provisionChannel() { |
| 1920 | let ch = document.getElementById('provision-channel-input').value.trim(); |
| 1921 | if (!ch) return; |
| 1922 | if (!ch.startsWith('#')) ch = '#' + ch; |
| 1923 | try { |
| 1924 | await api('POST', '/v1/channels', {name: ch}); |
| 1925 | document.getElementById('provision-channel-input').value = ''; |
| 1926 | loadTopology(); |
| 1927 | loadChanTab(); |
| 1928 | } catch(e) { alert('Provision failed: ' + e.message); } |
| 1929 | } |
| 1930 | |
| 1931 | async function dropChannel(ch) { |
| 1932 | if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return; |
| 1933 | const slug = ch.replace(/^#/,''); |
| 1934 | try { |
| 1935 | await api('DELETE', `/v1/topology/channels/${slug}`); |
| 1936 | loadTopology(); |
| 1937 | loadChanTab(); |
| 1938 | } catch(e) { alert('Drop failed: ' + e.message); } |
| 1939 | } |
| 1940 | |
| 1941 | // --- ROE template editor (#118) --- |
| 1942 | function renderROETemplates(templates) { |
| 1943 | const el = document.getElementById('roe-list'); |
| 1944 | if (!templates || !templates.length) { |
| 1945 | el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>'; |
| 1946 | return; |
| 1947 | } |
| 1948 | el.innerHTML = templates.map((t, i) => ` |
| 1949 | <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117"> |
| 1950 | <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px"> |
| 1951 | <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)"> |
| 1952 | <button class="sm danger" onclick="removeROE(${i})">remove</button> |
| 1953 | </div> |
| 1954 | <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px"> |
| 1955 | <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div> |
| 1956 | <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div> |
| 1957 | </div> |
| 1958 | <div style="display:flex;gap:10px"> |
| 1959 | <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div> |
| 1960 | <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div> |
| 1961 | <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div> |
| 1962 | </div> |
| 1963 | </div> |
| 1964 | `).join(''); |
| 1965 | } |
| 1966 | |
| 1967 | function addROETemplate() { |
| 1968 | if (!currentPolicies.roe_templates) currentPolicies.roe_templates = []; |
| 1969 | currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []}); |
| 1970 | renderROETemplates(currentPolicies.roe_templates); |
| 1971 | } |
| 1972 | function removeROE(i) { |
| 1973 | currentPolicies.roe_templates.splice(i, 1); |
| 1974 | renderROETemplates(currentPolicies.roe_templates); |
| 1975 | } |
| 1976 | function updateROE(i, field, val) { |
| 1977 | if (field === 'channels' || field === 'permissions') { |
| 1978 | currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean); |
| 1979 | } else { |
| 1980 | currentPolicies.roe_templates[i][field] = val; |
| 1981 | } |
| 1982 | } |
| 1983 | function updateROERateLimit(i, field, val) { |
| 1984 | if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {}; |
| 1985 | currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0; |
| 1986 | } |
| 1987 | |
| 1988 | // --- chat --- |
| 1989 | let chatChannel = null, chatSSE = null; |
| 1990 | |
| 1991 | async function loadChannels() { |
| @@ -1892,14 +2110,16 @@ | |
| 2110 | let _chatUnread = 0; |
| 2111 | |
| 2112 | function appendMsg(msg, isHistory) { |
| 2113 | const area = document.getElementById('chat-msgs'); |
| 2114 | |
| 2115 | // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text". |
| 2116 | let displayNick = msg.nick; |
| 2117 | let displayText = msg.text; |
| 2118 | if (msg.nick && msg.nick.endsWith('/bridge')) { |
| 2119 | displayNick = msg.nick.slice(0, -'/bridge'.length); |
| 2120 | } else if (msg.nick === 'bridge') { |
| 2121 | const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/); |
| 2122 | if (m) { displayNick = m[1]; displayText = m[2]; } |
| 2123 | } |
| 2124 | |
| 2125 | const atMs = new Date(msg.at).getTime(); |
| @@ -2542,10 +2762,73 @@ | |
| 2762 | try { |
| 2763 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2764 | alert('Password updated.'); |
| 2765 | } catch(e) { alert('Failed: ' + e.message); } |
| 2766 | } |
| 2767 | |
| 2768 | // --- API keys --- |
| 2769 | async function loadAPIKeys() { |
| 2770 | try { |
| 2771 | const keys = await api('GET', '/v1/api-keys'); |
| 2772 | renderAPIKeys(keys || []); |
| 2773 | } catch(e) { |
| 2774 | document.getElementById('apikeys-list-container').innerHTML = ''; |
| 2775 | } |
| 2776 | } |
| 2777 | |
| 2778 | function renderAPIKeys(keys) { |
| 2779 | const el = document.getElementById('apikeys-list-container'); |
| 2780 | if (!keys.length) { el.innerHTML = ''; return; } |
| 2781 | const rows = keys.map(k => { |
| 2782 | const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>'; |
| 2783 | const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' '); |
| 2784 | const lastUsed = k.last_used ? fmtTime(k.last_used) : '—'; |
| 2785 | const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : ''; |
| 2786 | return `<tr> |
| 2787 | <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td> |
| 2788 | <td>${scopes}</td> |
| 2789 | <td style="font-size:12px">${status}</td> |
| 2790 | <td style="color:#8b949e;font-size:12px">${lastUsed}</td> |
| 2791 | <td><div class="actions">${revokeBtn}</div></td> |
| 2792 | </tr>`; |
| 2793 | }).join(''); |
| 2794 | 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>`; |
| 2795 | } |
| 2796 | |
| 2797 | async function createAPIKey(e) { |
| 2798 | e.preventDefault(); |
| 2799 | const name = document.getElementById('new-apikey-name').value.trim(); |
| 2800 | const expires = document.getElementById('new-apikey-expires').value.trim(); |
| 2801 | const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value); |
| 2802 | const resultEl = document.getElementById('add-apikey-result'); |
| 2803 | if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; } |
| 2804 | if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; } |
| 2805 | try { |
| 2806 | const body = { name, scopes }; |
| 2807 | if (expires) body.expires_in = expires; |
| 2808 | const result = await api('POST', '/v1/api-keys', body); |
| 2809 | resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px"> |
| 2810 | <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div> |
| 2811 | <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div> |
| 2812 | <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code> |
| 2813 | </div>`; |
| 2814 | document.getElementById('new-apikey-name').value = ''; |
| 2815 | document.getElementById('new-apikey-expires').value = ''; |
| 2816 | document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false); |
| 2817 | loadAPIKeys(); |
| 2818 | } catch(e) { |
| 2819 | resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`; |
| 2820 | } |
| 2821 | } |
| 2822 | |
| 2823 | async function revokeAPIKey(id) { |
| 2824 | if (!confirm('Revoke this API key? This cannot be undone.')) return; |
| 2825 | try { |
| 2826 | await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`); |
| 2827 | loadAPIKeys(); |
| 2828 | } catch(e) { alert('Failed: ' + e.message); } |
| 2829 | } |
| 2830 | |
| 2831 | // --- AI / LLM tab --- |
| 2832 | async function loadAI() { |
| 2833 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2834 | } |
| @@ -2915,10 +3198,11 @@ | |
| 3198 | renderBehaviors(s.policies.behaviors || []); |
| 3199 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 3200 | renderBridgePolicy(s.policies.bridge || {}); |
| 3201 | renderLoggingPolicy(s.policies.logging || {}); |
| 3202 | loadAdmins(); |
| 3203 | loadAPIKeys(); |
| 3204 | loadConfigCards(); |
| 3205 | } catch(e) { |
| 3206 | document.getElementById('tls-badge').textContent = 'error'; |
| 3207 | } |
| 3208 | } |
| @@ -3222,14 +3506,17 @@ | |
| 3506 | // general |
| 3507 | document.getElementById('general-api-addr').value = cfg.api_addr || ''; |
| 3508 | document.getElementById('general-mcp-addr').value = cfg.mcp_addr || ''; |
| 3509 | // ergo |
| 3510 | const e = cfg.ergo || {}; |
| 3511 | document.getElementById('ergo-network-name').value = e.network_name || ''; |
| 3512 | document.getElementById('ergo-server-name').value = e.server_name || ''; |
| 3513 | document.getElementById('ergo-irc-addr').value = e.irc_addr || ''; |
| 3514 | document.getElementById('ergo-require-sasl').checked = !!e.require_sasl; |
| 3515 | document.getElementById('ergo-default-modes').value = e.default_channel_modes || ''; |
| 3516 | document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled); |
| 3517 | document.getElementById('ergo-external').checked = !!e.external; |
| 3518 | // tls |
| 3519 | const t = cfg.tls || {}; |
| 3520 | document.getElementById('tls-domain').value = t.domain || ''; |
| 3521 | document.getElementById('tls-email').value = t.email || ''; |
| 3522 | document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure; |
| @@ -3302,14 +3589,17 @@ | |
| 3589 | } |
| 3590 | |
| 3591 | function saveErgoConfig() { |
| 3592 | saveConfigPatch({ |
| 3593 | ergo: { |
| 3594 | network_name: document.getElementById('ergo-network-name').value.trim() || undefined, |
| 3595 | server_name: document.getElementById('ergo-server-name').value.trim() || undefined, |
| 3596 | irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined, |
| 3597 | require_sasl: document.getElementById('ergo-require-sasl').checked, |
| 3598 | default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined, |
| 3599 | history: { enabled: document.getElementById('ergo-history-enabled').checked }, |
| 3600 | external: document.getElementById('ergo-external').checked, |
| 3601 | } |
| 3602 | }, 'ergo-save-result'); |
| 3603 | } |
| 3604 | |
| 3605 | function saveTLSConfig() { |
| 3606 | |
| 3607 | 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 | } |
+27
-4
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -104,10 +104,13 @@ | ||
| 104 | 104 | |
| 105 | 105 | msgTotal atomic.Int64 |
| 106 | 106 | |
| 107 | 107 | joinCh chan string |
| 108 | 108 | client *girc.Client |
| 109 | + | |
| 110 | + // RELAYMSG support detected from ISUPPORT. | |
| 111 | + relaySep string // separator (e.g. "/"), empty if unsupported | |
| 109 | 112 | } |
| 110 | 113 | |
| 111 | 114 | // New creates a bridge Bot. |
| 112 | 115 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 113 | 116 | if nick == "" { |
| @@ -174,10 +177,22 @@ | ||
| 174 | 177 | SSL: false, |
| 175 | 178 | }) |
| 176 | 179 | |
| 177 | 180 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 178 | 181 | cl.Cmd.Mode(cl.GetNick(), "+B") |
| 182 | + // Check RELAYMSG support from ISUPPORT (RPL_005). | |
| 183 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 184 | + b.relaySep = sep | |
| 185 | + if b.log != nil { | |
| 186 | + b.log.Info("bridge: RELAYMSG supported", "separator", sep) | |
| 187 | + } | |
| 188 | + } else { | |
| 189 | + b.relaySep = "" | |
| 190 | + if b.log != nil { | |
| 191 | + b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback") | |
| 192 | + } | |
| 193 | + } | |
| 179 | 194 | if b.log != nil { |
| 180 | 195 | b.log.Info("bridge connected") |
| 181 | 196 | } |
| 182 | 197 | for _, ch := range b.initChannels { |
| 183 | 198 | cl.Cmd.Join(ch) |
| @@ -345,19 +360,27 @@ | ||
| 345 | 360 | } |
| 346 | 361 | |
| 347 | 362 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 348 | 363 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 349 | 364 | // including meta for rich rendering in the web UI. |
| 365 | +// | |
| 366 | +// When the server supports RELAYMSG (IRCv3), messages are attributed natively | |
| 367 | +// so other clients see the real sender nick. Falls back to [nick] prefix. | |
| 350 | 368 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 351 | 369 | if b.client == nil { |
| 352 | 370 | return fmt.Errorf("bridge: not connected") |
| 353 | 371 | } |
| 354 | - ircText := text | |
| 355 | - if senderNick != "" { | |
| 356 | - ircText = "[" + senderNick + "] " + text | |
| 372 | + if senderNick != "" && b.relaySep != "" { | |
| 373 | + // Use RELAYMSG for native attribution. | |
| 374 | + b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text) | |
| 375 | + } else { | |
| 376 | + ircText := text | |
| 377 | + if senderNick != "" { | |
| 378 | + ircText = "[" + senderNick + "] " + text | |
| 379 | + } | |
| 380 | + b.client.Cmd.Message(channel, ircText) | |
| 357 | 381 | } |
| 358 | - b.client.Cmd.Message(channel, ircText) | |
| 359 | 382 | |
| 360 | 383 | if senderNick != "" { |
| 361 | 384 | b.TouchUser(channel, senderNick) |
| 362 | 385 | } |
| 363 | 386 | |
| 364 | 387 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -104,10 +104,13 @@ | |
| 104 | |
| 105 | msgTotal atomic.Int64 |
| 106 | |
| 107 | joinCh chan string |
| 108 | client *girc.Client |
| 109 | } |
| 110 | |
| 111 | // New creates a bridge Bot. |
| 112 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 113 | if nick == "" { |
| @@ -174,10 +177,22 @@ | |
| 174 | SSL: false, |
| 175 | }) |
| 176 | |
| 177 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 178 | cl.Cmd.Mode(cl.GetNick(), "+B") |
| 179 | if b.log != nil { |
| 180 | b.log.Info("bridge connected") |
| 181 | } |
| 182 | for _, ch := range b.initChannels { |
| 183 | cl.Cmd.Join(ch) |
| @@ -345,19 +360,27 @@ | |
| 345 | } |
| 346 | |
| 347 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 348 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 349 | // including meta for rich rendering in the web UI. |
| 350 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 351 | if b.client == nil { |
| 352 | return fmt.Errorf("bridge: not connected") |
| 353 | } |
| 354 | ircText := text |
| 355 | if senderNick != "" { |
| 356 | ircText = "[" + senderNick + "] " + text |
| 357 | } |
| 358 | b.client.Cmd.Message(channel, ircText) |
| 359 | |
| 360 | if senderNick != "" { |
| 361 | b.TouchUser(channel, senderNick) |
| 362 | } |
| 363 | |
| 364 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -104,10 +104,13 @@ | |
| 104 | |
| 105 | msgTotal atomic.Int64 |
| 106 | |
| 107 | joinCh chan string |
| 108 | client *girc.Client |
| 109 | |
| 110 | // RELAYMSG support detected from ISUPPORT. |
| 111 | relaySep string // separator (e.g. "/"), empty if unsupported |
| 112 | } |
| 113 | |
| 114 | // New creates a bridge Bot. |
| 115 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 116 | if nick == "" { |
| @@ -174,10 +177,22 @@ | |
| 177 | SSL: false, |
| 178 | }) |
| 179 | |
| 180 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 181 | cl.Cmd.Mode(cl.GetNick(), "+B") |
| 182 | // Check RELAYMSG support from ISUPPORT (RPL_005). |
| 183 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 184 | b.relaySep = sep |
| 185 | if b.log != nil { |
| 186 | b.log.Info("bridge: RELAYMSG supported", "separator", sep) |
| 187 | } |
| 188 | } else { |
| 189 | b.relaySep = "" |
| 190 | if b.log != nil { |
| 191 | b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback") |
| 192 | } |
| 193 | } |
| 194 | if b.log != nil { |
| 195 | b.log.Info("bridge connected") |
| 196 | } |
| 197 | for _, ch := range b.initChannels { |
| 198 | cl.Cmd.Join(ch) |
| @@ -345,19 +360,27 @@ | |
| 360 | } |
| 361 | |
| 362 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 363 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 364 | // including meta for rich rendering in the web UI. |
| 365 | // |
| 366 | // When the server supports RELAYMSG (IRCv3), messages are attributed natively |
| 367 | // so other clients see the real sender nick. Falls back to [nick] prefix. |
| 368 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 369 | if b.client == nil { |
| 370 | return fmt.Errorf("bridge: not connected") |
| 371 | } |
| 372 | if senderNick != "" && b.relaySep != "" { |
| 373 | // Use RELAYMSG for native attribution. |
| 374 | b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text) |
| 375 | } else { |
| 376 | ircText := text |
| 377 | if senderNick != "" { |
| 378 | ircText = "[" + senderNick + "] " + text |
| 379 | } |
| 380 | b.client.Cmd.Message(channel, ircText) |
| 381 | } |
| 382 | |
| 383 | if senderNick != "" { |
| 384 | b.TouchUser(channel, senderNick) |
| 385 | } |
| 386 | |
| 387 |
+27
-4
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -104,10 +104,13 @@ | ||
| 104 | 104 | |
| 105 | 105 | msgTotal atomic.Int64 |
| 106 | 106 | |
| 107 | 107 | joinCh chan string |
| 108 | 108 | client *girc.Client |
| 109 | + | |
| 110 | + // RELAYMSG support detected from ISUPPORT. | |
| 111 | + relaySep string // separator (e.g. "/"), empty if unsupported | |
| 109 | 112 | } |
| 110 | 113 | |
| 111 | 114 | // New creates a bridge Bot. |
| 112 | 115 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 113 | 116 | if nick == "" { |
| @@ -174,10 +177,22 @@ | ||
| 174 | 177 | SSL: false, |
| 175 | 178 | }) |
| 176 | 179 | |
| 177 | 180 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 178 | 181 | cl.Cmd.Mode(cl.GetNick(), "+B") |
| 182 | + // Check RELAYMSG support from ISUPPORT (RPL_005). | |
| 183 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 184 | + b.relaySep = sep | |
| 185 | + if b.log != nil { | |
| 186 | + b.log.Info("bridge: RELAYMSG supported", "separator", sep) | |
| 187 | + } | |
| 188 | + } else { | |
| 189 | + b.relaySep = "" | |
| 190 | + if b.log != nil { | |
| 191 | + b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback") | |
| 192 | + } | |
| 193 | + } | |
| 179 | 194 | if b.log != nil { |
| 180 | 195 | b.log.Info("bridge connected") |
| 181 | 196 | } |
| 182 | 197 | for _, ch := range b.initChannels { |
| 183 | 198 | cl.Cmd.Join(ch) |
| @@ -345,19 +360,27 @@ | ||
| 345 | 360 | } |
| 346 | 361 | |
| 347 | 362 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 348 | 363 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 349 | 364 | // including meta for rich rendering in the web UI. |
| 365 | +// | |
| 366 | +// When the server supports RELAYMSG (IRCv3), messages are attributed natively | |
| 367 | +// so other clients see the real sender nick. Falls back to [nick] prefix. | |
| 350 | 368 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 351 | 369 | if b.client == nil { |
| 352 | 370 | return fmt.Errorf("bridge: not connected") |
| 353 | 371 | } |
| 354 | - ircText := text | |
| 355 | - if senderNick != "" { | |
| 356 | - ircText = "[" + senderNick + "] " + text | |
| 372 | + if senderNick != "" && b.relaySep != "" { | |
| 373 | + // Use RELAYMSG for native attribution. | |
| 374 | + b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text) | |
| 375 | + } else { | |
| 376 | + ircText := text | |
| 377 | + if senderNick != "" { | |
| 378 | + ircText = "[" + senderNick + "] " + text | |
| 379 | + } | |
| 380 | + b.client.Cmd.Message(channel, ircText) | |
| 357 | 381 | } |
| 358 | - b.client.Cmd.Message(channel, ircText) | |
| 359 | 382 | |
| 360 | 383 | if senderNick != "" { |
| 361 | 384 | b.TouchUser(channel, senderNick) |
| 362 | 385 | } |
| 363 | 386 | |
| 364 | 387 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -104,10 +104,13 @@ | |
| 104 | |
| 105 | msgTotal atomic.Int64 |
| 106 | |
| 107 | joinCh chan string |
| 108 | client *girc.Client |
| 109 | } |
| 110 | |
| 111 | // New creates a bridge Bot. |
| 112 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 113 | if nick == "" { |
| @@ -174,10 +177,22 @@ | |
| 174 | SSL: false, |
| 175 | }) |
| 176 | |
| 177 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 178 | cl.Cmd.Mode(cl.GetNick(), "+B") |
| 179 | if b.log != nil { |
| 180 | b.log.Info("bridge connected") |
| 181 | } |
| 182 | for _, ch := range b.initChannels { |
| 183 | cl.Cmd.Join(ch) |
| @@ -345,19 +360,27 @@ | |
| 345 | } |
| 346 | |
| 347 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 348 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 349 | // including meta for rich rendering in the web UI. |
| 350 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 351 | if b.client == nil { |
| 352 | return fmt.Errorf("bridge: not connected") |
| 353 | } |
| 354 | ircText := text |
| 355 | if senderNick != "" { |
| 356 | ircText = "[" + senderNick + "] " + text |
| 357 | } |
| 358 | b.client.Cmd.Message(channel, ircText) |
| 359 | |
| 360 | if senderNick != "" { |
| 361 | b.TouchUser(channel, senderNick) |
| 362 | } |
| 363 | |
| 364 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -104,10 +104,13 @@ | |
| 104 | |
| 105 | msgTotal atomic.Int64 |
| 106 | |
| 107 | joinCh chan string |
| 108 | client *girc.Client |
| 109 | |
| 110 | // RELAYMSG support detected from ISUPPORT. |
| 111 | relaySep string // separator (e.g. "/"), empty if unsupported |
| 112 | } |
| 113 | |
| 114 | // New creates a bridge Bot. |
| 115 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 116 | if nick == "" { |
| @@ -174,10 +177,22 @@ | |
| 177 | SSL: false, |
| 178 | }) |
| 179 | |
| 180 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 181 | cl.Cmd.Mode(cl.GetNick(), "+B") |
| 182 | // Check RELAYMSG support from ISUPPORT (RPL_005). |
| 183 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 184 | b.relaySep = sep |
| 185 | if b.log != nil { |
| 186 | b.log.Info("bridge: RELAYMSG supported", "separator", sep) |
| 187 | } |
| 188 | } else { |
| 189 | b.relaySep = "" |
| 190 | if b.log != nil { |
| 191 | b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback") |
| 192 | } |
| 193 | } |
| 194 | if b.log != nil { |
| 195 | b.log.Info("bridge connected") |
| 196 | } |
| 197 | for _, ch := range b.initChannels { |
| 198 | cl.Cmd.Join(ch) |
| @@ -345,19 +360,27 @@ | |
| 360 | } |
| 361 | |
| 362 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 363 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 364 | // including meta for rich rendering in the web UI. |
| 365 | // |
| 366 | // When the server supports RELAYMSG (IRCv3), messages are attributed natively |
| 367 | // so other clients see the real sender nick. Falls back to [nick] prefix. |
| 368 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 369 | if b.client == nil { |
| 370 | return fmt.Errorf("bridge: not connected") |
| 371 | } |
| 372 | if senderNick != "" && b.relaySep != "" { |
| 373 | // Use RELAYMSG for native attribution. |
| 374 | b.client.Cmd.SendRawf("RELAYMSG %s %s :%s", channel, senderNick, text) |
| 375 | } else { |
| 376 | ircText := text |
| 377 | if senderNick != "" { |
| 378 | ircText = "[" + senderNick + "] " + text |
| 379 | } |
| 380 | b.client.Cmd.Message(channel, ircText) |
| 381 | } |
| 382 | |
| 383 | if senderNick != "" { |
| 384 | b.TouchUser(channel, senderNick) |
| 385 | } |
| 386 | |
| 387 |
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -278,10 +278,13 @@ | ||
| 278 | 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | 280 | |
| 281 | 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | + | |
| 284 | + // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated). | |
| 285 | + Modes []string `yaml:"modes" json:"modes,omitempty"` | |
| 283 | 286 | } |
| 284 | 287 | |
| 285 | 288 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | 289 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | 290 | type ChannelTypeConfig struct { |
| @@ -295,10 +298,13 @@ | ||
| 295 | 298 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 296 | 299 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 297 | 300 | |
| 298 | 301 | // Supervision is the coordination channel where summaries should surface. |
| 299 | 302 | Supervision string `yaml:"supervision" json:"supervision,omitempty"` |
| 303 | + | |
| 304 | + // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated). | |
| 305 | + Modes []string `yaml:"modes" json:"modes,omitempty"` | |
| 300 | 306 | |
| 301 | 307 | // Ephemeral marks channels of this type for automatic cleanup. |
| 302 | 308 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | 309 | |
| 304 | 310 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 | 311 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -278,10 +278,13 @@ | |
| 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | |
| 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | } |
| 284 | |
| 285 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | type ChannelTypeConfig struct { |
| @@ -295,10 +298,13 @@ | |
| 295 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 296 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 297 | |
| 298 | // Supervision is the coordination channel where summaries should surface. |
| 299 | Supervision string `yaml:"supervision" json:"supervision,omitempty"` |
| 300 | |
| 301 | // Ephemeral marks channels of this type for automatic cleanup. |
| 302 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | |
| 304 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -278,10 +278,13 @@ | |
| 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | |
| 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | |
| 284 | // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated). |
| 285 | Modes []string `yaml:"modes" json:"modes,omitempty"` |
| 286 | } |
| 287 | |
| 288 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 289 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 290 | type ChannelTypeConfig struct { |
| @@ -295,10 +298,13 @@ | |
| 298 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 299 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 300 | |
| 301 | // Supervision is the coordination channel where summaries should surface. |
| 302 | Supervision string `yaml:"supervision" json:"supervision,omitempty"` |
| 303 | |
| 304 | // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated). |
| 305 | Modes []string `yaml:"modes" json:"modes,omitempty"` |
| 306 | |
| 307 | // Ephemeral marks channels of this type for automatic cleanup. |
| 308 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 309 | |
| 310 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 311 |
+3
-1
| --- internal/ergo/ircdconfig.go | ||
| +++ internal/ergo/ircdconfig.go | ||
| @@ -23,11 +23,13 @@ | ||
| 23 | 23 | {{- end}} |
| 24 | 24 | casemapping: ascii |
| 25 | 25 | enforce-utf8: true |
| 26 | 26 | max-sendq: 96k |
| 27 | 27 | relaymsg: |
| 28 | - enabled: false | |
| 28 | + enabled: true | |
| 29 | + separators: / | |
| 30 | + available-to-chanops: false | |
| 29 | 31 | ip-cloaking: |
| 30 | 32 | enabled: false |
| 31 | 33 | lookup-hostnames: false |
| 32 | 34 | |
| 33 | 35 | datastore: |
| 34 | 36 |
| --- internal/ergo/ircdconfig.go | |
| +++ internal/ergo/ircdconfig.go | |
| @@ -23,11 +23,13 @@ | |
| 23 | {{- end}} |
| 24 | casemapping: ascii |
| 25 | enforce-utf8: true |
| 26 | max-sendq: 96k |
| 27 | relaymsg: |
| 28 | enabled: false |
| 29 | ip-cloaking: |
| 30 | enabled: false |
| 31 | lookup-hostnames: false |
| 32 | |
| 33 | datastore: |
| 34 |
| --- internal/ergo/ircdconfig.go | |
| +++ internal/ergo/ircdconfig.go | |
| @@ -23,11 +23,13 @@ | |
| 23 | {{- end}} |
| 24 | casemapping: ascii |
| 25 | enforce-utf8: true |
| 26 | max-sendq: 96k |
| 27 | relaymsg: |
| 28 | enabled: true |
| 29 | separators: / |
| 30 | available-to-chanops: false |
| 31 | ip-cloaking: |
| 32 | enabled: false |
| 33 | lookup-hostnames: false |
| 34 | |
| 35 | datastore: |
| 36 |
| --- internal/ergo/manager.go | ||
| +++ internal/ergo/manager.go | ||
| @@ -115,10 +115,17 @@ | ||
| 115 | 115 | } |
| 116 | 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | 117 | } |
| 118 | 118 | } |
| 119 | 119 | } |
| 120 | + | |
| 121 | +// UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes. | |
| 122 | +// Use when scuttlebot.yaml Ergo settings change at runtime. | |
| 123 | +func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error { | |
| 124 | + m.cfg = cfg | |
| 125 | + return m.Rehash() | |
| 126 | +} | |
| 120 | 127 | |
| 121 | 128 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 122 | 129 | func (m *Manager) Rehash() error { |
| 123 | 130 | if err := m.writeConfig(); err != nil { |
| 124 | 131 | return fmt.Errorf("ergo: write config: %w", err) |
| 125 | 132 |
| --- internal/ergo/manager.go | |
| +++ internal/ergo/manager.go | |
| @@ -115,10 +115,17 @@ | |
| 115 | } |
| 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 122 | func (m *Manager) Rehash() error { |
| 123 | if err := m.writeConfig(); err != nil { |
| 124 | return fmt.Errorf("ergo: write config: %w", err) |
| 125 |
| --- internal/ergo/manager.go | |
| +++ internal/ergo/manager.go | |
| @@ -115,10 +115,17 @@ | |
| 115 | } |
| 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes. |
| 122 | // Use when scuttlebot.yaml Ergo settings change at runtime. |
| 123 | func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error { |
| 124 | m.cfg = cfg |
| 125 | return m.Rehash() |
| 126 | } |
| 127 | |
| 128 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 129 | func (m *Manager) Rehash() error { |
| 130 | if err := m.writeConfig(); err != nil { |
| 131 | return fmt.Errorf("ergo: write config: %w", err) |
| 132 |
+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 |
| --- internal/topology/policy.go | ||
| +++ internal/topology/policy.go | ||
| @@ -11,10 +11,11 @@ | ||
| 11 | 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | 12 | type ChannelType struct { |
| 13 | 13 | Name string |
| 14 | 14 | Prefix string |
| 15 | 15 | Autojoin []string |
| 16 | + Modes []string | |
| 16 | 17 | Supervision string |
| 17 | 18 | Ephemeral bool |
| 18 | 19 | TTL time.Duration |
| 19 | 20 | } |
| 20 | 21 | |
| @@ -61,10 +62,11 @@ | ||
| 61 | 62 | for _, t := range cfg.Types { |
| 62 | 63 | types = append(types, ChannelType{ |
| 63 | 64 | Name: t.Name, |
| 64 | 65 | Prefix: t.Prefix, |
| 65 | 66 | Autojoin: append([]string(nil), t.Autojoin...), |
| 67 | + Modes: append([]string(nil), t.Modes...), | |
| 66 | 68 | Supervision: t.Supervision, |
| 67 | 69 | Ephemeral: t.Ephemeral, |
| 68 | 70 | TTL: t.TTL.Duration, |
| 69 | 71 | }) |
| 70 | 72 | } |
| @@ -133,10 +135,18 @@ | ||
| 133 | 135 | if t := p.Match(channel); t != nil { |
| 134 | 136 | return t.TTL |
| 135 | 137 | } |
| 136 | 138 | return 0 |
| 137 | 139 | } |
| 140 | + | |
| 141 | +// ModesFor returns the channel modes for the given channel, or nil. | |
| 142 | +func (p *Policy) ModesFor(channel string) []string { | |
| 143 | + if t := p.Match(channel); t != nil { | |
| 144 | + return append([]string(nil), t.Modes...) | |
| 145 | + } | |
| 146 | + return nil | |
| 147 | +} | |
| 138 | 148 | |
| 139 | 149 | // StaticChannels returns the list of channels to provision at startup. |
| 140 | 150 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 141 | 151 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 142 | 152 | } |
| 143 | 153 |
| --- internal/topology/policy.go | |
| +++ internal/topology/policy.go | |
| @@ -11,10 +11,11 @@ | |
| 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | type ChannelType struct { |
| 13 | Name string |
| 14 | Prefix string |
| 15 | Autojoin []string |
| 16 | Supervision string |
| 17 | Ephemeral bool |
| 18 | TTL time.Duration |
| 19 | } |
| 20 | |
| @@ -61,10 +62,11 @@ | |
| 61 | for _, t := range cfg.Types { |
| 62 | types = append(types, ChannelType{ |
| 63 | Name: t.Name, |
| 64 | Prefix: t.Prefix, |
| 65 | Autojoin: append([]string(nil), t.Autojoin...), |
| 66 | Supervision: t.Supervision, |
| 67 | Ephemeral: t.Ephemeral, |
| 68 | TTL: t.TTL.Duration, |
| 69 | }) |
| 70 | } |
| @@ -133,10 +135,18 @@ | |
| 133 | if t := p.Match(channel); t != nil { |
| 134 | return t.TTL |
| 135 | } |
| 136 | return 0 |
| 137 | } |
| 138 | |
| 139 | // StaticChannels returns the list of channels to provision at startup. |
| 140 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 141 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 142 | } |
| 143 |
| --- internal/topology/policy.go | |
| +++ internal/topology/policy.go | |
| @@ -11,10 +11,11 @@ | |
| 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | type ChannelType struct { |
| 13 | Name string |
| 14 | Prefix string |
| 15 | Autojoin []string |
| 16 | Modes []string |
| 17 | Supervision string |
| 18 | Ephemeral bool |
| 19 | TTL time.Duration |
| 20 | } |
| 21 | |
| @@ -61,10 +62,11 @@ | |
| 62 | for _, t := range cfg.Types { |
| 63 | types = append(types, ChannelType{ |
| 64 | Name: t.Name, |
| 65 | Prefix: t.Prefix, |
| 66 | Autojoin: append([]string(nil), t.Autojoin...), |
| 67 | Modes: append([]string(nil), t.Modes...), |
| 68 | Supervision: t.Supervision, |
| 69 | Ephemeral: t.Ephemeral, |
| 70 | TTL: t.TTL.Duration, |
| 71 | }) |
| 72 | } |
| @@ -133,10 +135,18 @@ | |
| 135 | if t := p.Match(channel); t != nil { |
| 136 | return t.TTL |
| 137 | } |
| 138 | return 0 |
| 139 | } |
| 140 | |
| 141 | // ModesFor returns the channel modes for the given channel, or nil. |
| 142 | func (p *Policy) ModesFor(channel string) []string { |
| 143 | if t := p.Match(channel); t != nil { |
| 144 | return append([]string(nil), t.Modes...) |
| 145 | } |
| 146 | return nil |
| 147 | } |
| 148 | |
| 149 | // StaticChannels returns the list of channels to provision at startup. |
| 150 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 151 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 152 | } |
| 153 |
+62
-11
| --- internal/topology/topology.go | ||
| +++ internal/topology/topology.go | ||
| @@ -24,18 +24,21 @@ | ||
| 24 | 24 | Name string |
| 25 | 25 | |
| 26 | 26 | // Topic is the initial channel topic (shared state header). |
| 27 | 27 | Topic string |
| 28 | 28 | |
| 29 | - // Ops is a list of nicks to grant +o (channel operator) status. | |
| 29 | + // Ops is a list of nicks to grant +o (channel operator) status via AMODE. | |
| 30 | 30 | Ops []string |
| 31 | 31 | |
| 32 | - // Voice is a list of nicks to grant +v status. | |
| 32 | + // Voice is a list of nicks to grant +v status via AMODE. | |
| 33 | 33 | Voice []string |
| 34 | 34 | |
| 35 | 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | 36 | Autojoin []string |
| 37 | + | |
| 38 | + // Modes is a list of channel modes to set (e.g. "+m" for moderated). | |
| 39 | + Modes []string | |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | 43 | type channelRecord struct { |
| 41 | 44 | name string |
| @@ -207,15 +210,21 @@ | ||
| 207 | 210 | |
| 208 | 211 | if ch.Topic != "" { |
| 209 | 212 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 210 | 213 | } |
| 211 | 214 | |
| 215 | + // Use AMODE for persistent auto-mode on join (survives reconnects). | |
| 212 | 216 | for _, nick := range ch.Ops { |
| 213 | - m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick) | |
| 217 | + m.chanserv("AMODE %s +o %s", ch.Name, nick) | |
| 214 | 218 | } |
| 215 | 219 | for _, nick := range ch.Voice { |
| 216 | - m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick) | |
| 220 | + m.chanserv("AMODE %s +v %s", ch.Name, nick) | |
| 221 | + } | |
| 222 | + | |
| 223 | + // Apply channel modes (e.g. +m for moderated). | |
| 224 | + for _, mode := range ch.Modes { | |
| 225 | + m.client.Cmd.Mode(ch.Name, mode) | |
| 217 | 226 | } |
| 218 | 227 | |
| 219 | 228 | if len(ch.Autojoin) > 0 { |
| 220 | 229 | m.Invite(ch.Name, ch.Autojoin) |
| 221 | 230 | } |
| @@ -274,33 +283,75 @@ | ||
| 274 | 283 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 275 | 284 | m.DropChannel(rec.name) |
| 276 | 285 | } |
| 277 | 286 | } |
| 278 | 287 | |
| 279 | -// GrantAccess sets a ChanServ ACCESS entry for nick on the given channel. | |
| 280 | -// level is "OP" or "VOICE". If level is empty, no access is granted. | |
| 288 | +// GrantAccess sets a ChanServ AMODE entry for nick on the given channel. | |
| 289 | +// level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ | |
| 290 | +// automatically applies the mode every time the nick joins. | |
| 281 | 291 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 282 | 292 | if m.client == nil || level == "" { |
| 283 | 293 | return |
| 284 | 294 | } |
| 285 | - m.chanserv("ACCESS %s ADD %s %s", channel, nick, level) | |
| 286 | - m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level) | |
| 295 | + switch strings.ToUpper(level) { | |
| 296 | + case "OP": | |
| 297 | + m.chanserv("AMODE %s +o %s", channel, nick) | |
| 298 | + case "VOICE": | |
| 299 | + m.chanserv("AMODE %s +v %s", channel, nick) | |
| 300 | + default: | |
| 301 | + m.log.Warn("unknown access level", "level", level) | |
| 302 | + return | |
| 303 | + } | |
| 304 | + m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level) | |
| 287 | 305 | } |
| 288 | 306 | |
| 289 | -// RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel. | |
| 307 | +// RevokeAccess removes ChanServ AMODE entries for nick on the given channel. | |
| 290 | 308 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 291 | 309 | if m.client == nil { |
| 292 | 310 | return |
| 293 | 311 | } |
| 294 | - m.chanserv("ACCESS %s DEL %s", channel, nick) | |
| 295 | - m.log.Info("revoked channel access", "nick", nick, "channel", channel) | |
| 312 | + m.chanserv("AMODE %s -o %s", channel, nick) | |
| 313 | + m.chanserv("AMODE %s -v %s", channel, nick) | |
| 314 | + m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel) | |
| 296 | 315 | } |
| 297 | 316 | |
| 298 | 317 | func (m *Manager) chanserv(format string, args ...any) { |
| 299 | 318 | msg := fmt.Sprintf(format, args...) |
| 300 | 319 | m.client.Cmd.Message("ChanServ", msg) |
| 301 | 320 | } |
| 321 | + | |
| 322 | +// ChannelInfo describes an active provisioned channel. | |
| 323 | +type ChannelInfo struct { | |
| 324 | + Name string `json:"name"` | |
| 325 | + ProvisionedAt time.Time `json:"provisioned_at"` | |
| 326 | + Type string `json:"type,omitempty"` | |
| 327 | + Ephemeral bool `json:"ephemeral,omitempty"` | |
| 328 | + TTLSeconds int64 `json:"ttl_seconds,omitempty"` | |
| 329 | +} | |
| 330 | + | |
| 331 | +// ListChannels returns all actively provisioned channels. | |
| 332 | +func (m *Manager) ListChannels() []ChannelInfo { | |
| 333 | + m.mu.Lock() | |
| 334 | + defer m.mu.Unlock() | |
| 335 | + out := make([]ChannelInfo, 0, len(m.channels)) | |
| 336 | + for _, rec := range m.channels { | |
| 337 | + ci := ChannelInfo{ | |
| 338 | + Name: rec.name, | |
| 339 | + ProvisionedAt: rec.provisionedAt, | |
| 340 | + } | |
| 341 | + if m.policy != nil { | |
| 342 | + ci.Type = m.policy.TypeName(rec.name) | |
| 343 | + ci.Ephemeral = m.policy.IsEphemeral(rec.name) | |
| 344 | + ttl := m.policy.TTLFor(rec.name) | |
| 345 | + if ttl > 0 { | |
| 346 | + ci.TTLSeconds = int64(ttl.Seconds()) | |
| 347 | + } | |
| 348 | + } | |
| 349 | + out = append(out, ci) | |
| 350 | + } | |
| 351 | + return out | |
| 352 | +} | |
| 302 | 353 | |
| 303 | 354 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 304 | 355 | func ValidateName(name string) error { |
| 305 | 356 | if !strings.HasPrefix(name, "#") { |
| 306 | 357 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 307 | 358 |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -24,18 +24,21 @@ | |
| 24 | Name string |
| 25 | |
| 26 | // Topic is the initial channel topic (shared state header). |
| 27 | Topic string |
| 28 | |
| 29 | // Ops is a list of nicks to grant +o (channel operator) status. |
| 30 | Ops []string |
| 31 | |
| 32 | // Voice is a list of nicks to grant +v status. |
| 33 | Voice []string |
| 34 | |
| 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | Autojoin []string |
| 37 | } |
| 38 | |
| 39 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | type channelRecord struct { |
| 41 | name string |
| @@ -207,15 +210,21 @@ | |
| 207 | |
| 208 | if ch.Topic != "" { |
| 209 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 210 | } |
| 211 | |
| 212 | for _, nick := range ch.Ops { |
| 213 | m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick) |
| 214 | } |
| 215 | for _, nick := range ch.Voice { |
| 216 | m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick) |
| 217 | } |
| 218 | |
| 219 | if len(ch.Autojoin) > 0 { |
| 220 | m.Invite(ch.Name, ch.Autojoin) |
| 221 | } |
| @@ -274,33 +283,75 @@ | |
| 274 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 275 | m.DropChannel(rec.name) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // GrantAccess sets a ChanServ ACCESS entry for nick on the given channel. |
| 280 | // level is "OP" or "VOICE". If level is empty, no access is granted. |
| 281 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 282 | if m.client == nil || level == "" { |
| 283 | return |
| 284 | } |
| 285 | m.chanserv("ACCESS %s ADD %s %s", channel, nick, level) |
| 286 | m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level) |
| 287 | } |
| 288 | |
| 289 | // RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel. |
| 290 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 291 | if m.client == nil { |
| 292 | return |
| 293 | } |
| 294 | m.chanserv("ACCESS %s DEL %s", channel, nick) |
| 295 | m.log.Info("revoked channel access", "nick", nick, "channel", channel) |
| 296 | } |
| 297 | |
| 298 | func (m *Manager) chanserv(format string, args ...any) { |
| 299 | msg := fmt.Sprintf(format, args...) |
| 300 | m.client.Cmd.Message("ChanServ", msg) |
| 301 | } |
| 302 | |
| 303 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 304 | func ValidateName(name string) error { |
| 305 | if !strings.HasPrefix(name, "#") { |
| 306 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 307 |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -24,18 +24,21 @@ | |
| 24 | Name string |
| 25 | |
| 26 | // Topic is the initial channel topic (shared state header). |
| 27 | Topic string |
| 28 | |
| 29 | // Ops is a list of nicks to grant +o (channel operator) status via AMODE. |
| 30 | Ops []string |
| 31 | |
| 32 | // Voice is a list of nicks to grant +v status via AMODE. |
| 33 | Voice []string |
| 34 | |
| 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | Autojoin []string |
| 37 | |
| 38 | // Modes is a list of channel modes to set (e.g. "+m" for moderated). |
| 39 | Modes []string |
| 40 | } |
| 41 | |
| 42 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 43 | type channelRecord struct { |
| 44 | name string |
| @@ -207,15 +210,21 @@ | |
| 210 | |
| 211 | if ch.Topic != "" { |
| 212 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 213 | } |
| 214 | |
| 215 | // Use AMODE for persistent auto-mode on join (survives reconnects). |
| 216 | for _, nick := range ch.Ops { |
| 217 | m.chanserv("AMODE %s +o %s", ch.Name, nick) |
| 218 | } |
| 219 | for _, nick := range ch.Voice { |
| 220 | m.chanserv("AMODE %s +v %s", ch.Name, nick) |
| 221 | } |
| 222 | |
| 223 | // Apply channel modes (e.g. +m for moderated). |
| 224 | for _, mode := range ch.Modes { |
| 225 | m.client.Cmd.Mode(ch.Name, mode) |
| 226 | } |
| 227 | |
| 228 | if len(ch.Autojoin) > 0 { |
| 229 | m.Invite(ch.Name, ch.Autojoin) |
| 230 | } |
| @@ -274,33 +283,75 @@ | |
| 283 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 284 | m.DropChannel(rec.name) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // GrantAccess sets a ChanServ AMODE entry for nick on the given channel. |
| 289 | // level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ |
| 290 | // automatically applies the mode every time the nick joins. |
| 291 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 292 | if m.client == nil || level == "" { |
| 293 | return |
| 294 | } |
| 295 | switch strings.ToUpper(level) { |
| 296 | case "OP": |
| 297 | m.chanserv("AMODE %s +o %s", channel, nick) |
| 298 | case "VOICE": |
| 299 | m.chanserv("AMODE %s +v %s", channel, nick) |
| 300 | default: |
| 301 | m.log.Warn("unknown access level", "level", level) |
| 302 | return |
| 303 | } |
| 304 | m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level) |
| 305 | } |
| 306 | |
| 307 | // RevokeAccess removes ChanServ AMODE entries for nick on the given channel. |
| 308 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 309 | if m.client == nil { |
| 310 | return |
| 311 | } |
| 312 | m.chanserv("AMODE %s -o %s", channel, nick) |
| 313 | m.chanserv("AMODE %s -v %s", channel, nick) |
| 314 | m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel) |
| 315 | } |
| 316 | |
| 317 | func (m *Manager) chanserv(format string, args ...any) { |
| 318 | msg := fmt.Sprintf(format, args...) |
| 319 | m.client.Cmd.Message("ChanServ", msg) |
| 320 | } |
| 321 | |
| 322 | // ChannelInfo describes an active provisioned channel. |
| 323 | type ChannelInfo struct { |
| 324 | Name string `json:"name"` |
| 325 | ProvisionedAt time.Time `json:"provisioned_at"` |
| 326 | Type string `json:"type,omitempty"` |
| 327 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 328 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 329 | } |
| 330 | |
| 331 | // ListChannels returns all actively provisioned channels. |
| 332 | func (m *Manager) ListChannels() []ChannelInfo { |
| 333 | m.mu.Lock() |
| 334 | defer m.mu.Unlock() |
| 335 | out := make([]ChannelInfo, 0, len(m.channels)) |
| 336 | for _, rec := range m.channels { |
| 337 | ci := ChannelInfo{ |
| 338 | Name: rec.name, |
| 339 | ProvisionedAt: rec.provisionedAt, |
| 340 | } |
| 341 | if m.policy != nil { |
| 342 | ci.Type = m.policy.TypeName(rec.name) |
| 343 | ci.Ephemeral = m.policy.IsEphemeral(rec.name) |
| 344 | ttl := m.policy.TTLFor(rec.name) |
| 345 | if ttl > 0 { |
| 346 | ci.TTLSeconds = int64(ttl.Seconds()) |
| 347 | } |
| 348 | } |
| 349 | out = append(out, ci) |
| 350 | } |
| 351 | return out |
| 352 | } |
| 353 | |
| 354 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 355 | func ValidateName(name string) error { |
| 356 | if !strings.HasPrefix(name, "#") { |
| 357 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 358 |
| --- pkg/ircagent/ircagent.go | ||
| +++ pkg/ircagent/ircagent.go | ||
| @@ -271,10 +271,17 @@ | ||
| 271 | 271 | text := strings.TrimSpace(e.Last()) |
| 272 | 272 | if senderNick == a.cfg.Nick { |
| 273 | 273 | return |
| 274 | 274 | } |
| 275 | 275 | |
| 276 | + // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. | |
| 277 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 278 | + if idx := strings.Index(senderNick, sep); idx != -1 { | |
| 279 | + senderNick = senderNick[:idx] | |
| 280 | + } | |
| 281 | + } | |
| 282 | + // Fallback: parse legacy [nick] prefix from bridge bot. | |
| 276 | 283 | if strings.HasPrefix(text, "[") { |
| 277 | 284 | if end := strings.Index(text, "] "); end != -1 { |
| 278 | 285 | senderNick = text[1:end] |
| 279 | 286 | text = text[end+2:] |
| 280 | 287 | } |
| 281 | 288 |
| --- pkg/ircagent/ircagent.go | |
| +++ pkg/ircagent/ircagent.go | |
| @@ -271,10 +271,17 @@ | |
| 271 | text := strings.TrimSpace(e.Last()) |
| 272 | if senderNick == a.cfg.Nick { |
| 273 | return |
| 274 | } |
| 275 | |
| 276 | if strings.HasPrefix(text, "[") { |
| 277 | if end := strings.Index(text, "] "); end != -1 { |
| 278 | senderNick = text[1:end] |
| 279 | text = text[end+2:] |
| 280 | } |
| 281 |
| --- pkg/ircagent/ircagent.go | |
| +++ pkg/ircagent/ircagent.go | |
| @@ -271,10 +271,17 @@ | |
| 271 | text := strings.TrimSpace(e.Last()) |
| 272 | if senderNick == a.cfg.Nick { |
| 273 | return |
| 274 | } |
| 275 | |
| 276 | // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. |
| 277 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 278 | if idx := strings.Index(senderNick, sep); idx != -1 { |
| 279 | senderNick = senderNick[:idx] |
| 280 | } |
| 281 | } |
| 282 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 283 | if strings.HasPrefix(text, "[") { |
| 284 | if end := strings.Index(text, "] "); end != -1 { |
| 285 | senderNick = text[1:end] |
| 286 | text = text[end+2:] |
| 287 | } |
| 288 |
+7
-1
| --- pkg/sessionrelay/irc.go | ||
| +++ pkg/sessionrelay/irc.go | ||
| @@ -126,11 +126,11 @@ | ||
| 126 | 126 | } |
| 127 | 127 | if onJoined != nil { |
| 128 | 128 | onJoined() |
| 129 | 129 | } |
| 130 | 130 | }) |
| 131 | - client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { | |
| 131 | + client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { | |
| 132 | 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | 133 | return |
| 134 | 134 | } |
| 135 | 135 | target := normalizeChannel(e.Params[0]) |
| 136 | 136 | if !c.hasChannel(target) { |
| @@ -140,10 +140,16 @@ | ||
| 140 | 140 | sender := e.Source.Name |
| 141 | 141 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 142 | 142 | sender = acct |
| 143 | 143 | } |
| 144 | 144 | text := strings.TrimSpace(e.Last()) |
| 145 | + // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. | |
| 146 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 147 | + if idx := strings.Index(sender, sep); idx != -1 { | |
| 148 | + sender = sender[:idx] | |
| 149 | + } | |
| 150 | + } | |
| 145 | 151 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 146 | 152 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 147 | 153 | if end := strings.Index(text, "] "); end != -1 { |
| 148 | 154 | sender = text[1:end] |
| 149 | 155 | text = strings.TrimSpace(text[end+2:]) |
| 150 | 156 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -126,11 +126,11 @@ | |
| 126 | } |
| 127 | if onJoined != nil { |
| 128 | onJoined() |
| 129 | } |
| 130 | }) |
| 131 | client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | return |
| 134 | } |
| 135 | target := normalizeChannel(e.Params[0]) |
| 136 | if !c.hasChannel(target) { |
| @@ -140,10 +140,16 @@ | |
| 140 | sender := e.Source.Name |
| 141 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 142 | sender = acct |
| 143 | } |
| 144 | text := strings.TrimSpace(e.Last()) |
| 145 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 146 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 147 | if end := strings.Index(text, "] "); end != -1 { |
| 148 | sender = text[1:end] |
| 149 | text = strings.TrimSpace(text[end+2:]) |
| 150 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -126,11 +126,11 @@ | |
| 126 | } |
| 127 | if onJoined != nil { |
| 128 | onJoined() |
| 129 | } |
| 130 | }) |
| 131 | client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { |
| 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | return |
| 134 | } |
| 135 | target := normalizeChannel(e.Params[0]) |
| 136 | if !c.hasChannel(target) { |
| @@ -140,10 +140,16 @@ | |
| 140 | sender := e.Source.Name |
| 141 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 142 | sender = acct |
| 143 | } |
| 144 | text := strings.TrimSpace(e.Last()) |
| 145 | // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. |
| 146 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 147 | if idx := strings.Index(sender, sep); idx != -1 { |
| 148 | sender = sender[:idx] |
| 149 | } |
| 150 | } |
| 151 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 152 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 153 | if end := strings.Index(text, "] "); end != -1 { |
| 154 | sender = text[1:end] |
| 155 | text = strings.TrimSpace(text[end+2:]) |
| 156 |
+7
-1
| --- pkg/sessionrelay/irc.go | ||
| +++ pkg/sessionrelay/irc.go | ||
| @@ -126,11 +126,11 @@ | ||
| 126 | 126 | } |
| 127 | 127 | if onJoined != nil { |
| 128 | 128 | onJoined() |
| 129 | 129 | } |
| 130 | 130 | }) |
| 131 | - client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { | |
| 131 | + client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { | |
| 132 | 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | 133 | return |
| 134 | 134 | } |
| 135 | 135 | target := normalizeChannel(e.Params[0]) |
| 136 | 136 | if !c.hasChannel(target) { |
| @@ -140,10 +140,16 @@ | ||
| 140 | 140 | sender := e.Source.Name |
| 141 | 141 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 142 | 142 | sender = acct |
| 143 | 143 | } |
| 144 | 144 | text := strings.TrimSpace(e.Last()) |
| 145 | + // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. | |
| 146 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 147 | + if idx := strings.Index(sender, sep); idx != -1 { | |
| 148 | + sender = sender[:idx] | |
| 149 | + } | |
| 150 | + } | |
| 145 | 151 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 146 | 152 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 147 | 153 | if end := strings.Index(text, "] "); end != -1 { |
| 148 | 154 | sender = text[1:end] |
| 149 | 155 | text = strings.TrimSpace(text[end+2:]) |
| 150 | 156 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -126,11 +126,11 @@ | |
| 126 | } |
| 127 | if onJoined != nil { |
| 128 | onJoined() |
| 129 | } |
| 130 | }) |
| 131 | client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | return |
| 134 | } |
| 135 | target := normalizeChannel(e.Params[0]) |
| 136 | if !c.hasChannel(target) { |
| @@ -140,10 +140,16 @@ | |
| 140 | sender := e.Source.Name |
| 141 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 142 | sender = acct |
| 143 | } |
| 144 | text := strings.TrimSpace(e.Last()) |
| 145 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 146 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 147 | if end := strings.Index(text, "] "); end != -1 { |
| 148 | sender = text[1:end] |
| 149 | text = strings.TrimSpace(text[end+2:]) |
| 150 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -126,11 +126,11 @@ | |
| 126 | } |
| 127 | if onJoined != nil { |
| 128 | onJoined() |
| 129 | } |
| 130 | }) |
| 131 | client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { |
| 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | return |
| 134 | } |
| 135 | target := normalizeChannel(e.Params[0]) |
| 136 | if !c.hasChannel(target) { |
| @@ -140,10 +140,16 @@ | |
| 140 | sender := e.Source.Name |
| 141 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 142 | sender = acct |
| 143 | } |
| 144 | text := strings.TrimSpace(e.Last()) |
| 145 | // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. |
| 146 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 147 | if idx := strings.Index(sender, sep); idx != -1 { |
| 148 | sender = sender[:idx] |
| 149 | } |
| 150 | } |
| 151 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 152 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 153 | if end := strings.Index(text, "] "); end != -1 { |
| 154 | sender = text[1:end] |
| 155 | text = strings.TrimSpace(text[end+2:]) |
| 156 |