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

lmata 2026-04-03 01:35 trunk
Commit 0e7895411ee4c10b349f4194ad75c86d41846f316c03e3229fcd245ed89e26ab
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -24,10 +24,11 @@
2424
botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
2525
"github.com/conflicthq/scuttlebot/internal/config"
2626
"github.com/conflicthq/scuttlebot/internal/ergo"
2727
"github.com/conflicthq/scuttlebot/internal/mcp"
2828
"github.com/conflicthq/scuttlebot/internal/registry"
29
+ "github.com/conflicthq/scuttlebot/internal/store"
2930
"github.com/conflicthq/scuttlebot/internal/topology"
3031
)
3132
3233
var version = "dev"
3334
@@ -104,20 +105,39 @@
104105
os.Exit(1)
105106
case <-time.After(500 * time.Millisecond):
106107
}
107108
}
108109
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
+ }
109124
110125
// Build registry backed by Ergo's NickServ API.
111126
// Signing key persists so issued payloads stay valid across restarts.
112127
signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key"))
113128
if err != nil {
114129
log.Error("signing key", "err", err)
115130
os.Exit(1)
116131
}
117132
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 {
119139
log.Error("registry load", "err", err)
120140
os.Exit(1)
121141
}
122142
123143
// Shared API token — persisted so the UI token survives restarts.
@@ -201,10 +221,16 @@
201221
// Policy store — persists behavior/agent/logging settings.
202222
policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
203223
if err != nil {
204224
log.Error("policy store", "err", err)
205225
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
+ }
206232
}
207233
if bridgeBot != nil {
208234
bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
209235
}
210236
@@ -211,10 +237,16 @@
211237
// Admin store — bcrypt-hashed admin accounts.
212238
adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
213239
if err != nil {
214240
log.Error("admin store", "err", err)
215241
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
+ }
216248
}
217249
if adminStore.IsEmpty() {
218250
password := mustGenToken()[:16]
219251
if err := adminStore.Add("admin", password); err != nil {
220252
log.Error("create default admin", "err", err)
221253
--- 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
M go.mod
+11 -1
--- go.mod
+++ go.mod
@@ -11,9 +11,19 @@
1111
golang.org/x/term v0.32.0
1212
gopkg.in/yaml.v3 v3.0.1
1313
)
1414
1515
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
1622
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
1824
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
1929
)
2030
--- 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
M go.sum
+23
--- go.sum
+++ go.sum
@@ -1,21 +1,44 @@
11
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
22
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=
39
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
410
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=
515
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
616
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
717
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=
820
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
921
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
1022
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
1123
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=
1225
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
1326
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=
1429
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
1530
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
1631
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
1732
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
1833
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1934
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2035
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2136
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=
2245
--- 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 @@
44
"encoding/json"
55
"fmt"
66
"net/http"
77
"os"
88
"sync"
9
+
10
+ "github.com/conflicthq/scuttlebot/internal/store"
911
)
1012
1113
// BehaviorConfig defines a pre-registered system bot behavior.
1214
type BehaviorConfig struct {
1315
ID string `json:"id"`
@@ -147,17 +149,18 @@
147149
Nick: "steward",
148150
JoinAllChannels: true,
149151
},
150152
}
151153
152
-// PolicyStore persists Policies to a JSON file.
154
+// PolicyStore persists Policies to a JSON file or database.
153155
type PolicyStore struct {
154156
mu sync.RWMutex
155157
path string
156158
data Policies
157159
defaultBridgeTTLMinutes int
158160
onChange func(Policies)
161
+ db *store.Store // when non-nil, supersedes path
159162
}
160163
161164
func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) {
162165
if defaultBridgeTTLMinutes <= 0 {
163166
defaultBridgeTTLMinutes = 5
@@ -180,10 +183,32 @@
180183
return nil
181184
}
182185
if err != nil {
183186
return fmt.Errorf("policies: read %s: %w", ps.path, err)
184187
}
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 {
185210
var p Policies
186211
if err := json.Unmarshal(raw, &p); err != nil {
187212
return fmt.Errorf("policies: parse: %w", err)
188213
}
189214
ps.normalize(&p)
@@ -207,10 +232,13 @@
207232
func (ps *PolicyStore) save() error {
208233
raw, err := json.MarshalIndent(ps.data, "", " ")
209234
if err != nil {
210235
return err
211236
}
237
+ if ps.db != nil {
238
+ return ps.db.PolicySet(raw)
239
+ }
212240
return os.WriteFile(ps.path, raw, 0600)
213241
}
214242
215243
func (ps *PolicyStore) Get() Policies {
216244
ps.mu.RLock()
217245
--- 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 @@
77
"os"
88
"sync"
99
"time"
1010
1111
"golang.org/x/crypto/bcrypt"
12
+
13
+ "github.com/conflicthq/scuttlebot/internal/store"
1214
)
1315
1416
// Admin is a single admin account record.
1517
type Admin struct {
1618
Username string `json:"username"`
1719
Hash []byte `json:"hash"`
1820
Created time.Time `json:"created"`
1921
}
2022
21
-// AdminStore persists admin accounts to a JSON file.
23
+// AdminStore persists admin accounts to a JSON file or database.
2224
type AdminStore struct {
2325
mu sync.RWMutex
2426
path string
2527
data []Admin
28
+ db *store.Store // when non-nil, supersedes path
2629
}
2730
2831
// NewAdminStore loads (or creates) the admin store at the given path.
2932
func NewAdminStore(path string) (*AdminStore, error) {
3033
s := &AdminStore{path: path}
@@ -31,10 +34,27 @@
3134
if err := s.load(); err != nil {
3235
return nil, err
3336
}
3437
return s, nil
3538
}
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
+}
3656
3757
// IsEmpty reports whether there are no admin accounts.
3858
func (s *AdminStore) IsEmpty() bool {
3959
s.mu.RLock()
4060
defer s.mu.RUnlock()
@@ -55,15 +75,15 @@
5575
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
5676
if err != nil {
5777
return fmt.Errorf("admin: hash password: %w", err)
5878
}
5979
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
+ }
6585
return s.save()
6686
}
6787
6888
// SetPassword updates the password for an existing admin.
6989
func (s *AdminStore) SetPassword(username, password string) error {
@@ -75,10 +95,13 @@
7595
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
7696
if err != nil {
7797
return fmt.Errorf("admin: hash password: %w", err)
7898
}
7999
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
+ }
80103
return s.save()
81104
}
82105
}
83106
return fmt.Errorf("admin %q not found", username)
84107
}
@@ -89,10 +112,13 @@
89112
defer s.mu.Unlock()
90113
91114
for i, a := range s.data {
92115
if a.Username == username {
93116
s.data = append(s.data[:i], s.data[i+1:]...)
117
+ if s.db != nil {
118
+ return s.db.AdminDelete(username)
119
+ }
94120
return s.save()
95121
}
96122
}
97123
return fmt.Errorf("admin %q not found", username)
98124
}
99125
--- 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 @@
154154
// ServerName is the IRC server hostname (e.g. "irc.example.com").
155155
ServerName string `yaml:"server_name"`
156156
157157
// IRCAddr is the address Ergo listens for IRC connections on.
158158
// Default: "127.0.0.1:6667" (loopback plaintext for private networks).
159
+ // Set to ":6667" or ":6697" to accept connections from outside the host.
159160
IRCAddr string `yaml:"irc_addr"`
160161
161162
// APIAddr is the address of Ergo's HTTP management API.
162163
// Default: "127.0.0.1:8089" (loopback only).
163164
APIAddr string `yaml:"api_addr"`
164165
165166
// APIToken is the bearer token for Ergo's HTTP API.
166167
// scuttlebot generates this on first start and stores it.
167168
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"`
168180
169181
// History configures persistent message history storage.
170182
History HistoryConfig `yaml:"history"`
171183
}
172184
@@ -375,14 +387,17 @@
375387
}
376388
if c.Datastore.DSN == "" {
377389
c.Datastore.DSN = "./data/scuttlebot.db"
378390
}
379391
if c.APIAddr == "" {
380
- c.APIAddr = ":8080"
392
+ c.APIAddr = "127.0.0.1:8080"
381393
}
382394
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"
384399
}
385400
if !c.Bridge.Enabled && c.Bridge.Nick == "" {
386401
c.Bridge.Enabled = true // enabled by default
387402
}
388403
if c.TLS.Domain != "" && !c.TLS.AllowInsecure {
389404
--- 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 @@
1414
"fmt"
1515
"os"
1616
"strings"
1717
"sync"
1818
"time"
19
+
20
+ "github.com/conflicthq/scuttlebot/internal/store"
1921
)
2022
2123
// AgentType describes an agent's role and authority level.
2224
type AgentType string
2325
@@ -72,11 +74,12 @@
7274
type Registry struct {
7375
mu sync.RWMutex
7476
agents map[string]*Agent // keyed by nick
7577
provisioner AccountProvisioner
7678
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
7881
}
7982
8083
// New creates a new Registry with the given provisioner and HMAC signing key.
8184
// Call SetDataPath to enable persistence before registering any agents.
8285
func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
@@ -85,18 +88,78 @@
8588
provisioner: provisioner,
8689
signingKey: signingKey,
8790
}
8891
}
8992
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.
9296
func (r *Registry) SetDataPath(path string) error {
9397
r.mu.Lock()
9498
defer r.mu.Unlock()
9599
r.dataPath = path
96100
return r.load()
97101
}
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
+}
98161
99162
func (r *Registry) load() error {
100163
data, err := os.ReadFile(r.dataPath)
101164
if os.IsNotExist(err) {
102165
return nil
@@ -167,11 +230,11 @@
167230
Permissions: cfg.Permissions,
168231
Config: cfg,
169232
CreatedAt: time.Now(),
170233
}
171234
r.agents[nick] = agent
172
- r.save()
235
+ r.saveOne(agent)
173236
174237
payload, err := r.signPayload(agent)
175238
if err != nil {
176239
return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
177240
}
@@ -202,11 +265,11 @@
202265
Permissions: cfg.Permissions,
203266
Config: cfg,
204267
CreatedAt: time.Now(),
205268
}
206269
r.agents[nick] = agent
207
- r.save()
270
+ r.saveOne(agent)
208271
209272
return r.signPayload(agent)
210273
}
211274
212275
// Rotate generates a new passphrase for an agent and updates Ergo.
@@ -225,10 +288,12 @@
225288
226289
if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
227290
return nil, fmt.Errorf("registry: rotate credentials: %w", err)
228291
}
229292
293
+ // Rotation doesn't change stored agent data, but bump a file save for
294
+ // consistency; DB backends are unaffected since nothing persisted changed.
230295
r.save()
231296
return &Credentials{Nick: nick, Passphrase: passphrase}, nil
232297
}
233298
234299
// Revoke locks an agent out by rotating to an unguessable passphrase and
@@ -250,11 +315,11 @@
250315
if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
251316
return fmt.Errorf("registry: revoke credentials: %w", err)
252317
}
253318
254319
agent.Revoked = true
255
- r.save()
320
+ r.saveOne(agent)
256321
return nil
257322
}
258323
259324
// Delete fully removes an agent from the registry. The Ergo NickServ account
260325
// is locked out first (password rotated to an unguessable value) so the agent
@@ -278,11 +343,11 @@
278343
return fmt.Errorf("registry: delete lockout: %w", err)
279344
}
280345
}
281346
282347
delete(r.agents, nick)
283
- r.save()
348
+ r.deleteOne(nick)
284349
return nil
285350
}
286351
287352
// UpdateChannels replaces the channel list for an active agent.
288353
// Used by relay brokers to sync runtime /join and /part changes back to the registry.
@@ -293,11 +358,11 @@
293358
if err != nil {
294359
return err
295360
}
296361
agent.Channels = append([]string(nil), channels...)
297362
agent.Config.Channels = append([]string(nil), channels...)
298
- r.save()
363
+ r.saveOne(agent)
299364
return nil
300365
}
301366
302367
// Get returns the agent with the given nick.
303368
func (r *Registry) Get(nick string) (*Agent, error) {
304369
305370
ADDED internal/store/store.go
306371
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 }

Keyboard Shortcuts

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