ScuttleBot
feat(store): SQLite/Postgres datastore backend Wire registry, admin accounts, and policy store to database/sql. Both SQLite (modernc.org/sqlite, pure Go) and PostgreSQL (lib/pq) are supported. JSON file storage remains the default — set datastore.driver in scuttlebot.yaml or SCUTTLEBOT_DB_DRIVER to activate. - internal/store: new package with Open, migrate, CRUD for agents/admins/policies - registry.Registry: SetStore() switches from JSON file to DB - auth.AdminStore: SetStore() switches from JSON file to DB - api.PolicyStore: SetStore() switches from JSON file to DB; applyRaw extracted - cmd/scuttlebot/main: open datastore on startup and wire into all three stores - docker-compose already had SCUTTLEBOT_DB_DRIVER=postgres; now actually works
0e7895411ee4c10b349f4194ad75c86d41846f316c03e3229fcd245ed89e26ab
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -24,10 +24,11 @@ | ||
| 24 | 24 | botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager" |
| 25 | 25 | "github.com/conflicthq/scuttlebot/internal/config" |
| 26 | 26 | "github.com/conflicthq/scuttlebot/internal/ergo" |
| 27 | 27 | "github.com/conflicthq/scuttlebot/internal/mcp" |
| 28 | 28 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 29 | + "github.com/conflicthq/scuttlebot/internal/store" | |
| 29 | 30 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 30 | 31 | ) |
| 31 | 32 | |
| 32 | 33 | var version = "dev" |
| 33 | 34 | |
| @@ -104,20 +105,39 @@ | ||
| 104 | 105 | os.Exit(1) |
| 105 | 106 | case <-time.After(500 * time.Millisecond): |
| 106 | 107 | } |
| 107 | 108 | } |
| 108 | 109 | log.Info("ergo healthy") |
| 110 | + | |
| 111 | + // Open datastore if configured (SQLite or PostgreSQL). | |
| 112 | + // When not configured, all stores fall back to JSON files in data/. | |
| 113 | + var dataStore *store.Store | |
| 114 | + if cfg.Datastore.Driver != "" && cfg.Datastore.DSN != "" { | |
| 115 | + ds, err := store.Open(cfg.Datastore.Driver, cfg.Datastore.DSN) | |
| 116 | + if err != nil { | |
| 117 | + log.Error("datastore open", "driver", cfg.Datastore.Driver, "err", err) | |
| 118 | + os.Exit(1) | |
| 119 | + } | |
| 120 | + defer ds.Close() | |
| 121 | + dataStore = ds | |
| 122 | + log.Info("datastore open", "driver", cfg.Datastore.Driver) | |
| 123 | + } | |
| 109 | 124 | |
| 110 | 125 | // Build registry backed by Ergo's NickServ API. |
| 111 | 126 | // Signing key persists so issued payloads stay valid across restarts. |
| 112 | 127 | signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key")) |
| 113 | 128 | if err != nil { |
| 114 | 129 | log.Error("signing key", "err", err) |
| 115 | 130 | os.Exit(1) |
| 116 | 131 | } |
| 117 | 132 | reg := registry.New(ergoMgr.API(), []byte(signingKeyHex)) |
| 118 | - if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { | |
| 133 | + if dataStore != nil { | |
| 134 | + if err := reg.SetStore(dataStore); err != nil { | |
| 135 | + log.Error("registry load from store", "err", err) | |
| 136 | + os.Exit(1) | |
| 137 | + } | |
| 138 | + } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { | |
| 119 | 139 | log.Error("registry load", "err", err) |
| 120 | 140 | os.Exit(1) |
| 121 | 141 | } |
| 122 | 142 | |
| 123 | 143 | // Shared API token — persisted so the UI token survives restarts. |
| @@ -201,10 +221,16 @@ | ||
| 201 | 221 | // Policy store — persists behavior/agent/logging settings. |
| 202 | 222 | policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes) |
| 203 | 223 | if err != nil { |
| 204 | 224 | log.Error("policy store", "err", err) |
| 205 | 225 | os.Exit(1) |
| 226 | + } | |
| 227 | + if dataStore != nil { | |
| 228 | + if err := policyStore.SetStore(dataStore); err != nil { | |
| 229 | + log.Error("policy store load from db", "err", err) | |
| 230 | + os.Exit(1) | |
| 231 | + } | |
| 206 | 232 | } |
| 207 | 233 | if bridgeBot != nil { |
| 208 | 234 | bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute) |
| 209 | 235 | } |
| 210 | 236 | |
| @@ -211,10 +237,16 @@ | ||
| 211 | 237 | // Admin store — bcrypt-hashed admin accounts. |
| 212 | 238 | adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
| 213 | 239 | if err != nil { |
| 214 | 240 | log.Error("admin store", "err", err) |
| 215 | 241 | os.Exit(1) |
| 242 | + } | |
| 243 | + if dataStore != nil { | |
| 244 | + if err := adminStore.SetStore(dataStore); err != nil { | |
| 245 | + log.Error("admin store load from db", "err", err) | |
| 246 | + os.Exit(1) | |
| 247 | + } | |
| 216 | 248 | } |
| 217 | 249 | if adminStore.IsEmpty() { |
| 218 | 250 | password := mustGenToken()[:16] |
| 219 | 251 | if err := adminStore.Add("admin", password); err != nil { |
| 220 | 252 | log.Error("create default admin", "err", err) |
| 221 | 253 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -24,10 +24,11 @@ | |
| 24 | botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager" |
| 25 | "github.com/conflicthq/scuttlebot/internal/config" |
| 26 | "github.com/conflicthq/scuttlebot/internal/ergo" |
| 27 | "github.com/conflicthq/scuttlebot/internal/mcp" |
| 28 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 29 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 30 | ) |
| 31 | |
| 32 | var version = "dev" |
| 33 | |
| @@ -104,20 +105,39 @@ | |
| 104 | os.Exit(1) |
| 105 | case <-time.After(500 * time.Millisecond): |
| 106 | } |
| 107 | } |
| 108 | log.Info("ergo healthy") |
| 109 | |
| 110 | // Build registry backed by Ergo's NickServ API. |
| 111 | // Signing key persists so issued payloads stay valid across restarts. |
| 112 | signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key")) |
| 113 | if err != nil { |
| 114 | log.Error("signing key", "err", err) |
| 115 | os.Exit(1) |
| 116 | } |
| 117 | reg := registry.New(ergoMgr.API(), []byte(signingKeyHex)) |
| 118 | if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 119 | log.Error("registry load", "err", err) |
| 120 | os.Exit(1) |
| 121 | } |
| 122 | |
| 123 | // Shared API token — persisted so the UI token survives restarts. |
| @@ -201,10 +221,16 @@ | |
| 201 | // Policy store — persists behavior/agent/logging settings. |
| 202 | policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes) |
| 203 | if err != nil { |
| 204 | log.Error("policy store", "err", err) |
| 205 | os.Exit(1) |
| 206 | } |
| 207 | if bridgeBot != nil { |
| 208 | bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute) |
| 209 | } |
| 210 | |
| @@ -211,10 +237,16 @@ | |
| 211 | // Admin store — bcrypt-hashed admin accounts. |
| 212 | adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
| 213 | if err != nil { |
| 214 | log.Error("admin store", "err", err) |
| 215 | os.Exit(1) |
| 216 | } |
| 217 | if adminStore.IsEmpty() { |
| 218 | password := mustGenToken()[:16] |
| 219 | if err := adminStore.Add("admin", password); err != nil { |
| 220 | log.Error("create default admin", "err", err) |
| 221 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -24,10 +24,11 @@ | |
| 24 | botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager" |
| 25 | "github.com/conflicthq/scuttlebot/internal/config" |
| 26 | "github.com/conflicthq/scuttlebot/internal/ergo" |
| 27 | "github.com/conflicthq/scuttlebot/internal/mcp" |
| 28 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 29 | "github.com/conflicthq/scuttlebot/internal/store" |
| 30 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 31 | ) |
| 32 | |
| 33 | var version = "dev" |
| 34 | |
| @@ -104,20 +105,39 @@ | |
| 105 | os.Exit(1) |
| 106 | case <-time.After(500 * time.Millisecond): |
| 107 | } |
| 108 | } |
| 109 | log.Info("ergo healthy") |
| 110 | |
| 111 | // Open datastore if configured (SQLite or PostgreSQL). |
| 112 | // When not configured, all stores fall back to JSON files in data/. |
| 113 | var dataStore *store.Store |
| 114 | if cfg.Datastore.Driver != "" && cfg.Datastore.DSN != "" { |
| 115 | ds, err := store.Open(cfg.Datastore.Driver, cfg.Datastore.DSN) |
| 116 | if err != nil { |
| 117 | log.Error("datastore open", "driver", cfg.Datastore.Driver, "err", err) |
| 118 | os.Exit(1) |
| 119 | } |
| 120 | defer ds.Close() |
| 121 | dataStore = ds |
| 122 | log.Info("datastore open", "driver", cfg.Datastore.Driver) |
| 123 | } |
| 124 | |
| 125 | // Build registry backed by Ergo's NickServ API. |
| 126 | // Signing key persists so issued payloads stay valid across restarts. |
| 127 | signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key")) |
| 128 | if err != nil { |
| 129 | log.Error("signing key", "err", err) |
| 130 | os.Exit(1) |
| 131 | } |
| 132 | reg := registry.New(ergoMgr.API(), []byte(signingKeyHex)) |
| 133 | if dataStore != nil { |
| 134 | if err := reg.SetStore(dataStore); err != nil { |
| 135 | log.Error("registry load from store", "err", err) |
| 136 | os.Exit(1) |
| 137 | } |
| 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. |
| @@ -201,10 +221,16 @@ | |
| 221 | // Policy store — persists behavior/agent/logging settings. |
| 222 | policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes) |
| 223 | if err != nil { |
| 224 | log.Error("policy store", "err", err) |
| 225 | os.Exit(1) |
| 226 | } |
| 227 | if dataStore != nil { |
| 228 | if err := policyStore.SetStore(dataStore); err != nil { |
| 229 | log.Error("policy store load from db", "err", err) |
| 230 | os.Exit(1) |
| 231 | } |
| 232 | } |
| 233 | if bridgeBot != nil { |
| 234 | bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute) |
| 235 | } |
| 236 | |
| @@ -211,10 +237,16 @@ | |
| 237 | // Admin store — bcrypt-hashed admin accounts. |
| 238 | adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
| 239 | if err != nil { |
| 240 | log.Error("admin store", "err", err) |
| 241 | os.Exit(1) |
| 242 | } |
| 243 | if dataStore != nil { |
| 244 | if err := adminStore.SetStore(dataStore); err != nil { |
| 245 | log.Error("admin store load from db", "err", err) |
| 246 | os.Exit(1) |
| 247 | } |
| 248 | } |
| 249 | if adminStore.IsEmpty() { |
| 250 | password := mustGenToken()[:16] |
| 251 | if err := adminStore.Add("admin", password); err != nil { |
| 252 | log.Error("create default admin", "err", err) |
| 253 |
| --- go.mod | ||
| +++ go.mod | ||
| @@ -11,9 +11,19 @@ | ||
| 11 | 11 | golang.org/x/term v0.32.0 |
| 12 | 12 | gopkg.in/yaml.v3 v3.0.1 |
| 13 | 13 | ) |
| 14 | 14 | |
| 15 | 15 | require ( |
| 16 | + github.com/dustin/go-humanize v1.0.1 // indirect | |
| 17 | + github.com/google/uuid v1.6.0 // indirect | |
| 18 | + github.com/lib/pq v1.12.2 // indirect | |
| 19 | + github.com/mattn/go-isatty v0.0.20 // indirect | |
| 20 | + github.com/ncruces/go-strftime v1.0.0 // indirect | |
| 21 | + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 16 | 22 | golang.org/x/net v0.41.0 // indirect |
| 17 | - golang.org/x/sys v0.33.0 // indirect | |
| 23 | + golang.org/x/sys v0.42.0 // indirect | |
| 18 | 24 | golang.org/x/text v0.35.0 // indirect |
| 25 | + modernc.org/libc v1.70.0 // indirect | |
| 26 | + modernc.org/mathutil v1.7.1 // indirect | |
| 27 | + modernc.org/memory v1.11.0 // indirect | |
| 28 | + modernc.org/sqlite v1.48.0 // indirect | |
| 19 | 29 | ) |
| 20 | 30 |
| --- go.mod | |
| +++ go.mod | |
| @@ -11,9 +11,19 @@ | |
| 11 | golang.org/x/term v0.32.0 |
| 12 | gopkg.in/yaml.v3 v3.0.1 |
| 13 | ) |
| 14 | |
| 15 | require ( |
| 16 | golang.org/x/net v0.41.0 // indirect |
| 17 | golang.org/x/sys v0.33.0 // indirect |
| 18 | golang.org/x/text v0.35.0 // indirect |
| 19 | ) |
| 20 |
| --- go.mod | |
| +++ go.mod | |
| @@ -11,9 +11,19 @@ | |
| 11 | golang.org/x/term v0.32.0 |
| 12 | gopkg.in/yaml.v3 v3.0.1 |
| 13 | ) |
| 14 | |
| 15 | require ( |
| 16 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 17 | github.com/google/uuid v1.6.0 // indirect |
| 18 | github.com/lib/pq v1.12.2 // indirect |
| 19 | github.com/mattn/go-isatty v0.0.20 // indirect |
| 20 | github.com/ncruces/go-strftime v1.0.0 // indirect |
| 21 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 22 | golang.org/x/net v0.41.0 // indirect |
| 23 | golang.org/x/sys v0.42.0 // indirect |
| 24 | golang.org/x/text v0.35.0 // indirect |
| 25 | modernc.org/libc v1.70.0 // indirect |
| 26 | modernc.org/mathutil v1.7.1 // indirect |
| 27 | modernc.org/memory v1.11.0 // indirect |
| 28 | modernc.org/sqlite v1.48.0 // indirect |
| 29 | ) |
| 30 |
| --- go.sum | ||
| +++ go.sum | ||
| @@ -1,21 +1,44 @@ | ||
| 1 | 1 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= |
| 2 | 2 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= |
| 3 | +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | |
| 4 | +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | |
| 5 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 6 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 7 | +github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q= | |
| 8 | +github.com/lib/pq v1.12.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= | |
| 3 | 9 | github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk= |
| 4 | 10 | github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI= |
| 11 | +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | |
| 12 | +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | |
| 13 | +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= | |
| 14 | +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | |
| 5 | 15 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= |
| 6 | 16 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= |
| 7 | 17 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= |
| 18 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | |
| 19 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | |
| 8 | 20 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
| 9 | 21 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
| 10 | 22 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= |
| 11 | 23 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= |
| 24 | +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 12 | 25 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
| 13 | 26 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
| 27 | +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= | |
| 28 | +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 14 | 29 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= |
| 15 | 30 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= |
| 16 | 31 | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= |
| 17 | 32 | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= |
| 18 | 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
| 19 | 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 20 | 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 21 | 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 37 | +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= | |
| 38 | +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= | |
| 39 | +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | |
| 40 | +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | |
| 41 | +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | |
| 42 | +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | |
| 43 | +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= | |
| 44 | +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= | |
| 22 | 45 |
| --- go.sum | |
| +++ go.sum | |
| @@ -1,21 +1,44 @@ | |
| 1 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= |
| 2 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= |
| 3 | github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk= |
| 4 | github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI= |
| 5 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= |
| 6 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= |
| 7 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= |
| 8 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
| 9 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
| 10 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= |
| 11 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= |
| 12 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
| 13 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
| 14 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= |
| 15 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= |
| 16 | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= |
| 17 | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= |
| 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
| 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 20 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 21 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 22 |
| --- go.sum | |
| +++ go.sum | |
| @@ -1,21 +1,44 @@ | |
| 1 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= |
| 2 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= |
| 3 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 4 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| 5 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| 6 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| 7 | github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q= |
| 8 | github.com/lib/pq v1.12.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= |
| 9 | github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk= |
| 10 | github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI= |
| 11 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| 12 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| 13 | github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| 14 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| 15 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= |
| 16 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= |
| 17 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= |
| 18 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 19 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 20 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
| 21 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
| 22 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= |
| 23 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= |
| 24 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 25 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
| 26 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
| 27 | golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= |
| 28 | golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 29 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= |
| 30 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= |
| 31 | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= |
| 32 | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= |
| 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
| 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 37 | modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= |
| 38 | modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= |
| 39 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| 40 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| 41 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| 42 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| 43 | modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= |
| 44 | modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= |
| 45 |
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -4,10 +4,12 @@ | ||
| 4 | 4 | "encoding/json" |
| 5 | 5 | "fmt" |
| 6 | 6 | "net/http" |
| 7 | 7 | "os" |
| 8 | 8 | "sync" |
| 9 | + | |
| 10 | + "github.com/conflicthq/scuttlebot/internal/store" | |
| 9 | 11 | ) |
| 10 | 12 | |
| 11 | 13 | // BehaviorConfig defines a pre-registered system bot behavior. |
| 12 | 14 | type BehaviorConfig struct { |
| 13 | 15 | ID string `json:"id"` |
| @@ -147,17 +149,18 @@ | ||
| 147 | 149 | Nick: "steward", |
| 148 | 150 | JoinAllChannels: true, |
| 149 | 151 | }, |
| 150 | 152 | } |
| 151 | 153 | |
| 152 | -// PolicyStore persists Policies to a JSON file. | |
| 154 | +// PolicyStore persists Policies to a JSON file or database. | |
| 153 | 155 | type PolicyStore struct { |
| 154 | 156 | mu sync.RWMutex |
| 155 | 157 | path string |
| 156 | 158 | data Policies |
| 157 | 159 | defaultBridgeTTLMinutes int |
| 158 | 160 | onChange func(Policies) |
| 161 | + db *store.Store // when non-nil, supersedes path | |
| 159 | 162 | } |
| 160 | 163 | |
| 161 | 164 | func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) { |
| 162 | 165 | if defaultBridgeTTLMinutes <= 0 { |
| 163 | 166 | defaultBridgeTTLMinutes = 5 |
| @@ -180,10 +183,32 @@ | ||
| 180 | 183 | return nil |
| 181 | 184 | } |
| 182 | 185 | if err != nil { |
| 183 | 186 | return fmt.Errorf("policies: read %s: %w", ps.path, err) |
| 184 | 187 | } |
| 188 | + return ps.applyRaw(raw) | |
| 189 | +} | |
| 190 | + | |
| 191 | +// SetStore switches the policy store to database-backed persistence. The | |
| 192 | +// current in-memory defaults are merged with any saved policies in the store. | |
| 193 | +func (ps *PolicyStore) SetStore(db *store.Store) error { | |
| 194 | + raw, err := db.PolicyGet() | |
| 195 | + if err != nil { | |
| 196 | + return fmt.Errorf("policies: load from db: %w", err) | |
| 197 | + } | |
| 198 | + ps.mu.Lock() | |
| 199 | + defer ps.mu.Unlock() | |
| 200 | + ps.db = db | |
| 201 | + if raw == nil { | |
| 202 | + return nil // no saved policies yet; keep defaults | |
| 203 | + } | |
| 204 | + return ps.applyRaw(raw) | |
| 205 | +} | |
| 206 | + | |
| 207 | +// applyRaw merges a JSON blob into the in-memory policy state. | |
| 208 | +// Caller must hold ps.mu if called after initialisation. | |
| 209 | +func (ps *PolicyStore) applyRaw(raw []byte) error { | |
| 185 | 210 | var p Policies |
| 186 | 211 | if err := json.Unmarshal(raw, &p); err != nil { |
| 187 | 212 | return fmt.Errorf("policies: parse: %w", err) |
| 188 | 213 | } |
| 189 | 214 | ps.normalize(&p) |
| @@ -207,10 +232,13 @@ | ||
| 207 | 232 | func (ps *PolicyStore) save() error { |
| 208 | 233 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| 209 | 234 | if err != nil { |
| 210 | 235 | return err |
| 211 | 236 | } |
| 237 | + if ps.db != nil { | |
| 238 | + return ps.db.PolicySet(raw) | |
| 239 | + } | |
| 212 | 240 | return os.WriteFile(ps.path, raw, 0600) |
| 213 | 241 | } |
| 214 | 242 | |
| 215 | 243 | func (ps *PolicyStore) Get() Policies { |
| 216 | 244 | ps.mu.RLock() |
| 217 | 245 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -4,10 +4,12 @@ | |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "os" |
| 8 | "sync" |
| 9 | ) |
| 10 | |
| 11 | // BehaviorConfig defines a pre-registered system bot behavior. |
| 12 | type BehaviorConfig struct { |
| 13 | ID string `json:"id"` |
| @@ -147,17 +149,18 @@ | |
| 147 | Nick: "steward", |
| 148 | JoinAllChannels: true, |
| 149 | }, |
| 150 | } |
| 151 | |
| 152 | // PolicyStore persists Policies to a JSON file. |
| 153 | type PolicyStore struct { |
| 154 | mu sync.RWMutex |
| 155 | path string |
| 156 | data Policies |
| 157 | defaultBridgeTTLMinutes int |
| 158 | onChange func(Policies) |
| 159 | } |
| 160 | |
| 161 | func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) { |
| 162 | if defaultBridgeTTLMinutes <= 0 { |
| 163 | defaultBridgeTTLMinutes = 5 |
| @@ -180,10 +183,32 @@ | |
| 180 | return nil |
| 181 | } |
| 182 | if err != nil { |
| 183 | return fmt.Errorf("policies: read %s: %w", ps.path, err) |
| 184 | } |
| 185 | var p Policies |
| 186 | if err := json.Unmarshal(raw, &p); err != nil { |
| 187 | return fmt.Errorf("policies: parse: %w", err) |
| 188 | } |
| 189 | ps.normalize(&p) |
| @@ -207,10 +232,13 @@ | |
| 207 | func (ps *PolicyStore) save() error { |
| 208 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| 209 | if err != nil { |
| 210 | return err |
| 211 | } |
| 212 | return os.WriteFile(ps.path, raw, 0600) |
| 213 | } |
| 214 | |
| 215 | func (ps *PolicyStore) Get() Policies { |
| 216 | ps.mu.RLock() |
| 217 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -4,10 +4,12 @@ | |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "os" |
| 8 | "sync" |
| 9 | |
| 10 | "github.com/conflicthq/scuttlebot/internal/store" |
| 11 | ) |
| 12 | |
| 13 | // BehaviorConfig defines a pre-registered system bot behavior. |
| 14 | type BehaviorConfig struct { |
| 15 | ID string `json:"id"` |
| @@ -147,17 +149,18 @@ | |
| 149 | Nick: "steward", |
| 150 | JoinAllChannels: true, |
| 151 | }, |
| 152 | } |
| 153 | |
| 154 | // PolicyStore persists Policies to a JSON file or database. |
| 155 | type PolicyStore struct { |
| 156 | mu sync.RWMutex |
| 157 | path string |
| 158 | data Policies |
| 159 | defaultBridgeTTLMinutes int |
| 160 | onChange func(Policies) |
| 161 | db *store.Store // when non-nil, supersedes path |
| 162 | } |
| 163 | |
| 164 | func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) { |
| 165 | if defaultBridgeTTLMinutes <= 0 { |
| 166 | defaultBridgeTTLMinutes = 5 |
| @@ -180,10 +183,32 @@ | |
| 183 | return nil |
| 184 | } |
| 185 | if err != nil { |
| 186 | return fmt.Errorf("policies: read %s: %w", ps.path, err) |
| 187 | } |
| 188 | return ps.applyRaw(raw) |
| 189 | } |
| 190 | |
| 191 | // SetStore switches the policy store to database-backed persistence. The |
| 192 | // current in-memory defaults are merged with any saved policies in the store. |
| 193 | func (ps *PolicyStore) SetStore(db *store.Store) error { |
| 194 | raw, err := db.PolicyGet() |
| 195 | if err != nil { |
| 196 | return fmt.Errorf("policies: load from db: %w", err) |
| 197 | } |
| 198 | ps.mu.Lock() |
| 199 | defer ps.mu.Unlock() |
| 200 | ps.db = db |
| 201 | if raw == nil { |
| 202 | return nil // no saved policies yet; keep defaults |
| 203 | } |
| 204 | return ps.applyRaw(raw) |
| 205 | } |
| 206 | |
| 207 | // applyRaw merges a JSON blob into the in-memory policy state. |
| 208 | // Caller must hold ps.mu if called after initialisation. |
| 209 | func (ps *PolicyStore) applyRaw(raw []byte) error { |
| 210 | var p Policies |
| 211 | if err := json.Unmarshal(raw, &p); err != nil { |
| 212 | return fmt.Errorf("policies: parse: %w", err) |
| 213 | } |
| 214 | ps.normalize(&p) |
| @@ -207,10 +232,13 @@ | |
| 232 | func (ps *PolicyStore) save() error { |
| 233 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| 234 | if err != nil { |
| 235 | return err |
| 236 | } |
| 237 | if ps.db != nil { |
| 238 | return ps.db.PolicySet(raw) |
| 239 | } |
| 240 | return os.WriteFile(ps.path, raw, 0600) |
| 241 | } |
| 242 | |
| 243 | func (ps *PolicyStore) Get() Policies { |
| 244 | ps.mu.RLock() |
| 245 |
| --- internal/auth/admin.go | ||
| +++ internal/auth/admin.go | ||
| @@ -7,24 +7,27 @@ | ||
| 7 | 7 | "os" |
| 8 | 8 | "sync" |
| 9 | 9 | "time" |
| 10 | 10 | |
| 11 | 11 | "golang.org/x/crypto/bcrypt" |
| 12 | + | |
| 13 | + "github.com/conflicthq/scuttlebot/internal/store" | |
| 12 | 14 | ) |
| 13 | 15 | |
| 14 | 16 | // Admin is a single admin account record. |
| 15 | 17 | type Admin struct { |
| 16 | 18 | Username string `json:"username"` |
| 17 | 19 | Hash []byte `json:"hash"` |
| 18 | 20 | Created time.Time `json:"created"` |
| 19 | 21 | } |
| 20 | 22 | |
| 21 | -// AdminStore persists admin accounts to a JSON file. | |
| 23 | +// AdminStore persists admin accounts to a JSON file or database. | |
| 22 | 24 | type AdminStore struct { |
| 23 | 25 | mu sync.RWMutex |
| 24 | 26 | path string |
| 25 | 27 | data []Admin |
| 28 | + db *store.Store // when non-nil, supersedes path | |
| 26 | 29 | } |
| 27 | 30 | |
| 28 | 31 | // NewAdminStore loads (or creates) the admin store at the given path. |
| 29 | 32 | func NewAdminStore(path string) (*AdminStore, error) { |
| 30 | 33 | s := &AdminStore{path: path} |
| @@ -31,10 +34,27 @@ | ||
| 31 | 34 | if err := s.load(); err != nil { |
| 32 | 35 | return nil, err |
| 33 | 36 | } |
| 34 | 37 | return s, nil |
| 35 | 38 | } |
| 39 | + | |
| 40 | +// SetStore switches the admin store to database-backed persistence. All current | |
| 41 | +// in-memory state is replaced with rows loaded from the store. | |
| 42 | +func (s *AdminStore) SetStore(db *store.Store) error { | |
| 43 | + rows, err := db.AdminList() | |
| 44 | + if err != nil { | |
| 45 | + return fmt.Errorf("admin store: load from db: %w", err) | |
| 46 | + } | |
| 47 | + s.mu.Lock() | |
| 48 | + defer s.mu.Unlock() | |
| 49 | + s.db = db | |
| 50 | + s.data = make([]Admin, len(rows)) | |
| 51 | + for i, r := range rows { | |
| 52 | + s.data[i] = Admin{Username: r.Username, Hash: r.Hash, Created: r.CreatedAt} | |
| 53 | + } | |
| 54 | + return nil | |
| 55 | +} | |
| 36 | 56 | |
| 37 | 57 | // IsEmpty reports whether there are no admin accounts. |
| 38 | 58 | func (s *AdminStore) IsEmpty() bool { |
| 39 | 59 | s.mu.RLock() |
| 40 | 60 | defer s.mu.RUnlock() |
| @@ -55,15 +75,15 @@ | ||
| 55 | 75 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
| 56 | 76 | if err != nil { |
| 57 | 77 | return fmt.Errorf("admin: hash password: %w", err) |
| 58 | 78 | } |
| 59 | 79 | |
| 60 | - s.data = append(s.data, Admin{ | |
| 61 | - Username: username, | |
| 62 | - Hash: hash, | |
| 63 | - Created: time.Now().UTC(), | |
| 64 | - }) | |
| 80 | + a := Admin{Username: username, Hash: hash, Created: time.Now().UTC()} | |
| 81 | + s.data = append(s.data, a) | |
| 82 | + if s.db != nil { | |
| 83 | + return s.db.AdminUpsert(&store.AdminRow{Username: a.Username, Hash: a.Hash, CreatedAt: a.Created}) | |
| 84 | + } | |
| 65 | 85 | return s.save() |
| 66 | 86 | } |
| 67 | 87 | |
| 68 | 88 | // SetPassword updates the password for an existing admin. |
| 69 | 89 | func (s *AdminStore) SetPassword(username, password string) error { |
| @@ -75,10 +95,13 @@ | ||
| 75 | 95 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
| 76 | 96 | if err != nil { |
| 77 | 97 | return fmt.Errorf("admin: hash password: %w", err) |
| 78 | 98 | } |
| 79 | 99 | s.data[i].Hash = hash |
| 100 | + if s.db != nil { | |
| 101 | + return s.db.AdminUpsert(&store.AdminRow{Username: a.Username, Hash: hash, CreatedAt: a.Created}) | |
| 102 | + } | |
| 80 | 103 | return s.save() |
| 81 | 104 | } |
| 82 | 105 | } |
| 83 | 106 | return fmt.Errorf("admin %q not found", username) |
| 84 | 107 | } |
| @@ -89,10 +112,13 @@ | ||
| 89 | 112 | defer s.mu.Unlock() |
| 90 | 113 | |
| 91 | 114 | for i, a := range s.data { |
| 92 | 115 | if a.Username == username { |
| 93 | 116 | s.data = append(s.data[:i], s.data[i+1:]...) |
| 117 | + if s.db != nil { | |
| 118 | + return s.db.AdminDelete(username) | |
| 119 | + } | |
| 94 | 120 | return s.save() |
| 95 | 121 | } |
| 96 | 122 | } |
| 97 | 123 | return fmt.Errorf("admin %q not found", username) |
| 98 | 124 | } |
| 99 | 125 |
| --- internal/auth/admin.go | |
| +++ internal/auth/admin.go | |
| @@ -7,24 +7,27 @@ | |
| 7 | "os" |
| 8 | "sync" |
| 9 | "time" |
| 10 | |
| 11 | "golang.org/x/crypto/bcrypt" |
| 12 | ) |
| 13 | |
| 14 | // Admin is a single admin account record. |
| 15 | type Admin struct { |
| 16 | Username string `json:"username"` |
| 17 | Hash []byte `json:"hash"` |
| 18 | Created time.Time `json:"created"` |
| 19 | } |
| 20 | |
| 21 | // AdminStore persists admin accounts to a JSON file. |
| 22 | type AdminStore struct { |
| 23 | mu sync.RWMutex |
| 24 | path string |
| 25 | data []Admin |
| 26 | } |
| 27 | |
| 28 | // NewAdminStore loads (or creates) the admin store at the given path. |
| 29 | func NewAdminStore(path string) (*AdminStore, error) { |
| 30 | s := &AdminStore{path: path} |
| @@ -31,10 +34,27 @@ | |
| 31 | if err := s.load(); err != nil { |
| 32 | return nil, err |
| 33 | } |
| 34 | return s, nil |
| 35 | } |
| 36 | |
| 37 | // IsEmpty reports whether there are no admin accounts. |
| 38 | func (s *AdminStore) IsEmpty() bool { |
| 39 | s.mu.RLock() |
| 40 | defer s.mu.RUnlock() |
| @@ -55,15 +75,15 @@ | |
| 55 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
| 56 | if err != nil { |
| 57 | return fmt.Errorf("admin: hash password: %w", err) |
| 58 | } |
| 59 | |
| 60 | s.data = append(s.data, Admin{ |
| 61 | Username: username, |
| 62 | Hash: hash, |
| 63 | Created: time.Now().UTC(), |
| 64 | }) |
| 65 | return s.save() |
| 66 | } |
| 67 | |
| 68 | // SetPassword updates the password for an existing admin. |
| 69 | func (s *AdminStore) SetPassword(username, password string) error { |
| @@ -75,10 +95,13 @@ | |
| 75 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
| 76 | if err != nil { |
| 77 | return fmt.Errorf("admin: hash password: %w", err) |
| 78 | } |
| 79 | s.data[i].Hash = hash |
| 80 | return s.save() |
| 81 | } |
| 82 | } |
| 83 | return fmt.Errorf("admin %q not found", username) |
| 84 | } |
| @@ -89,10 +112,13 @@ | |
| 89 | defer s.mu.Unlock() |
| 90 | |
| 91 | for i, a := range s.data { |
| 92 | if a.Username == username { |
| 93 | s.data = append(s.data[:i], s.data[i+1:]...) |
| 94 | return s.save() |
| 95 | } |
| 96 | } |
| 97 | return fmt.Errorf("admin %q not found", username) |
| 98 | } |
| 99 |
| --- internal/auth/admin.go | |
| +++ internal/auth/admin.go | |
| @@ -7,24 +7,27 @@ | |
| 7 | "os" |
| 8 | "sync" |
| 9 | "time" |
| 10 | |
| 11 | "golang.org/x/crypto/bcrypt" |
| 12 | |
| 13 | "github.com/conflicthq/scuttlebot/internal/store" |
| 14 | ) |
| 15 | |
| 16 | // Admin is a single admin account record. |
| 17 | type Admin struct { |
| 18 | Username string `json:"username"` |
| 19 | Hash []byte `json:"hash"` |
| 20 | Created time.Time `json:"created"` |
| 21 | } |
| 22 | |
| 23 | // AdminStore persists admin accounts to a JSON file or database. |
| 24 | type AdminStore struct { |
| 25 | mu sync.RWMutex |
| 26 | path string |
| 27 | data []Admin |
| 28 | db *store.Store // when non-nil, supersedes path |
| 29 | } |
| 30 | |
| 31 | // NewAdminStore loads (or creates) the admin store at the given path. |
| 32 | func NewAdminStore(path string) (*AdminStore, error) { |
| 33 | s := &AdminStore{path: path} |
| @@ -31,10 +34,27 @@ | |
| 34 | if err := s.load(); err != nil { |
| 35 | return nil, err |
| 36 | } |
| 37 | return s, nil |
| 38 | } |
| 39 | |
| 40 | // SetStore switches the admin store to database-backed persistence. All current |
| 41 | // in-memory state is replaced with rows loaded from the store. |
| 42 | func (s *AdminStore) SetStore(db *store.Store) error { |
| 43 | rows, err := db.AdminList() |
| 44 | if err != nil { |
| 45 | return fmt.Errorf("admin store: load from db: %w", err) |
| 46 | } |
| 47 | s.mu.Lock() |
| 48 | defer s.mu.Unlock() |
| 49 | s.db = db |
| 50 | s.data = make([]Admin, len(rows)) |
| 51 | for i, r := range rows { |
| 52 | s.data[i] = Admin{Username: r.Username, Hash: r.Hash, Created: r.CreatedAt} |
| 53 | } |
| 54 | return nil |
| 55 | } |
| 56 | |
| 57 | // IsEmpty reports whether there are no admin accounts. |
| 58 | func (s *AdminStore) IsEmpty() bool { |
| 59 | s.mu.RLock() |
| 60 | defer s.mu.RUnlock() |
| @@ -55,15 +75,15 @@ | |
| 75 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
| 76 | if err != nil { |
| 77 | return fmt.Errorf("admin: hash password: %w", err) |
| 78 | } |
| 79 | |
| 80 | a := Admin{Username: username, Hash: hash, Created: time.Now().UTC()} |
| 81 | s.data = append(s.data, a) |
| 82 | if s.db != nil { |
| 83 | return s.db.AdminUpsert(&store.AdminRow{Username: a.Username, Hash: a.Hash, CreatedAt: a.Created}) |
| 84 | } |
| 85 | return s.save() |
| 86 | } |
| 87 | |
| 88 | // SetPassword updates the password for an existing admin. |
| 89 | func (s *AdminStore) SetPassword(username, password string) error { |
| @@ -75,10 +95,13 @@ | |
| 95 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
| 96 | if err != nil { |
| 97 | return fmt.Errorf("admin: hash password: %w", err) |
| 98 | } |
| 99 | s.data[i].Hash = hash |
| 100 | if s.db != nil { |
| 101 | return s.db.AdminUpsert(&store.AdminRow{Username: a.Username, Hash: hash, CreatedAt: a.Created}) |
| 102 | } |
| 103 | return s.save() |
| 104 | } |
| 105 | } |
| 106 | return fmt.Errorf("admin %q not found", username) |
| 107 | } |
| @@ -89,10 +112,13 @@ | |
| 112 | defer s.mu.Unlock() |
| 113 | |
| 114 | for i, a := range s.data { |
| 115 | if a.Username == username { |
| 116 | s.data = append(s.data[:i], s.data[i+1:]...) |
| 117 | if s.db != nil { |
| 118 | return s.db.AdminDelete(username) |
| 119 | } |
| 120 | return s.save() |
| 121 | } |
| 122 | } |
| 123 | return fmt.Errorf("admin %q not found", username) |
| 124 | } |
| 125 |
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -154,19 +154,31 @@ | ||
| 154 | 154 | // ServerName is the IRC server hostname (e.g. "irc.example.com"). |
| 155 | 155 | ServerName string `yaml:"server_name"` |
| 156 | 156 | |
| 157 | 157 | // IRCAddr is the address Ergo listens for IRC connections on. |
| 158 | 158 | // Default: "127.0.0.1:6667" (loopback plaintext for private networks). |
| 159 | + // Set to ":6667" or ":6697" to accept connections from outside the host. | |
| 159 | 160 | IRCAddr string `yaml:"irc_addr"` |
| 160 | 161 | |
| 161 | 162 | // APIAddr is the address of Ergo's HTTP management API. |
| 162 | 163 | // Default: "127.0.0.1:8089" (loopback only). |
| 163 | 164 | APIAddr string `yaml:"api_addr"` |
| 164 | 165 | |
| 165 | 166 | // APIToken is the bearer token for Ergo's HTTP API. |
| 166 | 167 | // scuttlebot generates this on first start and stores it. |
| 167 | 168 | APIToken string `yaml:"api_token"` |
| 169 | + | |
| 170 | + // RequireSASL enforces SASL authentication for all IRC connections. | |
| 171 | + // When true, only agents and users with registered NickServ accounts | |
| 172 | + // can connect. Recommended for public deployments. | |
| 173 | + // Default: false | |
| 174 | + RequireSASL bool `yaml:"require_sasl"` | |
| 175 | + | |
| 176 | + // DefaultChannelModes sets channel modes applied when a new channel is | |
| 177 | + // created. Common values: "+n" (no external messages), "+Rn" (registered | |
| 178 | + // users only). Default: "+n" | |
| 179 | + DefaultChannelModes string `yaml:"default_channel_modes"` | |
| 168 | 180 | |
| 169 | 181 | // History configures persistent message history storage. |
| 170 | 182 | History HistoryConfig `yaml:"history"` |
| 171 | 183 | } |
| 172 | 184 | |
| @@ -375,14 +387,17 @@ | ||
| 375 | 387 | } |
| 376 | 388 | if c.Datastore.DSN == "" { |
| 377 | 389 | c.Datastore.DSN = "./data/scuttlebot.db" |
| 378 | 390 | } |
| 379 | 391 | if c.APIAddr == "" { |
| 380 | - c.APIAddr = ":8080" | |
| 392 | + c.APIAddr = "127.0.0.1:8080" | |
| 381 | 393 | } |
| 382 | 394 | if c.MCPAddr == "" { |
| 383 | - c.MCPAddr = ":8081" | |
| 395 | + c.MCPAddr = "127.0.0.1:8081" | |
| 396 | + } | |
| 397 | + if c.Ergo.DefaultChannelModes == "" { | |
| 398 | + c.Ergo.DefaultChannelModes = "+n" | |
| 384 | 399 | } |
| 385 | 400 | if !c.Bridge.Enabled && c.Bridge.Nick == "" { |
| 386 | 401 | c.Bridge.Enabled = true // enabled by default |
| 387 | 402 | } |
| 388 | 403 | if c.TLS.Domain != "" && !c.TLS.AllowInsecure { |
| 389 | 404 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -154,19 +154,31 @@ | |
| 154 | // ServerName is the IRC server hostname (e.g. "irc.example.com"). |
| 155 | ServerName string `yaml:"server_name"` |
| 156 | |
| 157 | // IRCAddr is the address Ergo listens for IRC connections on. |
| 158 | // Default: "127.0.0.1:6667" (loopback plaintext for private networks). |
| 159 | IRCAddr string `yaml:"irc_addr"` |
| 160 | |
| 161 | // APIAddr is the address of Ergo's HTTP management API. |
| 162 | // Default: "127.0.0.1:8089" (loopback only). |
| 163 | APIAddr string `yaml:"api_addr"` |
| 164 | |
| 165 | // APIToken is the bearer token for Ergo's HTTP API. |
| 166 | // scuttlebot generates this on first start and stores it. |
| 167 | APIToken string `yaml:"api_token"` |
| 168 | |
| 169 | // History configures persistent message history storage. |
| 170 | History HistoryConfig `yaml:"history"` |
| 171 | } |
| 172 | |
| @@ -375,14 +387,17 @@ | |
| 375 | } |
| 376 | if c.Datastore.DSN == "" { |
| 377 | c.Datastore.DSN = "./data/scuttlebot.db" |
| 378 | } |
| 379 | if c.APIAddr == "" { |
| 380 | c.APIAddr = ":8080" |
| 381 | } |
| 382 | if c.MCPAddr == "" { |
| 383 | c.MCPAddr = ":8081" |
| 384 | } |
| 385 | if !c.Bridge.Enabled && c.Bridge.Nick == "" { |
| 386 | c.Bridge.Enabled = true // enabled by default |
| 387 | } |
| 388 | if c.TLS.Domain != "" && !c.TLS.AllowInsecure { |
| 389 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -154,19 +154,31 @@ | |
| 154 | // ServerName is the IRC server hostname (e.g. "irc.example.com"). |
| 155 | ServerName string `yaml:"server_name"` |
| 156 | |
| 157 | // IRCAddr is the address Ergo listens for IRC connections on. |
| 158 | // Default: "127.0.0.1:6667" (loopback plaintext for private networks). |
| 159 | // Set to ":6667" or ":6697" to accept connections from outside the host. |
| 160 | IRCAddr string `yaml:"irc_addr"` |
| 161 | |
| 162 | // APIAddr is the address of Ergo's HTTP management API. |
| 163 | // Default: "127.0.0.1:8089" (loopback only). |
| 164 | APIAddr string `yaml:"api_addr"` |
| 165 | |
| 166 | // APIToken is the bearer token for Ergo's HTTP API. |
| 167 | // scuttlebot generates this on first start and stores it. |
| 168 | APIToken string `yaml:"api_token"` |
| 169 | |
| 170 | // RequireSASL enforces SASL authentication for all IRC connections. |
| 171 | // When true, only agents and users with registered NickServ accounts |
| 172 | // can connect. Recommended for public deployments. |
| 173 | // Default: false |
| 174 | RequireSASL bool `yaml:"require_sasl"` |
| 175 | |
| 176 | // DefaultChannelModes sets channel modes applied when a new channel is |
| 177 | // created. Common values: "+n" (no external messages), "+Rn" (registered |
| 178 | // users only). Default: "+n" |
| 179 | DefaultChannelModes string `yaml:"default_channel_modes"` |
| 180 | |
| 181 | // History configures persistent message history storage. |
| 182 | History HistoryConfig `yaml:"history"` |
| 183 | } |
| 184 | |
| @@ -375,14 +387,17 @@ | |
| 387 | } |
| 388 | if c.Datastore.DSN == "" { |
| 389 | c.Datastore.DSN = "./data/scuttlebot.db" |
| 390 | } |
| 391 | if c.APIAddr == "" { |
| 392 | c.APIAddr = "127.0.0.1:8080" |
| 393 | } |
| 394 | if c.MCPAddr == "" { |
| 395 | c.MCPAddr = "127.0.0.1:8081" |
| 396 | } |
| 397 | if c.Ergo.DefaultChannelModes == "" { |
| 398 | c.Ergo.DefaultChannelModes = "+n" |
| 399 | } |
| 400 | if !c.Bridge.Enabled && c.Bridge.Nick == "" { |
| 401 | c.Bridge.Enabled = true // enabled by default |
| 402 | } |
| 403 | if c.TLS.Domain != "" && !c.TLS.AllowInsecure { |
| 404 |
| --- internal/registry/registry.go | ||
| +++ internal/registry/registry.go | ||
| @@ -14,10 +14,12 @@ | ||
| 14 | 14 | "fmt" |
| 15 | 15 | "os" |
| 16 | 16 | "strings" |
| 17 | 17 | "sync" |
| 18 | 18 | "time" |
| 19 | + | |
| 20 | + "github.com/conflicthq/scuttlebot/internal/store" | |
| 19 | 21 | ) |
| 20 | 22 | |
| 21 | 23 | // AgentType describes an agent's role and authority level. |
| 22 | 24 | type AgentType string |
| 23 | 25 | |
| @@ -72,11 +74,12 @@ | ||
| 72 | 74 | type Registry struct { |
| 73 | 75 | mu sync.RWMutex |
| 74 | 76 | agents map[string]*Agent // keyed by nick |
| 75 | 77 | provisioner AccountProvisioner |
| 76 | 78 | signingKey []byte |
| 77 | - dataPath string // path to persist agents JSON; empty = no persistence | |
| 79 | + dataPath string // path to persist agents JSON; empty = no persistence | |
| 80 | + db *store.Store // when non-nil, supersedes dataPath | |
| 78 | 81 | } |
| 79 | 82 | |
| 80 | 83 | // New creates a new Registry with the given provisioner and HMAC signing key. |
| 81 | 84 | // Call SetDataPath to enable persistence before registering any agents. |
| 82 | 85 | func New(provisioner AccountProvisioner, signingKey []byte) *Registry { |
| @@ -85,18 +88,78 @@ | ||
| 85 | 88 | provisioner: provisioner, |
| 86 | 89 | signingKey: signingKey, |
| 87 | 90 | } |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | -// SetDataPath enables persistence. The registry is loaded from path immediately | |
| 91 | -// (non-fatal if the file doesn't exist yet) and saved there after every mutation. | |
| 93 | +// SetDataPath enables file-based persistence. The registry is loaded from path | |
| 94 | +// immediately (non-fatal if the file doesn't exist yet) and saved there after | |
| 95 | +// every mutation. Mutually exclusive with SetStore. | |
| 92 | 96 | func (r *Registry) SetDataPath(path string) error { |
| 93 | 97 | r.mu.Lock() |
| 94 | 98 | defer r.mu.Unlock() |
| 95 | 99 | r.dataPath = path |
| 96 | 100 | return r.load() |
| 97 | 101 | } |
| 102 | + | |
| 103 | +// SetStore switches the registry to database-backed persistence. All current | |
| 104 | +// in-memory state is replaced with rows loaded from the store. Mutually | |
| 105 | +// exclusive with SetDataPath. | |
| 106 | +func (r *Registry) SetStore(db *store.Store) error { | |
| 107 | + rows, err := db.AgentList() | |
| 108 | + if err != nil { | |
| 109 | + return fmt.Errorf("registry: load from store: %w", err) | |
| 110 | + } | |
| 111 | + r.mu.Lock() | |
| 112 | + defer r.mu.Unlock() | |
| 113 | + r.db = db | |
| 114 | + r.dataPath = "" // DB takes over | |
| 115 | + r.agents = make(map[string]*Agent, len(rows)) | |
| 116 | + for _, row := range rows { | |
| 117 | + var cfg EngagementConfig | |
| 118 | + if err := json.Unmarshal(row.Config, &cfg); err != nil { | |
| 119 | + return fmt.Errorf("registry: decode agent %s config: %w", row.Nick, err) | |
| 120 | + } | |
| 121 | + a := &Agent{ | |
| 122 | + Nick: row.Nick, | |
| 123 | + Type: AgentType(row.Type), | |
| 124 | + Channels: cfg.Channels, | |
| 125 | + Permissions: cfg.Permissions, | |
| 126 | + Config: cfg, | |
| 127 | + CreatedAt: row.CreatedAt, | |
| 128 | + Revoked: row.Revoked, | |
| 129 | + } | |
| 130 | + r.agents[a.Nick] = a | |
| 131 | + } | |
| 132 | + return nil | |
| 133 | +} | |
| 134 | + | |
| 135 | +// saveOne persists a single agent. Uses the DB when available, otherwise | |
| 136 | +// falls back to a full file rewrite. | |
| 137 | +func (r *Registry) saveOne(a *Agent) { | |
| 138 | + if r.db != nil { | |
| 139 | + cfg, _ := json.Marshal(a.Config) | |
| 140 | + _ = r.db.AgentUpsert(&store.AgentRow{ | |
| 141 | + Nick: a.Nick, | |
| 142 | + Type: string(a.Type), | |
| 143 | + Config: cfg, | |
| 144 | + CreatedAt: a.CreatedAt, | |
| 145 | + Revoked: a.Revoked, | |
| 146 | + }) | |
| 147 | + return | |
| 148 | + } | |
| 149 | + r.save() | |
| 150 | +} | |
| 151 | + | |
| 152 | +// deleteOne removes a single agent from the store. Uses the DB when available, | |
| 153 | +// otherwise falls back to a full file rewrite (agent already removed from map). | |
| 154 | +func (r *Registry) deleteOne(nick string) { | |
| 155 | + if r.db != nil { | |
| 156 | + _ = r.db.AgentDelete(nick) | |
| 157 | + return | |
| 158 | + } | |
| 159 | + r.save() | |
| 160 | +} | |
| 98 | 161 | |
| 99 | 162 | func (r *Registry) load() error { |
| 100 | 163 | data, err := os.ReadFile(r.dataPath) |
| 101 | 164 | if os.IsNotExist(err) { |
| 102 | 165 | return nil |
| @@ -167,11 +230,11 @@ | ||
| 167 | 230 | Permissions: cfg.Permissions, |
| 168 | 231 | Config: cfg, |
| 169 | 232 | CreatedAt: time.Now(), |
| 170 | 233 | } |
| 171 | 234 | r.agents[nick] = agent |
| 172 | - r.save() | |
| 235 | + r.saveOne(agent) | |
| 173 | 236 | |
| 174 | 237 | payload, err := r.signPayload(agent) |
| 175 | 238 | if err != nil { |
| 176 | 239 | return nil, nil, fmt.Errorf("registry: sign payload: %w", err) |
| 177 | 240 | } |
| @@ -202,11 +265,11 @@ | ||
| 202 | 265 | Permissions: cfg.Permissions, |
| 203 | 266 | Config: cfg, |
| 204 | 267 | CreatedAt: time.Now(), |
| 205 | 268 | } |
| 206 | 269 | r.agents[nick] = agent |
| 207 | - r.save() | |
| 270 | + r.saveOne(agent) | |
| 208 | 271 | |
| 209 | 272 | return r.signPayload(agent) |
| 210 | 273 | } |
| 211 | 274 | |
| 212 | 275 | // Rotate generates a new passphrase for an agent and updates Ergo. |
| @@ -225,10 +288,12 @@ | ||
| 225 | 288 | |
| 226 | 289 | if err := r.provisioner.ChangePassword(nick, passphrase); err != nil { |
| 227 | 290 | return nil, fmt.Errorf("registry: rotate credentials: %w", err) |
| 228 | 291 | } |
| 229 | 292 | |
| 293 | + // Rotation doesn't change stored agent data, but bump a file save for | |
| 294 | + // consistency; DB backends are unaffected since nothing persisted changed. | |
| 230 | 295 | r.save() |
| 231 | 296 | return &Credentials{Nick: nick, Passphrase: passphrase}, nil |
| 232 | 297 | } |
| 233 | 298 | |
| 234 | 299 | // Revoke locks an agent out by rotating to an unguessable passphrase and |
| @@ -250,11 +315,11 @@ | ||
| 250 | 315 | if err := r.provisioner.ChangePassword(nick, lockout); err != nil { |
| 251 | 316 | return fmt.Errorf("registry: revoke credentials: %w", err) |
| 252 | 317 | } |
| 253 | 318 | |
| 254 | 319 | agent.Revoked = true |
| 255 | - r.save() | |
| 320 | + r.saveOne(agent) | |
| 256 | 321 | return nil |
| 257 | 322 | } |
| 258 | 323 | |
| 259 | 324 | // Delete fully removes an agent from the registry. The Ergo NickServ account |
| 260 | 325 | // is locked out first (password rotated to an unguessable value) so the agent |
| @@ -278,11 +343,11 @@ | ||
| 278 | 343 | return fmt.Errorf("registry: delete lockout: %w", err) |
| 279 | 344 | } |
| 280 | 345 | } |
| 281 | 346 | |
| 282 | 347 | delete(r.agents, nick) |
| 283 | - r.save() | |
| 348 | + r.deleteOne(nick) | |
| 284 | 349 | return nil |
| 285 | 350 | } |
| 286 | 351 | |
| 287 | 352 | // UpdateChannels replaces the channel list for an active agent. |
| 288 | 353 | // Used by relay brokers to sync runtime /join and /part changes back to the registry. |
| @@ -293,11 +358,11 @@ | ||
| 293 | 358 | if err != nil { |
| 294 | 359 | return err |
| 295 | 360 | } |
| 296 | 361 | agent.Channels = append([]string(nil), channels...) |
| 297 | 362 | agent.Config.Channels = append([]string(nil), channels...) |
| 298 | - r.save() | |
| 363 | + r.saveOne(agent) | |
| 299 | 364 | return nil |
| 300 | 365 | } |
| 301 | 366 | |
| 302 | 367 | // Get returns the agent with the given nick. |
| 303 | 368 | func (r *Registry) Get(nick string) (*Agent, error) { |
| 304 | 369 | |
| 305 | 370 | ADDED internal/store/store.go |
| 306 | 371 | ADDED internal/store/store_test.go |
| --- internal/registry/registry.go | |
| +++ internal/registry/registry.go | |
| @@ -14,10 +14,12 @@ | |
| 14 | "fmt" |
| 15 | "os" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "time" |
| 19 | ) |
| 20 | |
| 21 | // AgentType describes an agent's role and authority level. |
| 22 | type AgentType string |
| 23 | |
| @@ -72,11 +74,12 @@ | |
| 72 | type Registry struct { |
| 73 | mu sync.RWMutex |
| 74 | agents map[string]*Agent // keyed by nick |
| 75 | provisioner AccountProvisioner |
| 76 | signingKey []byte |
| 77 | dataPath string // path to persist agents JSON; empty = no persistence |
| 78 | } |
| 79 | |
| 80 | // New creates a new Registry with the given provisioner and HMAC signing key. |
| 81 | // Call SetDataPath to enable persistence before registering any agents. |
| 82 | func New(provisioner AccountProvisioner, signingKey []byte) *Registry { |
| @@ -85,18 +88,78 @@ | |
| 85 | provisioner: provisioner, |
| 86 | signingKey: signingKey, |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // SetDataPath enables persistence. The registry is loaded from path immediately |
| 91 | // (non-fatal if the file doesn't exist yet) and saved there after every mutation. |
| 92 | func (r *Registry) SetDataPath(path string) error { |
| 93 | r.mu.Lock() |
| 94 | defer r.mu.Unlock() |
| 95 | r.dataPath = path |
| 96 | return r.load() |
| 97 | } |
| 98 | |
| 99 | func (r *Registry) load() error { |
| 100 | data, err := os.ReadFile(r.dataPath) |
| 101 | if os.IsNotExist(err) { |
| 102 | return nil |
| @@ -167,11 +230,11 @@ | |
| 167 | Permissions: cfg.Permissions, |
| 168 | Config: cfg, |
| 169 | CreatedAt: time.Now(), |
| 170 | } |
| 171 | r.agents[nick] = agent |
| 172 | r.save() |
| 173 | |
| 174 | payload, err := r.signPayload(agent) |
| 175 | if err != nil { |
| 176 | return nil, nil, fmt.Errorf("registry: sign payload: %w", err) |
| 177 | } |
| @@ -202,11 +265,11 @@ | |
| 202 | Permissions: cfg.Permissions, |
| 203 | Config: cfg, |
| 204 | CreatedAt: time.Now(), |
| 205 | } |
| 206 | r.agents[nick] = agent |
| 207 | r.save() |
| 208 | |
| 209 | return r.signPayload(agent) |
| 210 | } |
| 211 | |
| 212 | // Rotate generates a new passphrase for an agent and updates Ergo. |
| @@ -225,10 +288,12 @@ | |
| 225 | |
| 226 | if err := r.provisioner.ChangePassword(nick, passphrase); err != nil { |
| 227 | return nil, fmt.Errorf("registry: rotate credentials: %w", err) |
| 228 | } |
| 229 | |
| 230 | r.save() |
| 231 | return &Credentials{Nick: nick, Passphrase: passphrase}, nil |
| 232 | } |
| 233 | |
| 234 | // Revoke locks an agent out by rotating to an unguessable passphrase and |
| @@ -250,11 +315,11 @@ | |
| 250 | if err := r.provisioner.ChangePassword(nick, lockout); err != nil { |
| 251 | return fmt.Errorf("registry: revoke credentials: %w", err) |
| 252 | } |
| 253 | |
| 254 | agent.Revoked = true |
| 255 | r.save() |
| 256 | return nil |
| 257 | } |
| 258 | |
| 259 | // Delete fully removes an agent from the registry. The Ergo NickServ account |
| 260 | // is locked out first (password rotated to an unguessable value) so the agent |
| @@ -278,11 +343,11 @@ | |
| 278 | return fmt.Errorf("registry: delete lockout: %w", err) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | delete(r.agents, nick) |
| 283 | r.save() |
| 284 | return nil |
| 285 | } |
| 286 | |
| 287 | // UpdateChannels replaces the channel list for an active agent. |
| 288 | // Used by relay brokers to sync runtime /join and /part changes back to the registry. |
| @@ -293,11 +358,11 @@ | |
| 293 | if err != nil { |
| 294 | return err |
| 295 | } |
| 296 | agent.Channels = append([]string(nil), channels...) |
| 297 | agent.Config.Channels = append([]string(nil), channels...) |
| 298 | r.save() |
| 299 | return nil |
| 300 | } |
| 301 | |
| 302 | // Get returns the agent with the given nick. |
| 303 | func (r *Registry) Get(nick string) (*Agent, error) { |
| 304 | |
| 305 | DDED internal/store/store.go |
| 306 | DDED internal/store/store_test.go |
| --- internal/registry/registry.go | |
| +++ internal/registry/registry.go | |
| @@ -14,10 +14,12 @@ | |
| 14 | "fmt" |
| 15 | "os" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/conflicthq/scuttlebot/internal/store" |
| 21 | ) |
| 22 | |
| 23 | // AgentType describes an agent's role and authority level. |
| 24 | type AgentType string |
| 25 | |
| @@ -72,11 +74,12 @@ | |
| 74 | type Registry struct { |
| 75 | mu sync.RWMutex |
| 76 | agents map[string]*Agent // keyed by nick |
| 77 | provisioner AccountProvisioner |
| 78 | signingKey []byte |
| 79 | dataPath string // path to persist agents JSON; empty = no persistence |
| 80 | db *store.Store // when non-nil, supersedes dataPath |
| 81 | } |
| 82 | |
| 83 | // New creates a new Registry with the given provisioner and HMAC signing key. |
| 84 | // Call SetDataPath to enable persistence before registering any agents. |
| 85 | func New(provisioner AccountProvisioner, signingKey []byte) *Registry { |
| @@ -85,18 +88,78 @@ | |
| 88 | provisioner: provisioner, |
| 89 | signingKey: signingKey, |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // SetDataPath enables file-based persistence. The registry is loaded from path |
| 94 | // immediately (non-fatal if the file doesn't exist yet) and saved there after |
| 95 | // every mutation. Mutually exclusive with SetStore. |
| 96 | func (r *Registry) SetDataPath(path string) error { |
| 97 | r.mu.Lock() |
| 98 | defer r.mu.Unlock() |
| 99 | r.dataPath = path |
| 100 | return r.load() |
| 101 | } |
| 102 | |
| 103 | // SetStore switches the registry to database-backed persistence. All current |
| 104 | // in-memory state is replaced with rows loaded from the store. Mutually |
| 105 | // exclusive with SetDataPath. |
| 106 | func (r *Registry) SetStore(db *store.Store) error { |
| 107 | rows, err := db.AgentList() |
| 108 | if err != nil { |
| 109 | return fmt.Errorf("registry: load from store: %w", err) |
| 110 | } |
| 111 | r.mu.Lock() |
| 112 | defer r.mu.Unlock() |
| 113 | r.db = db |
| 114 | r.dataPath = "" // DB takes over |
| 115 | r.agents = make(map[string]*Agent, len(rows)) |
| 116 | for _, row := range rows { |
| 117 | var cfg EngagementConfig |
| 118 | if err := json.Unmarshal(row.Config, &cfg); err != nil { |
| 119 | return fmt.Errorf("registry: decode agent %s config: %w", row.Nick, err) |
| 120 | } |
| 121 | a := &Agent{ |
| 122 | Nick: row.Nick, |
| 123 | Type: AgentType(row.Type), |
| 124 | Channels: cfg.Channels, |
| 125 | Permissions: cfg.Permissions, |
| 126 | Config: cfg, |
| 127 | CreatedAt: row.CreatedAt, |
| 128 | Revoked: row.Revoked, |
| 129 | } |
| 130 | r.agents[a.Nick] = a |
| 131 | } |
| 132 | return nil |
| 133 | } |
| 134 | |
| 135 | // saveOne persists a single agent. Uses the DB when available, otherwise |
| 136 | // falls back to a full file rewrite. |
| 137 | func (r *Registry) saveOne(a *Agent) { |
| 138 | if r.db != nil { |
| 139 | cfg, _ := json.Marshal(a.Config) |
| 140 | _ = r.db.AgentUpsert(&store.AgentRow{ |
| 141 | Nick: a.Nick, |
| 142 | Type: string(a.Type), |
| 143 | Config: cfg, |
| 144 | CreatedAt: a.CreatedAt, |
| 145 | Revoked: a.Revoked, |
| 146 | }) |
| 147 | return |
| 148 | } |
| 149 | r.save() |
| 150 | } |
| 151 | |
| 152 | // deleteOne removes a single agent from the store. Uses the DB when available, |
| 153 | // otherwise falls back to a full file rewrite (agent already removed from map). |
| 154 | func (r *Registry) deleteOne(nick string) { |
| 155 | if r.db != nil { |
| 156 | _ = r.db.AgentDelete(nick) |
| 157 | return |
| 158 | } |
| 159 | r.save() |
| 160 | } |
| 161 | |
| 162 | func (r *Registry) load() error { |
| 163 | data, err := os.ReadFile(r.dataPath) |
| 164 | if os.IsNotExist(err) { |
| 165 | return nil |
| @@ -167,11 +230,11 @@ | |
| 230 | Permissions: cfg.Permissions, |
| 231 | Config: cfg, |
| 232 | CreatedAt: time.Now(), |
| 233 | } |
| 234 | r.agents[nick] = agent |
| 235 | r.saveOne(agent) |
| 236 | |
| 237 | payload, err := r.signPayload(agent) |
| 238 | if err != nil { |
| 239 | return nil, nil, fmt.Errorf("registry: sign payload: %w", err) |
| 240 | } |
| @@ -202,11 +265,11 @@ | |
| 265 | Permissions: cfg.Permissions, |
| 266 | Config: cfg, |
| 267 | CreatedAt: time.Now(), |
| 268 | } |
| 269 | r.agents[nick] = agent |
| 270 | r.saveOne(agent) |
| 271 | |
| 272 | return r.signPayload(agent) |
| 273 | } |
| 274 | |
| 275 | // Rotate generates a new passphrase for an agent and updates Ergo. |
| @@ -225,10 +288,12 @@ | |
| 288 | |
| 289 | if err := r.provisioner.ChangePassword(nick, passphrase); err != nil { |
| 290 | return nil, fmt.Errorf("registry: rotate credentials: %w", err) |
| 291 | } |
| 292 | |
| 293 | // Rotation doesn't change stored agent data, but bump a file save for |
| 294 | // consistency; DB backends are unaffected since nothing persisted changed. |
| 295 | r.save() |
| 296 | return &Credentials{Nick: nick, Passphrase: passphrase}, nil |
| 297 | } |
| 298 | |
| 299 | // Revoke locks an agent out by rotating to an unguessable passphrase and |
| @@ -250,11 +315,11 @@ | |
| 315 | if err := r.provisioner.ChangePassword(nick, lockout); err != nil { |
| 316 | return fmt.Errorf("registry: revoke credentials: %w", err) |
| 317 | } |
| 318 | |
| 319 | agent.Revoked = true |
| 320 | r.saveOne(agent) |
| 321 | return nil |
| 322 | } |
| 323 | |
| 324 | // Delete fully removes an agent from the registry. The Ergo NickServ account |
| 325 | // is locked out first (password rotated to an unguessable value) so the agent |
| @@ -278,11 +343,11 @@ | |
| 343 | return fmt.Errorf("registry: delete lockout: %w", err) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | delete(r.agents, nick) |
| 348 | r.deleteOne(nick) |
| 349 | return nil |
| 350 | } |
| 351 | |
| 352 | // UpdateChannels replaces the channel list for an active agent. |
| 353 | // Used by relay brokers to sync runtime /join and /part changes back to the registry. |
| @@ -293,11 +358,11 @@ | |
| 358 | if err != nil { |
| 359 | return err |
| 360 | } |
| 361 | agent.Channels = append([]string(nil), channels...) |
| 362 | agent.Config.Channels = append([]string(nil), channels...) |
| 363 | r.saveOne(agent) |
| 364 | return nil |
| 365 | } |
| 366 | |
| 367 | // Get returns the agent with the given nick. |
| 368 | func (r *Registry) Get(nick string) (*Agent, error) { |
| 369 | |
| 370 | DDED internal/store/store.go |
| 371 | DDED internal/store/store_test.go |
| --- a/internal/store/store.go | ||
| +++ b/internal/store/store.go | ||
| @@ -0,0 +1,117 @@ | ||
| 1 | +// Package store provides a thin database/sql wrapper for scuttlebot's | |
| 2 | +// persistent state: agent registry, admin accounts, and policies. | |
| 3 | +// Both SQLite and PostgreSQL are supported. | |
| 4 | +package store | |
| 5 | + | |
| 6 | +import ( | |
| 7 | + "database/sql" | |
| 8 | + "encoding/base64" | |
| 9 | + "fmt" | |
| 10 | + "strconv" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + _ "github.com/lib/pq" | |
| 14 | + _ "modernc.org/sqlite" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// AgentRow is the flat database representation of a registered agent. | |
| 18 | +type AgentRow struct { | |
| 19 | + Nick string | |
| 20 | + Type string | |
| 21 | + Config []byte // JSON-encoded EngagementConfig | |
| 22 | + CreatedAt time.Time | |
| 23 | + Revoked bool | |
| 24 | +bool | |
| 25 | + LastSeen *time.Time | |
| 26 | +} | |
| 27 | + | |
| 28 | +// AdminRow is the flat database representation of an admin account. | |
| 29 | +type AdminRow struct { | |
| 30 | + Username string | |
| 31 | + Hash []byte // bcrypt hash | |
| 32 | + CreatedAt time.Time | |
| 33 | +} | |
| 34 | + | |
| 35 | +// Store wraps a sql.DB with scuttlebot-specific CRUD operations. | |
| 36 | +type Store struct { | |
| 37 | + db *sql.DB | |
| 38 | + driver string | |
| 39 | +} | |
| 40 | + | |
| 41 | +// Open opens a database connection, runs schema migrations, and rturns a Store. | |
| 42 | +// driver must be "sqlite" or "postgres". dsn is the connection string. | |
| 43 | +func Open(driver, dsn string) (*Store, error) { | |
| 44 | + db, err := sql.Open(driver, dsn) | |
| 45 | + if err != nil { | |
| 46 | + return nil, fmt.Errorf("store: open %s: %w", driver, err) | |
| 47 | + } | |
| 48 | + if err := db.Ping(); err != nil { | |
| 49 | + db.Close() | |
| 50 | + return nil, fmt.Errorf("store: ping %s: %w", driver, err) | |
| 51 | + } | |
| 52 | + s := &Store{db: db, driver: driver} | |
| 53 | + if err := s.migrate(); err != nil { | |
| 54 | + db.Close() | |
| 55 | + return nil, fmt.Errorf("store: migrate: %w", err) | |
| 56 | + } | |
| 57 | + return s, nil | |
| 58 | +} | |
| 59 | + | |
| 60 | +// Close closes the underlying database connection. | |
| 61 | +func (s *Store) Close() error { return s.db.Close() } | |
| 62 | + | |
| 63 | +// ph returns the query placeholder for argument n (1-indexed). | |
| 64 | +// SQLite uses "?"; PostgreSQL uses "$1", "$2", … | |
| 65 | +func (s *Store) ph(n int) string { | |
| 66 | + if s.driver == "postgres" { | |
| 67 | + return "$" + strconv.Itoa(n) | |
| 68 | + } | |
| 69 | + return "?" | |
| 70 | +} | |
| 71 | + | |
| 72 | +func (s *Store) migrate() error { | |
| 73 | + stmts := []string{ | |
| 74 | + `CREATE TABLE IF NOT EXISTS agents ( | |
| 75 | + nick TEXT PRIMARY KEY, | |
| 76 | + type TEXT NOT NULL, | |
| 77 | + config TEXT NOT NULL, | |
| 78 | + created_at TEXT NOT NULL, | |
| 79 | + revoked INTEGER NOT NULL DEFAULT 0 | |
| 80 | + )`, | |
| 81 | + `CREATE TABLE IF NOT EXISTS admins ( | |
| 82 | + username TEXT PRIMARY KEY, | |
| 83 | + hash TEXT NOT NULL, | |
| 84 | + created_at TEXT NOT NULL | |
| 85 | + )`, | |
| 86 | + `CREATE TABLE IF NOT EXISTS policies ( | |
| 87 | + id | |
| 88 | + } | |
| 89 | + // Run base schema. | |
| 90 | + for _, stmt := range stmts { | |
| 91 | + if _, err := s.db.Exec(stmt); err != nireturn nil | |
| 92 | +} | |
| 93 | +rturns a Store. | |
| 94 | +// driver must be "sqlite" or "postgres". dsn is the connection string. | |
| 95 | +func Open(driver, dsn string) (*Store, error) { | |
| 96 | + db, err := sql.Op/ Package store provides a thin dvides a thin databasf("store: ping %s: %w", driver,river: driver} | |
| 97 | + if err := s.migrate(); err != nil { | |
| 98 | + db.Close() | |
| 99 | + return nil, fmt.Errorf("store: migrate: %w", err) | |
| 100 | + } | |
| 101 | + return s, nil | |
| 102 | +} | |
| 103 | + | |
| 104 | +// Close s.ph(4), s.ph(5es a thin database/sql wrapper for scuttlebot's | |
| 105 | +// persistent state: agent registry, admi// Package store providns ( | |
| 106 | + username TEXT PRIMARY KEY, | |
| 107 | + hash TEXT NOT NULL, | |
| 108 | + created_at TEXT NOT NULL | |
| 109 | + )`, | |
| 110 | + `CREATE TABLE IF NOT EXISTS policies ( | |
| 111 | + OT NULL | |
| 112 | + )`, | |
| 113 | + } | |
| 114 | + // Run base schema. | |
| 115 | + for _, stmt := range stmts { | |
| 116 | + if _, err := s.db.Exe; err != nil { | |
| 117 | + d |
| --- a/internal/store/store.go | |
| +++ b/internal/store/store.go | |
| @@ -0,0 +1,117 @@ | |
| --- a/internal/store/store.go | |
| +++ b/internal/store/store.go | |
| @@ -0,0 +1,117 @@ | |
| 1 | // Package store provides a thin database/sql wrapper for scuttlebot's |
| 2 | // persistent state: agent registry, admin accounts, and policies. |
| 3 | // Both SQLite and PostgreSQL are supported. |
| 4 | package store |
| 5 | |
| 6 | import ( |
| 7 | "database/sql" |
| 8 | "encoding/base64" |
| 9 | "fmt" |
| 10 | "strconv" |
| 11 | "time" |
| 12 | |
| 13 | _ "github.com/lib/pq" |
| 14 | _ "modernc.org/sqlite" |
| 15 | ) |
| 16 | |
| 17 | // AgentRow is the flat database representation of a registered agent. |
| 18 | type AgentRow struct { |
| 19 | Nick string |
| 20 | Type string |
| 21 | Config []byte // JSON-encoded EngagementConfig |
| 22 | CreatedAt time.Time |
| 23 | Revoked bool |
| 24 | bool |
| 25 | LastSeen *time.Time |
| 26 | } |
| 27 | |
| 28 | // AdminRow is the flat database representation of an admin account. |
| 29 | type AdminRow struct { |
| 30 | Username string |
| 31 | Hash []byte // bcrypt hash |
| 32 | CreatedAt time.Time |
| 33 | } |
| 34 | |
| 35 | // Store wraps a sql.DB with scuttlebot-specific CRUD operations. |
| 36 | type Store struct { |
| 37 | db *sql.DB |
| 38 | driver string |
| 39 | } |
| 40 | |
| 41 | // Open opens a database connection, runs schema migrations, and rturns a Store. |
| 42 | // driver must be "sqlite" or "postgres". dsn is the connection string. |
| 43 | func Open(driver, dsn string) (*Store, error) { |
| 44 | db, err := sql.Open(driver, dsn) |
| 45 | if err != nil { |
| 46 | return nil, fmt.Errorf("store: open %s: %w", driver, err) |
| 47 | } |
| 48 | if err := db.Ping(); err != nil { |
| 49 | db.Close() |
| 50 | return nil, fmt.Errorf("store: ping %s: %w", driver, err) |
| 51 | } |
| 52 | s := &Store{db: db, driver: driver} |
| 53 | if err := s.migrate(); err != nil { |
| 54 | db.Close() |
| 55 | return nil, fmt.Errorf("store: migrate: %w", err) |
| 56 | } |
| 57 | return s, nil |
| 58 | } |
| 59 | |
| 60 | // Close closes the underlying database connection. |
| 61 | func (s *Store) Close() error { return s.db.Close() } |
| 62 | |
| 63 | // ph returns the query placeholder for argument n (1-indexed). |
| 64 | // SQLite uses "?"; PostgreSQL uses "$1", "$2", … |
| 65 | func (s *Store) ph(n int) string { |
| 66 | if s.driver == "postgres" { |
| 67 | return "$" + strconv.Itoa(n) |
| 68 | } |
| 69 | return "?" |
| 70 | } |
| 71 | |
| 72 | func (s *Store) migrate() error { |
| 73 | stmts := []string{ |
| 74 | `CREATE TABLE IF NOT EXISTS agents ( |
| 75 | nick TEXT PRIMARY KEY, |
| 76 | type TEXT NOT NULL, |
| 77 | config TEXT NOT NULL, |
| 78 | created_at TEXT NOT NULL, |
| 79 | revoked INTEGER NOT NULL DEFAULT 0 |
| 80 | )`, |
| 81 | `CREATE TABLE IF NOT EXISTS admins ( |
| 82 | username TEXT PRIMARY KEY, |
| 83 | hash TEXT NOT NULL, |
| 84 | created_at TEXT NOT NULL |
| 85 | )`, |
| 86 | `CREATE TABLE IF NOT EXISTS policies ( |
| 87 | id |
| 88 | } |
| 89 | // Run base schema. |
| 90 | for _, stmt := range stmts { |
| 91 | if _, err := s.db.Exec(stmt); err != nireturn nil |
| 92 | } |
| 93 | rturns a Store. |
| 94 | // driver must be "sqlite" or "postgres". dsn is the connection string. |
| 95 | func Open(driver, dsn string) (*Store, error) { |
| 96 | db, err := sql.Op/ Package store provides a thin dvides a thin databasf("store: ping %s: %w", driver,river: driver} |
| 97 | if err := s.migrate(); err != nil { |
| 98 | db.Close() |
| 99 | return nil, fmt.Errorf("store: migrate: %w", err) |
| 100 | } |
| 101 | return s, nil |
| 102 | } |
| 103 | |
| 104 | // Close s.ph(4), s.ph(5es a thin database/sql wrapper for scuttlebot's |
| 105 | // persistent state: agent registry, admi// Package store providns ( |
| 106 | username TEXT PRIMARY KEY, |
| 107 | hash TEXT NOT NULL, |
| 108 | created_at TEXT NOT NULL |
| 109 | )`, |
| 110 | `CREATE TABLE IF NOT EXISTS policies ( |
| 111 | OT NULL |
| 112 | )`, |
| 113 | } |
| 114 | // Run base schema. |
| 115 | for _, stmt := range stmts { |
| 116 | if _, err := s.db.Exe; err != nil { |
| 117 | d |
| --- a/internal/store/store_test.go | ||
| +++ b/internal/store/store_test.go | ||
| @@ -0,0 +1,153 @@ | ||
| 1 | +package store | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "testing" | |
| 5 | + "time" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func openTest(t *testing.T) *Store { | |
| 9 | + t.Helper() | |
| 10 | + s, err := Open("sqlite", ":memory:") | |
| 11 | + if err != nil { | |
| 12 | + t.Fatalf("Open: %v", err) | |
| 13 | + } | |
| 14 | + t.Cleanup(func() { s.Close() }) | |
| 15 | + return s | |
| 16 | +} | |
| 17 | + | |
| 18 | +func TestAgentUpsertAndList(t *testing.T) { | |
| 19 | + s := openTest(t) | |
| 20 | + | |
| 21 | + cfg := []byte(`{"channels":["#general"]}`) | |
| 22 | + r := &AgentRow{ | |
| 23 | + Nick: "claude-repo-abc1", | |
| 24 | + Type: "worker", | |
| 25 | + Config: cfg, | |
| 26 | + CreatedAt: time.Now().UTC().Truncate(time.Second), | |
| 27 | + Revoked: false, | |
| 28 | + } | |
| 29 | + | |
| 30 | + if err := s.AgentUpsert(r); err != nil { | |
| 31 | + t.Fatalf("AgentUpsert: %v", err) | |
| 32 | + } | |
| 33 | + | |
| 34 | + rows, err := s.AgentList() | |
| 35 | + if err != nil { | |
| 36 | + t.Fatalf("AgentList: %v", err) | |
| 37 | + } | |
| 38 | + if len(rows) != 1 { | |
| 39 | + t.Fatalf("want 1 agent, got %d", len(rows)) | |
| 40 | + } | |
| 41 | + if rows[0].Nick != r.Nick { | |
| 42 | + t.Errorf("nick = %q, want %q", rows[0].Nick, r.Nick) | |
| 43 | + } | |
| 44 | + if string(rows[0].Config) != string(cfg) { | |
| 45 | + t.Errorf("config = %q, want %q", rows[0].Config, cfg) | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Upsert again with Revoked=true. | |
| 49 | + r.Revoked = true | |
| 50 | + if err := s.AgentUpsert(r); err != nil { | |
| 51 | + t.Fatalf("AgentUpsert (revoke): %v", err) | |
| 52 | + } | |
| 53 | + rows, err = s.AgentList() | |
| 54 | + if err != nil { | |
| 55 | + t.Fatalf("AgentList: %v", err) | |
| 56 | + } | |
| 57 | + if !rows[0].Revoked { | |
| 58 | + t.Error("expected revoked=true after upsert") | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +func TestAgentDelete(t *testing.T) { | |
| 63 | + s := openTest(t) | |
| 64 | + | |
| 65 | + r := &AgentRow{Nick: "test-nick", Type: "worker", Config: []byte(`{}`), CreatedAt: time.Now()} | |
| 66 | + if err := s.AgentUpsert(r); err != nil { | |
| 67 | + t.Fatal(err) | |
| 68 | + } | |
| 69 | + if err := s.AgentDelete("test-nick"); err != nil { | |
| 70 | + t.Fatal(err) | |
| 71 | + } | |
| 72 | + rows, err := s.AgentList() | |
| 73 | + if err != nil { | |
| 74 | + t.Fatal(err) | |
| 75 | + } | |
| 76 | + if len(rows) != 0 { | |
| 77 | + t.Errorf("expected 0 agents after delete, got %d", len(rows)) | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +func TestAdminUpsertListDelete(t *testing.T) { | |
| 82 | + s := openTest(t) | |
| 83 | + | |
| 84 | + r := &AdminRow{ | |
| 85 | + Username: "admin", | |
| 86 | + Hash: []byte("$2a$10$fakehashabcdefghijklmnopqrstuvwx"), | |
| 87 | + CreatedAt: time.Now().UTC().Truncate(time.Second), | |
| 88 | + } | |
| 89 | + if err := s.AdminUpsert(r); err != nil { | |
| 90 | + t.Fatalf("AdminUpsert: %v", err) | |
| 91 | + } | |
| 92 | + | |
| 93 | + rows, err := s.AdminList() | |
| 94 | + if err != nil { | |
| 95 | + t.Fatalf("AdminList: %v", err) | |
| 96 | + } | |
| 97 | + if len(rows) != 1 { | |
| 98 | + t.Fatalf("want 1 admin, got %d", len(rows)) | |
| 99 | + } | |
| 100 | + if rows[0].Username != "admin" { | |
| 101 | + t.Errorf("username = %q, want admin", rows[0].Username) | |
| 102 | + } | |
| 103 | + if string(rows[0].Hash) != string(r.Hash) { | |
| 104 | + t.Errorf("hash mismatch after round-trip") | |
| 105 | + } | |
| 106 | + | |
| 107 | + if err := s.AdminDelete("admin"); err != nil { | |
| 108 | + t.Fatal(err) | |
| 109 | + } | |
| 110 | + rows, err = s.AdminList() | |
| 111 | + if err != nil { | |
| 112 | + t.Fatal(err) | |
| 113 | + } | |
| 114 | + if len(rows) != 0 { | |
| 115 | + t.Errorf("expected 0 admins after delete, got %d", len(rows)) | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +func TestPolicyGetSet(t *testing.T) { | |
| 120 | + s := openTest(t) | |
| 121 | + | |
| 122 | + // No policy yet — should return nil. | |
| 123 | + data, err := s.PolicyGet() | |
| 124 | + if err != nil { | |
| 125 | + t.Fatalf("PolicyGet (empty): %v", err) | |
| 126 | + } | |
| 127 | + if data != nil { | |
| 128 | + t.Errorf("expected nil before first set, got %q", data) | |
| 129 | + } | |
| 130 | + | |
| 131 | + blob := []byte(`{"behaviors":[]}`) | |
| 132 | + if err := s.PolicySet(blob); err != nil { | |
| 133 | + t.Fatalf("PolicySet: %v", err) | |
| 134 | + } | |
| 135 | + | |
| 136 | + got, err := s.PolicyGet() | |
| 137 | + if err != nil { | |
| 138 | + t.Fatalf("PolicyGet: %v", err) | |
| 139 | + } | |
| 140 | + if string(got) != string(blob) { | |
| 141 | + t.Errorf("PolicyGet = %q, want %q", got, blob) | |
| 142 | + } | |
| 143 | + | |
| 144 | + // Overwrite. | |
| 145 | + blob2 := []byte(`{"behaviors":[{"id":"scribe"}]}`) | |
| 146 | + if err := s.PolicySet(blob2); err != nil { | |
| 147 | + t.Fatalf("PolicySet (overwrite): %v", err) | |
| 148 | + } | |
| 149 | + got2, _ := s.PolicyGet() | |
| 150 | + if string(got2) != string(blob2) { | |
| 151 | + t.Errorf("PolicyGet after overwrite = %q, want %q", got2, blob2) | |
| 152 | + } | |
| 153 | +} |
| --- a/internal/store/store_test.go | |
| +++ b/internal/store/store_test.go | |
| @@ -0,0 +1,153 @@ | |
| --- a/internal/store/store_test.go | |
| +++ b/internal/store/store_test.go | |
| @@ -0,0 +1,153 @@ | |
| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func openTest(t *testing.T) *Store { |
| 9 | t.Helper() |
| 10 | s, err := Open("sqlite", ":memory:") |
| 11 | if err != nil { |
| 12 | t.Fatalf("Open: %v", err) |
| 13 | } |
| 14 | t.Cleanup(func() { s.Close() }) |
| 15 | return s |
| 16 | } |
| 17 | |
| 18 | func TestAgentUpsertAndList(t *testing.T) { |
| 19 | s := openTest(t) |
| 20 | |
| 21 | cfg := []byte(`{"channels":["#general"]}`) |
| 22 | r := &AgentRow{ |
| 23 | Nick: "claude-repo-abc1", |
| 24 | Type: "worker", |
| 25 | Config: cfg, |
| 26 | CreatedAt: time.Now().UTC().Truncate(time.Second), |
| 27 | Revoked: false, |
| 28 | } |
| 29 | |
| 30 | if err := s.AgentUpsert(r); err != nil { |
| 31 | t.Fatalf("AgentUpsert: %v", err) |
| 32 | } |
| 33 | |
| 34 | rows, err := s.AgentList() |
| 35 | if err != nil { |
| 36 | t.Fatalf("AgentList: %v", err) |
| 37 | } |
| 38 | if len(rows) != 1 { |
| 39 | t.Fatalf("want 1 agent, got %d", len(rows)) |
| 40 | } |
| 41 | if rows[0].Nick != r.Nick { |
| 42 | t.Errorf("nick = %q, want %q", rows[0].Nick, r.Nick) |
| 43 | } |
| 44 | if string(rows[0].Config) != string(cfg) { |
| 45 | t.Errorf("config = %q, want %q", rows[0].Config, cfg) |
| 46 | } |
| 47 | |
| 48 | // Upsert again with Revoked=true. |
| 49 | r.Revoked = true |
| 50 | if err := s.AgentUpsert(r); err != nil { |
| 51 | t.Fatalf("AgentUpsert (revoke): %v", err) |
| 52 | } |
| 53 | rows, err = s.AgentList() |
| 54 | if err != nil { |
| 55 | t.Fatalf("AgentList: %v", err) |
| 56 | } |
| 57 | if !rows[0].Revoked { |
| 58 | t.Error("expected revoked=true after upsert") |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestAgentDelete(t *testing.T) { |
| 63 | s := openTest(t) |
| 64 | |
| 65 | r := &AgentRow{Nick: "test-nick", Type: "worker", Config: []byte(`{}`), CreatedAt: time.Now()} |
| 66 | if err := s.AgentUpsert(r); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := s.AgentDelete("test-nick"); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | rows, err := s.AgentList() |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if len(rows) != 0 { |
| 77 | t.Errorf("expected 0 agents after delete, got %d", len(rows)) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestAdminUpsertListDelete(t *testing.T) { |
| 82 | s := openTest(t) |
| 83 | |
| 84 | r := &AdminRow{ |
| 85 | Username: "admin", |
| 86 | Hash: []byte("$2a$10$fakehashabcdefghijklmnopqrstuvwx"), |
| 87 | CreatedAt: time.Now().UTC().Truncate(time.Second), |
| 88 | } |
| 89 | if err := s.AdminUpsert(r); err != nil { |
| 90 | t.Fatalf("AdminUpsert: %v", err) |
| 91 | } |
| 92 | |
| 93 | rows, err := s.AdminList() |
| 94 | if err != nil { |
| 95 | t.Fatalf("AdminList: %v", err) |
| 96 | } |
| 97 | if len(rows) != 1 { |
| 98 | t.Fatalf("want 1 admin, got %d", len(rows)) |
| 99 | } |
| 100 | if rows[0].Username != "admin" { |
| 101 | t.Errorf("username = %q, want admin", rows[0].Username) |
| 102 | } |
| 103 | if string(rows[0].Hash) != string(r.Hash) { |
| 104 | t.Errorf("hash mismatch after round-trip") |
| 105 | } |
| 106 | |
| 107 | if err := s.AdminDelete("admin"); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | rows, err = s.AdminList() |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | if len(rows) != 0 { |
| 115 | t.Errorf("expected 0 admins after delete, got %d", len(rows)) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestPolicyGetSet(t *testing.T) { |
| 120 | s := openTest(t) |
| 121 | |
| 122 | // No policy yet — should return nil. |
| 123 | data, err := s.PolicyGet() |
| 124 | if err != nil { |
| 125 | t.Fatalf("PolicyGet (empty): %v", err) |
| 126 | } |
| 127 | if data != nil { |
| 128 | t.Errorf("expected nil before first set, got %q", data) |
| 129 | } |
| 130 | |
| 131 | blob := []byte(`{"behaviors":[]}`) |
| 132 | if err := s.PolicySet(blob); err != nil { |
| 133 | t.Fatalf("PolicySet: %v", err) |
| 134 | } |
| 135 | |
| 136 | got, err := s.PolicyGet() |
| 137 | if err != nil { |
| 138 | t.Fatalf("PolicyGet: %v", err) |
| 139 | } |
| 140 | if string(got) != string(blob) { |
| 141 | t.Errorf("PolicyGet = %q, want %q", got, blob) |
| 142 | } |
| 143 | |
| 144 | // Overwrite. |
| 145 | blob2 := []byte(`{"behaviors":[{"id":"scribe"}]}`) |
| 146 | if err := s.PolicySet(blob2); err != nil { |
| 147 | t.Fatalf("PolicySet (overwrite): %v", err) |
| 148 | } |
| 149 | got2, _ := s.PolicyGet() |
| 150 | if string(got2) != string(blob2) { |
| 151 | t.Errorf("PolicyGet after overwrite = %q, want %q", got2, blob2) |
| 152 | } |
| 153 | } |