diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go
index 71558e11..a8293658 100644
--- a/Server/admin/api_test.go
+++ b/Server/admin/api_test.go
@@ -15,14 +15,13 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// newTestModService builds a real ModerationService over the test database so
// PATCH-user ban paths exercise the production authorization (BAN_MEMBERS +
// role hierarchy) instead of a stub.
func newTestModService(database *db.DB) *service.ModerationService {
- st := store.NewSQLiteStore(database)
+ st := database
checker := permissions.NewChecker(st)
return service.NewModerationService(st, service.NewPermissionService(st, checker))
}
diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go
index 8e1a9d57..0c0b7a97 100644
--- a/Server/api/channel_handler_test.go
+++ b/Server/api/channel_handler_test.go
@@ -13,7 +13,6 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// ─── schema for channel tests ─────────────────────────────────────────────────
@@ -189,7 +188,7 @@ func newChannelTestDB(t *testing.T) *db.DB {
func buildChannelRouter(database *db.DB) http.Handler {
r := chi.NewRouter()
limiter := auth.NewRateLimiter()
- st := store.NewSQLiteStore(database)
+ st := database
svc := service.New(st, limiter)
api.MountChannelRoutes(r, database, svc, limiter, nil)
return r
@@ -610,7 +609,7 @@ func TestSearch_TrustedProxyRateLimitUsesForwardedIP(t *testing.T) {
database := newChannelTestDB(t)
r := chi.NewRouter()
limiter := auth.NewRateLimiter()
- svc := service.New(store.NewSQLiteStore(database), limiter)
+ svc := service.New(database, limiter)
api.MountChannelRoutes(r, database, svc, limiter, []string{"127.0.0.0/8"})
token := chTestCreateToken(t, database, "proxysearch", 1)
diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go
index 7e192fcc..23aaf4ab 100644
--- a/Server/api/coverage_push_test.go
+++ b/Server/api/coverage_push_test.go
@@ -20,7 +20,6 @@ import (
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// ─── handleCreateInvite: malformed JSON body ────────────────────────────────
@@ -750,7 +749,7 @@ func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string)
limiter := auth.NewRateLimiter()
r := chi.NewRouter()
- svc := service.New(store.NewSQLiteStore(database), limiter)
+ svc := service.New(database, limiter)
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
api.MountProfileRoutes(r, database, svc, limiter, nil, nil)
api.MountInviteRoutes(r, database, svc)
diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go
index 86721ea6..8610f5ee 100644
--- a/Server/api/dm_handler_test.go
+++ b/Server/api/dm_handler_test.go
@@ -14,7 +14,6 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// ─── DM test schema ─────────────────────────────────────────────────────────
@@ -174,7 +173,7 @@ func dmCreateToken(t *testing.T, database *db.DB, username string, roleID int) s
func buildDMRouter(database *db.DB, broadcaster api.DMBroadcaster) http.Handler {
r := chi.NewRouter()
- svc := service.New(store.NewSQLiteStore(database), auth.NewRateLimiter())
+ svc := service.New(database, auth.NewRateLimiter())
api.MountDMRoutes(r, database, svc, broadcaster)
return r
}
diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go
index e9cc8a40..789de56d 100644
--- a/Server/api/invite_handler_test.go
+++ b/Server/api/invite_handler_test.go
@@ -11,13 +11,12 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// buildInviteRouter returns a chi router with invite routes and auth middleware.
func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
r := chi.NewRouter()
- svc := service.New(store.NewSQLiteStore(database), limiter)
+ svc := service.New(database, limiter)
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
api.MountInviteRoutes(r, database, svc)
return r
diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go
index 2cf95b7c..a0660336 100644
--- a/Server/api/plugins_handler.go
+++ b/Server/api/plugins_handler.go
@@ -15,7 +15,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/owncord/server/plugin"
- "github.com/owncord/server/store"
)
// maxPluginUploadBytes caps the multipart upload at 16 MiB to match the
@@ -26,13 +25,13 @@ const maxPluginUploadBytes = 16 * 1024 * 1024
// PluginAdminHandler exposes plugin lifecycle operations to the admin panel.
type PluginAdminHandler struct {
registry *plugin.Registry
- store store.PluginStore
+ store plugin.PluginStore
}
// NewPluginAdminHandler builds an http.Handler that the router can mount.
// Pass a nil registry when plugin support is disabled — the handler then
// reports an empty list and 503 on lifecycle calls.
-func NewPluginAdminHandler(registry *plugin.Registry, st store.PluginStore) http.Handler {
+func NewPluginAdminHandler(registry *plugin.Registry, st plugin.PluginStore) http.Handler {
h := &PluginAdminHandler{registry: registry, store: st}
r := chi.NewRouter()
r.Get("/", h.list)
diff --git a/Server/api/plugins_handler_test.go b/Server/api/plugins_handler_test.go
index cad708d0..f1e24c2e 100644
--- a/Server/api/plugins_handler_test.go
+++ b/Server/api/plugins_handler_test.go
@@ -2,8 +2,8 @@
//
// The handler is covered at the HTTP boundary so the fixtures do not depend
// on the Wazero runtime. A nil Registry exercises the "plugin runtime
-// disabled" branch; a real Registry wired against a MemStore exercises the
-// happy path.
+// disabled" branch; a real Registry wired against a real in-memory DB
+// exercises the happy path.
package api
import (
@@ -18,10 +18,26 @@ import (
"strings"
"testing"
+ "github.com/owncord/server/db"
"github.com/owncord/server/plugin"
- "github.com/owncord/server/store"
)
+// openPluginTestDB opens an in-memory database with the full migration set so
+// the plugins and plugin_kv tables exist. *db.DB satisfies the plugin.Config
+// Store interface directly (D3 removed the store abstraction).
+func openPluginTestDB(t *testing.T) *db.DB {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ if err := db.Migrate(database); err != nil {
+ t.Fatalf("db.Migrate: %v", err)
+ }
+ return database
+}
+
func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
req := httptest.NewRequest("GET", "/", nil)
@@ -91,7 +107,7 @@ func TestPluginsHandlerInstallRejectsNonZipMagic(t *testing.T) {
func TestPluginsHandlerInstallHappyPath(t *testing.T) {
reg := newTestPluginRegistry(t)
- mem := store.NewMemStore()
+ mem := openPluginTestDB(t)
// Wire the store into the handler so /list can show the new row. The
// registry already writes via its own PluginStore.
h := NewPluginAdminHandler(reg, mem)
@@ -137,15 +153,15 @@ func TestPluginsHandlerLifecycleInvalidID(t *testing.T) {
func TestIsZipContentType(t *testing.T) {
cases := map[string]bool{
- "application/zip": true,
- "application/zip; charset=binary": true,
- "APPLICATION/ZIP": true,
- "application/x-zip-compressed": true,
- "application/octet-stream": true,
- "text/plain": false,
- "image/png": false,
- "": false,
- "application/json; charset=utf-8": false,
+ "application/zip": true,
+ "application/zip; charset=binary": true,
+ "APPLICATION/ZIP": true,
+ "application/x-zip-compressed": true,
+ "application/octet-stream": true,
+ "text/plain": false,
+ "image/png": false,
+ "": false,
+ "application/json; charset=utf-8": false,
}
for ct, want := range cases {
if got := isZipContentType(ct); got != want {
@@ -156,12 +172,12 @@ func TestIsZipContentType(t *testing.T) {
func TestHasZipMagic(t *testing.T) {
cases := map[string]bool{
- "PK\x03\x04rest": true,
- "PK\x05\x06": true,
- "PK\x07\x08rest": false, // spanned-archive signature; not accepted here
- "not a zip": false,
- "": false,
- "PK": false,
+ "PK\x03\x04rest": true,
+ "PK\x05\x06": true,
+ "PK\x07\x08rest": false, // spanned-archive signature; not accepted here
+ "not a zip": false,
+ "": false,
+ "PK": false,
}
for body, want := range cases {
if got := hasZipMagic([]byte(body)); got != want {
@@ -175,7 +191,7 @@ func TestHasZipMagic(t *testing.T) {
func newTestPluginRegistry(t *testing.T) *plugin.Registry {
t.Helper()
dir := t.TempDir()
- mem := store.NewMemStore()
+ mem := openPluginTestDB(t)
reg, err := plugin.NewRegistry(plugin.Config{
Directory: filepath.Join(dir, "plugins"),
Store: mem,
diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go
index e0a6d086..aeaebb00 100644
--- a/Server/api/profile_handler_test.go
+++ b/Server/api/profile_handler_test.go
@@ -14,14 +14,13 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// buildProfileRouter returns a chi router with profile routes mounted.
func buildProfileRouter(database *db.DB) http.Handler {
r := chi.NewRouter()
limiter := auth.NewRateLimiter()
- svc := service.New(store.NewSQLiteStore(database), limiter)
+ svc := service.New(database, limiter)
api.MountProfileRoutes(r, database, svc, limiter, nil, nil)
return r
}
diff --git a/Server/api/router.go b/Server/api/router.go
index a40ef231..6f26d3f2 100644
--- a/Server/api/router.go
+++ b/Server/api/router.go
@@ -19,7 +19,6 @@ import (
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
"github.com/owncord/server/storage"
- dbstore "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
"github.com/owncord/server/updater"
"github.com/owncord/server/ws"
@@ -94,8 +93,9 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
}
// Service layer — centralizes business logic for REST and WS handlers.
- st := dbstore.NewSQLiteStore(database)
- svc := service.New(st, limiter)
+ // *db.DB satisfies service.Store directly (the store abstraction was
+ // removed in D3).
+ svc := service.New(database, limiter)
// Auth routes: register, login, logout, me.
MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey)
@@ -247,7 +247,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
// which case lifecycle calls return 503 and list returns []).
r.Group(func(r chi.Router) {
r.Use(admin.RequireAdminAuth(database))
- r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, st))
+ r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, database))
})
})
diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go
index 95b1fc4a..20251174 100644
--- a/Server/api/upload_handler_test.go
+++ b/Server/api/upload_handler_test.go
@@ -23,7 +23,6 @@ import (
"github.com/owncord/server/permissions"
"github.com/owncord/server/service"
"github.com/owncord/server/storage"
- "github.com/owncord/server/store"
)
// testPermSvc wires a PermissionService around the test DB so
@@ -31,7 +30,7 @@ import (
// exercise per-channel ACLs directly — they go through the live
// permissions.Checker, which is the production path anyway.
func testPermSvc(database *db.DB) *service.PermissionService {
- return service.NewPermissionService(store.NewSQLiteStore(database), permissions.NewChecker(database))
+ return service.NewPermissionService(database, permissions.NewChecker(database))
}
// ─── schema for upload tests ─────────────────────────────────────────────────
diff --git a/Server/db/event_queries.go b/Server/db/event_queries.go
new file mode 100644
index 00000000..462f320f
--- /dev/null
+++ b/Server/db/event_queries.go
@@ -0,0 +1,163 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// ── Event persistence (Phase B Step 7) ──────────────────────────────────────
+//
+// These methods back the cold-tier reconnect replay. They were previously in
+// the store package's SQLiteStore; they moved here (D3) when that pass-through
+// layer was removed. The SQL is unchanged.
+
+// PersistEvent appends a single event to the events table with the
+// caller-supplied seq. The hub assigns seq before this is called so the row
+// seq always matches the wrapped-payload seq, even if the persister drops
+// some events under load.
+func (d *DB) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error {
+ _, err := d.sqlDB.ExecContext(ctx,
+ `INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?)`,
+ seq, eventType, channelID, payload,
+ )
+ if err != nil {
+ return fmt.Errorf("PersistEvent: %w", err)
+ }
+ return nil
+}
+
+// GetEventsSince returns events with seq > afterSeq up to limit, ordered ASC.
+func (d *DB) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]PersistedEvent, error) {
+ rows, err := d.sqlDB.QueryContext(ctx,
+ `SELECT seq, event_type, channel_id, payload, created_at
+ FROM events
+ WHERE seq > ?
+ ORDER BY seq ASC
+ LIMIT ?`,
+ afterSeq, limit,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("GetEventsSince: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ return scanEventRows(rows)
+}
+
+// GetEventsSinceForChannels filters events to those whose channel_id is 0
+// (global broadcast) or in channelIDs.
+func (d *DB) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]PersistedEvent, error) {
+ // Build IN clause manually since database/sql does not expand slices.
+ if len(channelIDs) == 0 {
+ // Only global broadcasts.
+ rows, err := d.sqlDB.QueryContext(ctx,
+ `SELECT seq, event_type, channel_id, payload, created_at
+ FROM events
+ WHERE seq > ? AND channel_id = 0
+ ORDER BY seq ASC
+ LIMIT ?`,
+ afterSeq, limit,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ return scanEventRows(rows)
+ }
+
+ placeholders := make([]string, len(channelIDs))
+ args := make([]any, 0, len(channelIDs)+2)
+ args = append(args, afterSeq)
+ for i, cid := range channelIDs {
+ placeholders[i] = "?"
+ args = append(args, cid)
+ }
+ args = append(args, limit)
+
+ query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input
+ `SELECT seq, event_type, channel_id, payload, created_at
+ FROM events
+ WHERE seq > ?
+ AND (channel_id = 0 OR channel_id IN (%s))
+ ORDER BY seq ASC
+ LIMIT ?`,
+ strings.Join(placeholders, ","),
+ )
+ rows, err := d.sqlDB.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ return scanEventRows(rows)
+}
+
+// GetMaxEventSeq returns the largest seq in the events table, or 0 if empty.
+func (d *DB) GetMaxEventSeq(ctx context.Context) (int64, error) {
+ var maxSeq sql.NullInt64
+ err := d.sqlDB.QueryRowContext(ctx, `SELECT MAX(seq) FROM events`).Scan(&maxSeq)
+ if err != nil {
+ return 0, fmt.Errorf("GetMaxEventSeq: %w", err)
+ }
+ if !maxSeq.Valid {
+ return 0, nil
+ }
+ return maxSeq.Int64, nil
+}
+
+// PruneEventsOlderThan deletes events older than cutoff. Returns rows deleted.
+func (d *DB) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
+ res, err := d.sqlDB.ExecContext(ctx,
+ `DELETE FROM events WHERE created_at < ?`,
+ cutoff.UTC().Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ return 0, fmt.Errorf("PruneEventsOlderThan: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return 0, fmt.Errorf("PruneEventsOlderThan RowsAffected: %w", err)
+ }
+ return n, nil
+}
+
+// ── helpers ─────────────────────────────────────────────────────────────────
+
+type rowsScanner interface {
+ Next() bool
+ Scan(dest ...any) error
+ Err() error
+}
+
+func scanEventRows(rows rowsScanner) ([]PersistedEvent, error) {
+ var out []PersistedEvent
+ for rows.Next() {
+ var e PersistedEvent
+ var createdAt string
+ if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &createdAt); err != nil {
+ return nil, fmt.Errorf("scanEventRows: %w", err)
+ }
+ e.CreatedAt = parseSQLiteTime(createdAt)
+ out = append(out, e)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// parseSQLiteTime parses the several timestamp formats SQLite may return.
+func parseSQLiteTime(s string) time.Time {
+ // SQLite CURRENT_TIMESTAMP returns "YYYY-MM-DD HH:MM:SS" in UTC.
+ for _, layout := range []string{
+ "2006-01-02 15:04:05",
+ time.RFC3339,
+ time.RFC3339Nano,
+ } {
+ if t, err := time.Parse(layout, s); err == nil {
+ return t.UTC()
+ }
+ }
+ return time.Time{}
+}
diff --git a/Server/db/plugin_queries.go b/Server/db/plugin_queries.go
new file mode 100644
index 00000000..bb61a0be
--- /dev/null
+++ b/Server/db/plugin_queries.go
@@ -0,0 +1,152 @@
+package db
+
+import (
+ "context"
+ "fmt"
+)
+
+// ── Plugin persistence (Phase C Step 9) ─────────────────────────────────────
+//
+// Moved verbatim from the store package's SQLiteStore (D3). The SQL is
+// unchanged.
+
+func (d *DB) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) {
+ res, err := d.sqlDB.ExecContext(ctx,
+ `INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?)
+ ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json`,
+ name, version, manifestJSON,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("InstallPlugin: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil || id == 0 {
+ // On conflict path LastInsertId may be 0; look up by name.
+ row := d.sqlDB.QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, name)
+ if scanErr := row.Scan(&id); scanErr != nil {
+ return 0, fmt.Errorf("InstallPlugin lookup: %w", scanErr)
+ }
+ }
+ return id, nil
+}
+
+func (d *DB) EnablePlugin(ctx context.Context, id int64) error {
+ _, err := d.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id)
+ return err
+}
+
+func (d *DB) DisablePlugin(ctx context.Context, id int64) error {
+ _, err := d.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id)
+ return err
+}
+
+func (d *DB) UninstallPlugin(ctx context.Context, id int64) error {
+ _, err := d.sqlDB.ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id)
+ return err
+}
+
+func (d *DB) GetPlugin(ctx context.Context, id int64) (*PluginRow, error) {
+ row := d.sqlDB.QueryRowContext(ctx,
+ `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?`,
+ id,
+ )
+ return scanPluginRow(row)
+}
+
+func (d *DB) GetPluginByName(ctx context.Context, name string) (*PluginRow, error) {
+ row := d.sqlDB.QueryRowContext(ctx,
+ `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?`,
+ name,
+ )
+ return scanPluginRow(row)
+}
+
+func (d *DB) ListPlugins(ctx context.Context) ([]PluginRow, error) {
+ rows, err := d.sqlDB.QueryContext(ctx,
+ `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name`,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("ListPlugins: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ var out []PluginRow
+ for rows.Next() {
+ var p PluginRow
+ var enabledInt int64
+ var installedAt string
+ if err := rows.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil {
+ return nil, fmt.Errorf("ListPlugins scan: %w", err)
+ }
+ p.Enabled = enabledInt != 0
+ p.InstalledAt = parseSQLiteTime(installedAt)
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (d *DB) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) {
+ row := d.sqlDB.QueryRowContext(ctx,
+ `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`,
+ pluginID, key,
+ )
+ var v []byte
+ if err := row.Scan(&v); err != nil {
+ return nil, err
+ }
+ return v, nil
+}
+
+func (d *DB) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error {
+ _, err := d.sqlDB.ExecContext(ctx,
+ `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?)
+ ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`,
+ pluginID, key, value,
+ )
+ return err
+}
+
+func (d *DB) PluginKVDelete(ctx context.Context, pluginID int64, key string) error {
+ _, err := d.sqlDB.ExecContext(ctx,
+ `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`,
+ pluginID, key,
+ )
+ return err
+}
+
+func (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) {
+ rows, err := d.sqlDB.QueryContext(ctx,
+ `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`,
+ pluginID, prefix+"%", limit,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("PluginKVScan: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ out := make(map[string][]byte)
+ for rows.Next() {
+ var k string
+ var v []byte
+ if err := rows.Scan(&k, &v); err != nil {
+ return nil, err
+ }
+ out[k] = v
+ }
+ return out, rows.Err()
+}
+
+// rowScanner is satisfied by *sql.Row and *sql.Rows.
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanPluginRow(row rowScanner) (*PluginRow, error) {
+ var p PluginRow
+ var enabledInt int64
+ var installedAt string
+ if err := row.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil {
+ return nil, err
+ }
+ p.Enabled = enabledInt != 0
+ p.InstalledAt = parseSQLiteTime(installedAt)
+ return &p, nil
+}
diff --git a/Server/main.go b/Server/main.go
index e983d5c3..05f75400 100644
--- a/Server/main.go
+++ b/Server/main.go
@@ -25,7 +25,6 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/plugin"
"github.com/owncord/server/storage"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
"github.com/owncord/server/ws"
)
@@ -143,12 +142,6 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
}
}()
- // ── 5. Construct shared store wrapper ──────────────────────────────────
- // Used by both the event persistence layer and the plugin runtime. Once
- // the Phase A "store everywhere" refactor lands, NewRouter will accept
- // store.Store directly and this wrapper goes away.
- storeWrapper := store.NewSQLiteStore(database)
-
// ── 5a. Construct plugin runtime BEFORE the router so the router can
// wire the live registry into the plugin admin handler. ────────────────
var pluginRegistry *plugin.Registry
@@ -158,7 +151,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
MaxMemoryMB: cfg.Plugins.MaxMemoryMB,
CPUBudgetMs: cfg.Plugins.CPUBudgetMs,
HTTPAllowlist: cfg.Plugins.HTTPAllowlist,
- Store: storeWrapper,
+ Store: database,
})
if plugErr != nil {
log.Warn("plugin runtime init failed; continuing without plugins", "error", plugErr)
@@ -186,7 +179,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
// this, the events table accumulates rows whose payload seqs reset
// to 1 after every restart, breaking the reconnect "events since
// last_seq" contract.
- if maxSeq, seedErr := storeWrapper.GetMaxEventSeq(bgCtx); seedErr != nil {
+ if maxSeq, seedErr := database.GetMaxEventSeq(bgCtx); seedErr != nil {
log.Warn("event persistence: failed to read MAX(events.seq); starting hub seq from 0", "error", seedErr)
} else if maxSeq > 0 {
hub.SeedSeq(uint64(maxSeq))
@@ -194,18 +187,18 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
}
persister := ws.NewEventPersister(
- storeWrapper,
+ database,
4096,
cfg.EventPersistence.BatchSize,
time.Duration(cfg.EventPersistence.BatchFlushMs)*time.Millisecond,
)
persister.Start(bgCtx)
hub.SetEventPersister(persister)
- hub.SetEventStore(storeWrapper)
+ hub.SetEventStore(database)
retention := time.Duration(cfg.EventPersistence.RetentionHours) * time.Hour
prunerInterval := time.Duration(cfg.EventPersistence.PrunerIntervalMinutes) * time.Minute
- ws.StartEventPruner(bgCtx, storeWrapper, retention, prunerInterval)
+ ws.StartEventPruner(bgCtx, database, retention, prunerInterval)
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stopCancel()
diff --git a/Server/plugin/dbstore_test.go b/Server/plugin/dbstore_test.go
new file mode 100644
index 00000000..48331c69
--- /dev/null
+++ b/Server/plugin/dbstore_test.go
@@ -0,0 +1,24 @@
+package plugin
+
+import (
+ "testing"
+
+ "github.com/owncord/server/db"
+)
+
+// openPluginTestDB opens an in-memory database with the full migration set
+// applied (the plugins and plugin_kv tables live in migration 015). *db.DB
+// satisfies the PluginStore interface directly — D3 removed the store
+// abstraction and its MemStore fake, so plugin tests run against a real DB.
+func openPluginTestDB(t *testing.T) *db.DB {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ if err := db.Migrate(database); err != nil {
+ t.Fatalf("db.Migrate: %v", err)
+ }
+ return database
+}
diff --git a/Server/plugin/host_storage.go b/Server/plugin/host_storage.go
index ccd15047..ae31831f 100644
--- a/Server/plugin/host_storage.go
+++ b/Server/plugin/host_storage.go
@@ -12,8 +12,8 @@ import (
)
const (
- maxPluginValueBytes = 64 * 1024 // 64 KB per value
- maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
+ maxPluginValueBytes = 64 * 1024 // 64 KB per value
+ maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
)
// StoragePut writes a single key/value pair on behalf of inst.
diff --git a/Server/plugin/plugin_test.go b/Server/plugin/plugin_test.go
index 518b8be7..4bd6bb47 100644
--- a/Server/plugin/plugin_test.go
+++ b/Server/plugin/plugin_test.go
@@ -15,8 +15,6 @@ import (
"os"
"path/filepath"
"testing"
-
- "github.com/owncord/server/store"
)
func TestParseManifestRoundTrip(t *testing.T) {
@@ -100,7 +98,7 @@ func TestRegistryInstallFromDisk(t *testing.T) {
0o644)
_ = os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644)
- mem := store.NewMemStore()
+ mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{Directory: dir, Store: mem})
if err != nil {
t.Fatal(err)
@@ -118,7 +116,7 @@ func TestRegistryInstallFromDisk(t *testing.T) {
}
func TestStorageGatedByCapability(t *testing.T) {
- mem := store.NewMemStore()
+ mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{Store: mem})
if err != nil {
t.Fatal(err)
@@ -152,7 +150,7 @@ func TestStorageGatedByCapability(t *testing.T) {
// reinstall and ownership compared by plugin identity — the same plugin can
// re-bind its own commands while a different plugin still cannot hijack them.
func TestReinstallRebindsCommands(t *testing.T) {
- mem := store.NewMemStore()
+ mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
diff --git a/Server/plugin/pluginstore.go b/Server/plugin/pluginstore.go
new file mode 100644
index 00000000..b7418179
--- /dev/null
+++ b/Server/plugin/pluginstore.go
@@ -0,0 +1,25 @@
+package plugin
+
+import (
+ "context"
+
+ "github.com/owncord/server/db"
+)
+
+// PluginStore manages installed plugins and per-plugin KV namespaces.
+// *db.DB satisfies it (the methods moved into the db package when the store
+// abstraction was removed in D3).
+type PluginStore interface {
+ InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error)
+ EnablePlugin(ctx context.Context, id int64) error
+ DisablePlugin(ctx context.Context, id int64) error
+ UninstallPlugin(ctx context.Context, id int64) error
+ GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error)
+ GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error)
+ ListPlugins(ctx context.Context) ([]db.PluginRow, error)
+
+ PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error)
+ PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error
+ PluginKVDelete(ctx context.Context, pluginID int64, key string) error
+ PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error)
+}
diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go
index 4ab01d43..5c798e8e 100644
--- a/Server/plugin/registry.go
+++ b/Server/plugin/registry.go
@@ -22,8 +22,6 @@ import (
"path/filepath"
"strings"
"sync"
-
- "github.com/owncord/server/store"
)
// Config is the runtime configuration sourced from PluginsConfig.
@@ -32,7 +30,7 @@ type Config struct {
MaxMemoryMB int
CPUBudgetMs int
HTTPAllowlist []string
- Store store.PluginStore
+ Store PluginStore
}
// Registry is the central plugin coordinator.
diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go
index 5c91dbf3..af5077b2 100644
--- a/Server/plugin/sandbox_wazero_test.go
+++ b/Server/plugin/sandbox_wazero_test.go
@@ -25,8 +25,6 @@ import (
"path/filepath"
"strings"
"testing"
-
- "github.com/owncord/server/store"
)
// addWASM is the bytes of a minimal (module (func (export "add") ... )).
@@ -55,9 +53,9 @@ func writeTestPlugin(t *testing.T, root, name string, manifest string, wasmBytes
}
}
-func newWazeroTestRegistry(t *testing.T, dir string) (*Registry, store.PluginStore) {
+func newWazeroTestRegistry(t *testing.T, dir string) (*Registry, PluginStore) {
t.Helper()
- mem := store.NewMemStore()
+ mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{
Directory: dir,
MaxMemoryMB: 16,
diff --git a/Server/service/block.go b/Server/service/block.go
index f60079f7..f57988d1 100644
--- a/Server/service/block.go
+++ b/Server/service/block.go
@@ -6,17 +6,16 @@ import (
"log/slog"
"time"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// BlockService handles user block/unblock operations.
type BlockService struct {
- st store.Store
+ st Store
}
// NewBlockService creates a BlockService.
-func NewBlockService(st store.Store) *BlockService {
+func NewBlockService(st Store) *BlockService {
return &BlockService{st: st}
}
diff --git a/Server/service/channel.go b/Server/service/channel.go
index f29e4cb4..04fe6692 100644
--- a/Server/service/channel.go
+++ b/Server/service/channel.go
@@ -8,19 +8,18 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// ChannelService handles channel-related business logic including
// listing, permission-filtered access, typing, presence, and read state.
type ChannelService struct {
- st store.Store
+ st Store
perms *PermissionService
}
// NewChannelService creates a ChannelService.
-func NewChannelService(st store.Store, perms *PermissionService) *ChannelService {
+func NewChannelService(st Store, perms *PermissionService) *ChannelService {
return &ChannelService{
st: st,
perms: perms,
diff --git a/Server/store/store.go b/Server/service/datastore.go
similarity index 57%
rename from Server/store/store.go
rename to Server/service/datastore.go
index 3d6c9df3..f5a8f203 100644
--- a/Server/store/store.go
+++ b/Server/service/datastore.go
@@ -1,47 +1,23 @@
-// Package store defines the database abstraction layer for OwnCord.
-// The Store interface decouples services from the concrete database
-// implementation, enabling SQLite (default) and future PostgreSQL support.
-package store
+package service
import (
"context"
- "database/sql"
"time"
"github.com/owncord/server/db"
)
-// Store is the top-level interface combining all domain-specific stores.
-// Services accept Store instead of *db.DB, enabling swappable backends.
+// Store is the data-access surface the service layer depends on. It is the
+// set of *db.DB methods the services call — no event/plugin/transaction
+// methods, which belong to other layers. *db.DB satisfies this interface
+// directly (D3 removed the former store package's pass-through wrapper), and
+// tests inject fakes that embed a real in-memory *db.DB and override the one
+// method they need to exercise an error path.
+//
+// permissions.NewChecker takes its own narrower interface (permissions.DB),
+// which *db.DB and this Store both satisfy.
type Store interface {
- MessageStore
- ChannelStore
- UserStore
- SessionStore
- RoleStore
- InviteStore
- VoiceStore
- DMStore
- BlockStore
- AttachmentStore
- AdminStore
- SettingsStore
- EventStore
- PluginStore
-
- // Close releases the underlying database connection.
- Close() error
-
- // WithTx executes fn within a transaction. The transaction is committed
- // if fn returns nil, rolled back otherwise.
- WithTx(ctx context.Context, fn func(Store) error) error
-
- // Raw access for callers that need it (migration, backup, etc.).
- SQLDb() *sql.DB
-}
-
-// MessageStore handles message CRUD, reactions, search, and read state.
-type MessageStore interface {
+ // ── Messages / reactions / read-state ──
CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error)
GetMessage(id int64) (*db.Message, error)
GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error)
@@ -60,10 +36,8 @@ type MessageStore interface {
GetLatestMessageID(channelID int64) (int64, error)
LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error)
GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error)
-}
-// ChannelStore handles channel CRUD and permission overrides.
-type ChannelStore interface {
+ // ── Channels ──
ListChannels() ([]db.Channel, error)
GetChannel(id int64) (*db.Channel, error)
CreateChannel(name, chanType, category, topic string, position int) (int64, error)
@@ -74,10 +48,8 @@ type ChannelStore interface {
GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error)
GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error)
GetChannelTypes(ids []int64) (map[int64]string, error)
-}
-// UserStore handles user lookup and profile operations.
-type UserStore interface {
+ // ── Users ──
GetUserByID(id int64) (*db.User, error)
GetUserByUsername(username string) (*db.User, error)
CreateUser(username, passwordHash string, roleID int) (int64, error)
@@ -91,10 +63,8 @@ type UserStore interface {
ResetAllUserStatuses() error
DeleteAccount(ctx context.Context, userID int64) error
ListMembers() ([]db.MemberSummary, error)
-}
-// SessionStore handles authentication session management.
-type SessionStore interface {
+ // ── Sessions ──
CreateSession(userID int64, tokenHash, device, ip string) (int64, error)
GetSessionByTokenHash(tokenHash string) (*db.Session, error)
GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error)
@@ -106,27 +76,21 @@ type SessionStore interface {
ListUserSessions(userID int64) ([]db.Session, error)
ForceLogoutUser(userID int64) error
GetUserSessions(userID int64) ([]db.Session, error)
-}
-// RoleStore handles role lookups.
-type RoleStore interface {
+ // ── Roles ──
GetRoleByID(id int64) (*db.Role, error)
GetRoleForUser(userID int64) (*db.Role, error)
GetUserWithRole(userID int64) (*db.User, *db.Role, error)
ListRoles() ([]*db.Role, error)
-}
-// InviteStore handles invite management.
-type InviteStore interface {
+ // ── Invites ──
CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error)
GetInvite(code string) (*db.Invite, error)
ListInvites() ([]*db.Invite, error)
UseInviteAtomic(code string) error
RevokeInvite(code string) error
-}
-// VoiceStore handles voice state management.
-type VoiceStore interface {
+ // ── Voice ──
JoinVoiceChannel(userID, channelID int64) error
JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error
LeaveVoiceChannel(userID int64) error
@@ -143,10 +107,8 @@ type VoiceStore interface {
EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error)
UpdateVoiceScreenshare(userID int64, screenshare bool) error
CountChannelVoiceUsers(channelID int64) (int, error)
-}
-// DMStore handles direct message channels.
-type DMStore interface {
+ // ── Direct messages ──
GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error)
GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error)
OpenDM(userID, channelID int64) error
@@ -154,27 +116,21 @@ type DMStore interface {
IsDMParticipant(userID, channelID int64) (bool, error)
GetDMParticipantIDs(channelID int64) ([]int64, error)
GetDMRecipient(channelID, requestingUserID int64) (*db.User, error)
-}
-// BlockStore handles user blocks.
-type BlockStore interface {
+ // ── Blocks ──
BlockUser(blockerID, blockedID int64) error
UnblockUser(blockerID, blockedID int64) error
IsBlocked(blockerID, blockedID int64) (bool, error)
IsEitherBlocked(userA, userB int64) (bool, error)
ListBlockedUsers(blockerID int64) ([]int64, error)
-}
-// AttachmentStore handles file attachment metadata.
-type AttachmentStore interface {
+ // ── Attachments ──
CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error
GetAttachmentByID(id string) (*db.Attachment, error)
GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error)
DeleteOrphanedAttachments(cutoff string) ([]string, error)
-}
-// AdminStore handles admin operations.
-type AdminStore interface {
+ // ── Admin ──
UserCount() (int64, error)
GetServerStats() (*db.ServerStats, error)
ListAllUsers(limit, offset int) ([]db.UserWithRole, error)
@@ -188,62 +144,9 @@ type AdminStore interface {
BackupTo(path string) error
BackupToSafe(path, safeRoot string) error
CountUsersWithoutTOTP() (int, error)
-}
-// SettingsStore handles server settings.
-type SettingsStore interface {
+ // ── Settings ──
GetSetting(key string) (string, error)
SetSetting(key, value string) error
GetAllSettings() (map[string]string, error)
}
-
-// EventStore persists broadcast events for cold-replay during reconnection
-// when the in-memory ring buffer no longer covers the client's last_seq.
-//
-// Phase B Step 7 — Event Persistence Layer.
-type EventStore interface {
- // PersistEvent appends an event with the hub-assigned seq. seq must be
- // the same monotonic counter the wrapped payload exposes to clients so
- // that cold-replay queries by seq return rows whose payload seq matches
- // the row seq. channelID == 0 means the event was a global broadcast.
- // Implementations should be tolerant of out-of-order seq insertion (e.g.
- // they SHOULD NOT rely on AUTOINCREMENT semantics).
- PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error
-
- // GetEventsSince returns up to limit events with seq > afterSeq, ordered
- // by seq ascending. Used as a fallback after the ring buffer misses.
- GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error)
-
- // GetEventsSinceForChannels returns up to limit events with seq > afterSeq
- // whose channel_id is in channelIDs OR is 0 (global broadcasts), ordered by
- // seq ascending. Mirrors EventRingBuffer.EventsSinceFiltered.
- GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error)
-
- // PruneEventsOlderThan deletes events with created_at < cutoff. Returns the
- // number of deleted rows. Called periodically by the pruner goroutine.
- PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
-
- // GetMaxEventSeq returns the largest seq in the events table, or 0 if the
- // table is empty. Used at startup to seed the hub's in-memory monotonic
- // counter so wrapped-payload seqs stay aligned with row seqs across
- // restarts. Returns 0 (without error) when the table is empty.
- GetMaxEventSeq(ctx context.Context) (int64, error)
-}
-
-// PluginStore manages installed plugins and per-plugin KV namespaces.
-//
-// Phase C Step 9 — Wazero Plugin Runtime.
-type PluginStore interface {
- InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error)
- EnablePlugin(ctx context.Context, id int64) error
- DisablePlugin(ctx context.Context, id int64) error
- UninstallPlugin(ctx context.Context, id int64) error
- GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error)
- GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error)
- ListPlugins(ctx context.Context) ([]db.PluginRow, error)
-
- PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error)
- PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error
- PluginKVDelete(ctx context.Context, pluginID int64, key string) error
- PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error)
-}
diff --git a/Server/service/dm.go b/Server/service/dm.go
index 0cb4ad75..89a7d993 100644
--- a/Server/service/dm.go
+++ b/Server/service/dm.go
@@ -7,17 +7,16 @@ import (
"time"
"github.com/owncord/server/db"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// DMService handles direct message channel operations.
type DMService struct {
- st store.Store
+ st Store
}
// NewDMService creates a DMService.
-func NewDMService(st store.Store) *DMService {
+func NewDMService(st Store) *DMService {
return &DMService{st: st}
}
diff --git a/Server/service/invite.go b/Server/service/invite.go
index bebbd4f2..88a2b87a 100644
--- a/Server/service/invite.go
+++ b/Server/service/invite.go
@@ -6,17 +6,16 @@ import (
"time"
"github.com/owncord/server/db"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// InviteService handles invite management.
type InviteService struct {
- st store.Store
+ st Store
}
// NewInviteService creates an InviteService.
-func NewInviteService(st store.Store) *InviteService {
+func NewInviteService(st Store) *InviteService {
return &InviteService{st: st}
}
diff --git a/Server/service/message.go b/Server/service/message.go
index 4a428e66..ea559326 100644
--- a/Server/service/message.go
+++ b/Server/service/message.go
@@ -12,7 +12,6 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
@@ -100,13 +99,13 @@ type ReactionResult struct {
// MessageService handles message-related business logic including
// send, edit, delete, reactions, pins, and search.
type MessageService struct {
- st store.Store
+ st Store
perms *PermissionService
limiter *auth.RateLimiter
}
// NewMessageService creates a MessageService.
-func NewMessageService(st store.Store, perms *PermissionService, limiter *auth.RateLimiter) *MessageService {
+func NewMessageService(st Store, perms *PermissionService, limiter *auth.RateLimiter) *MessageService {
return &MessageService{
st: st,
perms: perms,
diff --git a/Server/service/message_test.go b/Server/service/message_test.go
index 41a0020c..594d0faf 100644
--- a/Server/service/message_test.go
+++ b/Server/service/message_test.go
@@ -8,32 +8,33 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
)
-// newTestMessageService creates a MessageService with a MemStore pre-populated
-// with one text channel, one user, and a member role that has basic permissions.
-// The rate limiter is nil (disabled) to avoid flakiness in unit tests.
-func newTestMessageService() (*MessageService, *store.MemStore) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+// newTestMessageService creates a MessageService against a real in-memory DB
+// pre-populated with one text channel, one user, and a member role that has
+// basic permissions. The rate limiter is nil (disabled) to avoid flakiness in
+// unit tests.
+func newTestMessageService(t *testing.T) (*MessageService, *db.DB) {
+ t.Helper()
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
Position: 1,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice", Status: "online"})
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
- checker := permissions.NewChecker(ms)
- permSvc := NewPermissionService(ms, checker)
- msgSvc := NewMessageService(ms, permSvc, nil)
- return msgSvc, ms
+ checker := permissions.NewChecker(database)
+ permSvc := NewPermissionService(database, checker)
+ msgSvc := NewMessageService(database, permSvc, nil)
+ return msgSvc, database
}
func TestSendMessage_Valid(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
result, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
@@ -60,25 +61,25 @@ func TestSendMessage_Valid(t *testing.T) {
// gate delegates to CanPost, so a blocked user is refused from posting into
// a DM — the old broadcast gate's DM branch skipped the block check entirely.
func TestCanPost_DMBlockEnforced(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID, Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages, Position: 1,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUserRole(2, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice"})
- ms.SeedUser(&db.User{ID: 2, Username: "bob"})
- ms.SeedChannel(&db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"})
- ms.SeedDMParticipant(50, 1)
- ms.SeedDMParticipant(50, 2)
- checker := permissions.NewChecker(ms)
- svc := NewMessageService(ms, NewPermissionService(ms, checker), nil)
+ seedUser(t, database, &db.User{ID: 1, Username: "alice"})
+ seedUser(t, database, &db.User{ID: 2, Username: "bob"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedUserRole(t, database, 2, permissions.MemberRoleID)
+ seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"})
+ seedDMParticipant(t, database, 50, 1)
+ seedDMParticipant(t, database, 50, 2)
+ checker := permissions.NewChecker(database)
+ svc := NewMessageService(database, NewPermissionService(database, checker), nil)
if err := svc.CanPost(1, 50); err != nil {
t.Fatalf("unblocked DM participant should be allowed: %v", err)
}
- ms.SeedBlock(2, 1) // bob blocks alice
+ seedBlock(t, database, 2, 1) // bob blocks alice
if err := svc.CanPost(1, 50); !errors.Is(err, ErrBlocked) {
t.Fatalf("blocked user must be refused: got %v", err)
}
@@ -93,16 +94,16 @@ func TestCanPost_DMBlockEnforced(t *testing.T) {
// TestCanPost_ChannelPermissionRequired: regular channels still require
// READ|SEND via the cached checker.
func TestCanPost_ChannelPermissionRequired(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID, Name: "member",
Permissions: permissions.ReadMessages, Position: 1, // no SendMessages
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice"})
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
- checker := permissions.NewChecker(ms)
- svc := NewMessageService(ms, NewPermissionService(ms, checker), nil)
+ seedUser(t, database, &db.User{ID: 1, Username: "alice"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
+ checker := permissions.NewChecker(database)
+ svc := NewMessageService(database, NewPermissionService(database, checker), nil)
if err := svc.CanPost(1, 10); !errors.Is(err, ErrForbidden) {
t.Fatalf("missing SEND_MESSAGES must refuse: got %v", err)
@@ -113,22 +114,22 @@ func TestCanPost_ChannelPermissionRequired(t *testing.T) {
// postable only by users with MANAGE_MESSAGES, even when they hold
// READ|SEND. A plain member is refused; a moderator with MANAGE_MESSAGES posts.
func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID, Name: "member",
Permissions: permissions.ReadMessages | permissions.SendMessages, Position: 1,
})
- ms.SeedRole(&db.Role{
+ seedRole(t, database, &db.Role{
ID: permissions.ModeratorRoleID, Name: "moderator",
Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages, Position: 60,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUserRole(2, permissions.ModeratorRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice"})
- ms.SeedUser(&db.User{ID: 2, Username: "mod"})
- ms.SeedChannel(&db.Channel{ID: 20, Name: "announcements", Type: "announcement"})
- checker := permissions.NewChecker(ms)
- svc := NewMessageService(ms, NewPermissionService(ms, checker), nil)
+ seedUser(t, database, &db.User{ID: 1, Username: "alice"})
+ seedUser(t, database, &db.User{ID: 2, Username: "mod"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedUserRole(t, database, 2, permissions.ModeratorRoleID)
+ seedChannel(t, database, &db.Channel{ID: 20, Name: "announcements", Type: "announcement"})
+ checker := permissions.NewChecker(database)
+ svc := NewMessageService(database, NewPermissionService(database, checker), nil)
// Member has READ|SEND but not MANAGE_MESSAGES → refused in an announcement channel.
if err := svc.CanPost(1, 20); !errors.Is(err, ErrForbidden) {
@@ -145,25 +146,25 @@ func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) {
// nonexistent attachment is skipped (never linked) while the message still
// sends — no check-then-link race, and retries cannot hard-fail.
func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AttachFiles,
Position: 1,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUserRole(2, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice", Status: "online"})
- ms.SeedUser(&db.User{ID: 2, Username: "mallory", Status: "online"})
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
- checker := permissions.NewChecker(ms)
- svc := NewMessageService(ms, NewPermissionService(ms, checker), nil)
+ seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"})
+ seedUser(t, database, &db.User{ID: 2, Username: "mallory", Status: "online"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedUserRole(t, database, 2, permissions.MemberRoleID)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
+ checker := permissions.NewChecker(database)
+ svc := NewMessageService(database, NewPermissionService(database, checker), nil)
- if err := ms.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil {
+ if err := database.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil {
t.Fatal(err)
}
- if err := ms.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil {
+ if err := database.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil {
t.Fatal(err)
}
@@ -179,11 +180,11 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) {
t.Fatal("message should persist even when some attachments are skipped")
}
- own, _ := ms.GetAttachmentByID("att-own")
+ own, _ := database.GetAttachmentByID("att-own")
if own.MessageID == nil || *own.MessageID != result.MessageID {
t.Error("sender's own attachment should be linked to the new message")
}
- foreign, _ := ms.GetAttachmentByID("att-foreign")
+ foreign, _ := database.GetAttachmentByID("att-foreign")
if foreign.MessageID != nil {
t.Error("another user's attachment must never be linked (IDOR guard)")
}
@@ -200,14 +201,14 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) {
if retry.MessageID <= 0 {
t.Fatal("retry should persist a message")
}
- own2, _ := ms.GetAttachmentByID("att-own")
+ own2, _ := database.GetAttachmentByID("att-own")
if own2.MessageID == nil || *own2.MessageID != result.MessageID {
t.Error("already-linked attachment must stay linked to the original message")
}
}
func TestSendMessage_EmptyContent(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
_, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
@@ -225,7 +226,7 @@ func TestSendMessage_EmptyContent(t *testing.T) {
}
func TestSendMessage_ExceedsMaxLength(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
// maxMessageLen is 4000 runes; create content that exceeds it.
longContent := strings.Repeat("a", 4001)
@@ -246,7 +247,7 @@ func TestSendMessage_ExceedsMaxLength(t *testing.T) {
}
func TestSendMessage_ChannelNotFound(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
_, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 999,
@@ -263,7 +264,7 @@ func TestSendMessage_ChannelNotFound(t *testing.T) {
}
func TestSendMessage_InvalidChannelID(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
_, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 0,
@@ -280,21 +281,21 @@ func TestSendMessage_InvalidChannelID(t *testing.T) {
}
func TestSendMessage_NoPermission(t *testing.T) {
- ms := store.NewMemStore()
+ database := newTestDB(t)
// Role with ReadMessages only (no SendMessages).
- ms.SeedRole(&db.Role{
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.ReadMessages,
Position: 1,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice"})
- ms.SeedChannel(&db.Channel{ID: 10, Name: "readonly", Type: "text"})
+ seedUser(t, database, &db.User{ID: 1, Username: "alice"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"})
- checker := permissions.NewChecker(ms)
- permSvc := NewPermissionService(ms, checker)
- svc := NewMessageService(ms, permSvc, nil)
+ checker := permissions.NewChecker(database)
+ permSvc := NewPermissionService(database, checker)
+ svc := NewMessageService(database, permSvc, nil)
_, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
@@ -311,7 +312,7 @@ func TestSendMessage_NoPermission(t *testing.T) {
}
func TestEditMessage_OwnerCanEdit(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
// Send a message first.
result, err := svc.SendMessage(context.Background(), SendMessageParams{
@@ -337,10 +338,10 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) {
}
func TestEditMessage_NonOwnerFails(t *testing.T) {
- svc, ms := newTestMessageService()
+ svc, database := newTestMessageService(t)
// Add a second user.
- ms.SeedUserRole(2, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 2, Username: "bob"})
+ seedUser(t, database, &db.User{ID: 2, Username: "bob"})
+ seedUserRole(t, database, 2, permissions.MemberRoleID)
// User 1 sends a message.
result, err := svc.SendMessage(context.Background(), SendMessageParams{
@@ -364,7 +365,7 @@ func TestEditMessage_NonOwnerFails(t *testing.T) {
}
func TestEditMessage_EmptyContentFails(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
result, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
@@ -386,7 +387,7 @@ func TestEditMessage_EmptyContentFails(t *testing.T) {
}
func TestDeleteMessage_OwnerCanDelete(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
result, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
@@ -411,9 +412,9 @@ func TestDeleteMessage_OwnerCanDelete(t *testing.T) {
}
func TestDeleteMessage_NonOwnerWithoutModFails(t *testing.T) {
- svc, ms := newTestMessageService()
- ms.SeedUserRole(2, permissions.MemberRoleID)
- ms.SeedUser(&db.User{ID: 2, Username: "bob"})
+ svc, database := newTestMessageService(t)
+ seedUser(t, database, &db.User{ID: 2, Username: "bob"})
+ seedUserRole(t, database, 2, permissions.MemberRoleID)
// User 1 sends.
result, err := svc.SendMessage(context.Background(), SendMessageParams{
@@ -437,30 +438,30 @@ func TestDeleteMessage_NonOwnerWithoutModFails(t *testing.T) {
}
func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) {
- ms := store.NewMemStore()
+ database := newTestDB(t)
// Mod role has ManageMessages + SendMessages + ReadMessages.
modPerms := permissions.SendMessages | permissions.ReadMessages | permissions.ManageMessages
- ms.SeedRole(&db.Role{
+ seedRole(t, database, &db.Role{
ID: permissions.ModeratorRoleID,
Name: "moderator",
Permissions: modPerms,
Position: 10,
})
- ms.SeedRole(&db.Role{
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages,
Position: 1,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- ms.SeedUserRole(2, permissions.ModeratorRoleID)
- ms.SeedUser(&db.User{ID: 1, Username: "alice"})
- ms.SeedUser(&db.User{ID: 2, Username: "mod_bob"})
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ seedUser(t, database, &db.User{ID: 1, Username: "alice"})
+ seedUser(t, database, &db.User{ID: 2, Username: "mod_bob"})
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ seedUserRole(t, database, 2, permissions.ModeratorRoleID)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
- checker := permissions.NewChecker(ms)
- permSvc := NewPermissionService(ms, checker)
- svc := NewMessageService(ms, permSvc, nil)
+ checker := permissions.NewChecker(database)
+ permSvc := NewPermissionService(database, checker)
+ svc := NewMessageService(database, permSvc, nil)
// User 1 sends a message.
result, err := svc.SendMessage(context.Background(), SendMessageParams{
@@ -484,7 +485,7 @@ func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) {
}
func TestDeleteMessage_InvalidMessageID(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
_, err := svc.DeleteMessage(1, 0)
if err == nil {
@@ -496,7 +497,7 @@ func TestDeleteMessage_InvalidMessageID(t *testing.T) {
}
func TestSendMessage_HTMLSanitized(t *testing.T) {
- svc, _ := newTestMessageService()
+ svc, _ := newTestMessageService(t)
result, err := svc.SendMessage(context.Background(), SendMessageParams{
ChannelID: 10,
diff --git a/Server/service/moderation.go b/Server/service/moderation.go
index d5c692a6..28d0efe5 100644
--- a/Server/service/moderation.go
+++ b/Server/service/moderation.go
@@ -7,18 +7,17 @@ import (
"time"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// ModerationService handles user ban/unban operations.
type ModerationService struct {
- st store.Store
+ st Store
perms *PermissionService
}
// NewModerationService creates a ModerationService.
-func NewModerationService(st store.Store, perms *PermissionService) *ModerationService {
+func NewModerationService(st Store, perms *PermissionService) *ModerationService {
return &ModerationService{st: st, perms: perms}
}
diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go
index 37b316a3..4b008e9c 100644
--- a/Server/service/moderation_test.go
+++ b/Server/service/moderation_test.go
@@ -8,27 +8,27 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
)
-// newTestModerationService seeds a MemStore with a role hierarchy:
+// newTestModerationService seeds a real in-memory DB with a role hierarchy:
// owner (pos 100, Administrator) > mod (pos 80, BanMembers) > member (pos 40).
// Users: 1=owner, 2=mod, 3=member, 4=member, 5=mod (equal rank to 2).
-func newTestModerationService() (*ModerationService, *store.MemStore) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100})
- ms.SeedRole(&db.Role{ID: 2, Name: "mod", Permissions: permissions.BanMembers, Position: 80})
- ms.SeedRole(&db.Role{ID: 3, Name: "member", Permissions: permissions.SendMessages, Position: 40})
+func newTestModerationService(t *testing.T) (*ModerationService, *db.DB) {
+ t.Helper()
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100})
+ seedRole(t, database, &db.Role{ID: 2, Name: "mod", Permissions: permissions.BanMembers, Position: 80})
+ seedRole(t, database, &db.Role{ID: 3, Name: "member", Permissions: permissions.SendMessages, Position: 40})
for userID, roleID := range map[int64]int64{1: 1, 2: 2, 3: 3, 4: 3, 5: 2} {
- ms.SeedUserRole(userID, roleID)
- ms.SeedUser(&db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"})
+ seedUser(t, database, &db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"})
+ seedUserRole(t, database, userID, roleID)
}
- checker := permissions.NewChecker(ms)
- return NewModerationService(ms, NewPermissionService(ms, checker)), ms
+ checker := permissions.NewChecker(database)
+ return NewModerationService(database, NewPermissionService(database, checker)), database
}
func TestBanUser_RequiresBanPermission(t *testing.T) {
- svc, _ := newTestModerationService()
+ svc, _ := newTestModerationService(t)
// A member without BAN_MEMBERS is refused.
if err := svc.BanUser(context.Background(), 3, 4, "nope", nil); !errors.Is(err, ErrForbidden) {
@@ -42,7 +42,7 @@ func TestBanUser_RequiresBanPermission(t *testing.T) {
}
func TestBanUser_HierarchyEnforced(t *testing.T) {
- svc, ms := newTestModerationService()
+ svc, database := newTestModerationService(t)
// Equal rank: mod cannot ban mod.
if err := svc.BanUser(context.Background(), 2, 5, "peer", nil); !errors.Is(err, ErrForbidden) {
@@ -52,19 +52,19 @@ func TestBanUser_HierarchyEnforced(t *testing.T) {
if err := svc.BanUser(context.Background(), 2, 1, "coup", nil); !errors.Is(err, ErrForbidden) {
t.Fatalf("ban owner: want ErrForbidden, got %v", err)
}
- owner, _ := ms.GetUserByID(1)
+ owner, _ := database.GetUserByID(1)
if owner.Banned {
t.Fatal("owner must not be banned")
}
}
func TestBanUser_AuthorizedSucceeds(t *testing.T) {
- svc, ms := newTestModerationService()
+ svc, database := newTestModerationService(t)
if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil {
t.Fatalf("authorized ban: %v", err)
}
- target, _ := ms.GetUserByID(3)
+ target, _ := database.GetUserByID(3)
if !target.Banned {
t.Fatal("target should be banned")
}
@@ -83,7 +83,7 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) {
}
func TestUnbanUser_AuthorizationMatrix(t *testing.T) {
- svc, ms := newTestModerationService()
+ svc, database := newTestModerationService(t)
if err := svc.BanUser(context.Background(), 1, 3, "setup", nil); err != nil {
t.Fatalf("setup ban: %v", err)
@@ -101,7 +101,7 @@ func TestUnbanUser_AuthorizationMatrix(t *testing.T) {
if err := svc.UnbanUser(context.Background(), 2, 3); err != nil {
t.Fatalf("authorized unban: %v", err)
}
- target, _ := ms.GetUserByID(3)
+ target, _ := database.GetUserByID(3)
if target.Banned {
t.Fatal("target should be unbanned")
}
diff --git a/Server/service/permission.go b/Server/service/permission.go
index 2671ba7d..ba1389b1 100644
--- a/Server/service/permission.go
+++ b/Server/service/permission.go
@@ -7,7 +7,6 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
@@ -27,7 +26,7 @@ const permCacheTTL = 30 * time.Second
// at scale. The cache is populated lazily on first access and invalidated
// on role or channel override changes.
type PermissionService struct {
- st store.Store
+ st Store
checker *permissions.Checker
mu sync.RWMutex
@@ -35,7 +34,7 @@ type PermissionService struct {
}
// NewPermissionService creates a PermissionService backed by the given DB.
-func NewPermissionService(st store.Store, checker *permissions.Checker) *PermissionService {
+func NewPermissionService(st Store, checker *permissions.Checker) *PermissionService {
return &PermissionService{
st: st,
checker: checker,
diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go
index b1e58c4c..c5898dfa 100644
--- a/Server/service/permission_test.go
+++ b/Server/service/permission_test.go
@@ -6,27 +6,27 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
)
-// newTestPermService creates a PermissionService backed by a MemStore
+// newTestPermService creates a PermissionService backed by a real in-memory DB
// pre-populated with a single role and user.
-func newTestPermService() (*PermissionService, *store.MemStore) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+func newTestPermService(t *testing.T) (*PermissionService, *db.DB) {
+ t.Helper()
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.MemberRoleID,
Name: "member",
Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions,
Position: 1,
})
- ms.SeedUserRole(1, permissions.MemberRoleID)
- checker := permissions.NewChecker(ms)
- return NewPermissionService(ms, checker), ms
+ seedUserRole(t, database, 1, permissions.MemberRoleID)
+ checker := permissions.NewChecker(database)
+ return NewPermissionService(database, checker), database
}
func TestHasChannelPerm_Allowed(t *testing.T) {
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply.
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
@@ -38,8 +38,8 @@ func TestHasChannelPerm_Allowed(t *testing.T) {
}
func TestHasChannelPerm_Denied(t *testing.T) {
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// ManageMessages is NOT in the member role.
if svc.HasChannelPerm(1, 10, permissions.ManageMessages) {
@@ -48,10 +48,10 @@ func TestHasChannelPerm_Denied(t *testing.T) {
}
func TestHasChannelPerm_OverrideDeny(t *testing.T) {
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "readonly", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"})
// Deny SendMessages for this channel.
- ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages)
+ seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Invalidate so next check re-populates cache.
svc.InvalidateAll()
@@ -65,10 +65,10 @@ func TestHasChannelPerm_OverrideDeny(t *testing.T) {
}
func TestHasChannelPerm_OverrideAllow(t *testing.T) {
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "special", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "special", Type: "text"})
// Allow ManageMessages (not in base role) via channel override.
- ms.SeedChannelOverride(permissions.MemberRoleID, 10, permissions.ManageMessages, 0)
+ seedChannelOverride(t, database, permissions.MemberRoleID, 10, permissions.ManageMessages, 0)
svc.InvalidateAll()
if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) {
@@ -77,20 +77,20 @@ func TestHasChannelPerm_OverrideAllow(t *testing.T) {
}
func TestHasChannelPerm_AdminBypass(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedRole(&db.Role{
+ database := newTestDB(t)
+ seedRole(t, database, &db.Role{
ID: permissions.AdminRoleID,
Name: "admin",
Permissions: permissions.Administrator,
Position: 90,
})
- ms.SeedUserRole(1, permissions.AdminRoleID)
- checker := permissions.NewChecker(ms)
- svc := NewPermissionService(ms, checker)
+ seedUserRole(t, database, 1, permissions.AdminRoleID)
+ checker := permissions.NewChecker(database)
+ svc := NewPermissionService(database, checker)
- ms.SeedChannel(&db.Channel{ID: 10, Name: "locked", Type: "text"})
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "locked", Type: "text"})
// Deny everything via override; admin should still bypass.
- ms.SeedChannelOverride(permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages)
+ seedChannelOverride(t, database, permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages)
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
t.Fatal("admin should bypass all permission checks")
@@ -101,14 +101,14 @@ func TestHasChannelPerm_AdminBypass(t *testing.T) {
}
func TestInvalidateUser_ClearsCacheForUser(t *testing.T) {
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Populate cache.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
// Now add a deny override.
- ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages)
+ seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Without invalidation, cache still says allowed.
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
@@ -123,17 +123,17 @@ func TestInvalidateUser_ClearsCacheForUser(t *testing.T) {
}
func TestInvalidateAll_ClearsEntireCache(t *testing.T) {
- svc, ms := newTestPermService()
+ svc, database := newTestPermService(t)
// Add a second user.
- ms.SeedUserRole(2, permissions.MemberRoleID)
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ seedUserRole(t, database, 2, permissions.MemberRoleID)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Populate cache for both users.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
svc.HasChannelPerm(2, 10, permissions.SendMessages)
// Add deny override.
- ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages)
+ seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Both still cached as allowed.
if !svc.HasChannelPerm(1, 10, permissions.SendMessages) {
@@ -159,14 +159,14 @@ func TestPermCacheTTLExpiry(t *testing.T) {
// in a unit test, so we verify the structural behavior: after manually
// backdating the populatedAt field the cache should be stale and the
// next check should re-populate from the store.
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// Populate cache.
svc.HasChannelPerm(1, 10, permissions.SendMessages)
// Add deny override.
- ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages)
+ seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages)
// Manually expire the cache entry by backdating populatedAt.
svc.mu.Lock()
@@ -182,8 +182,8 @@ func TestPermCacheTTLExpiry(t *testing.T) {
}
func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) {
- svc, ms := newTestPermService()
- ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"})
+ svc, database := newTestPermService(t)
+ seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"})
// User 999 has no role assigned.
if svc.HasChannelPerm(999, 10, permissions.SendMessages) {
diff --git a/Server/service/seed_test.go b/Server/service/seed_test.go
new file mode 100644
index 00000000..547064e6
--- /dev/null
+++ b/Server/service/seed_test.go
@@ -0,0 +1,181 @@
+package service
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/owncord/server/db"
+)
+
+// This file provides real in-memory SQLite test helpers for the service
+// package. D3 removed the store abstraction (and its MemStore fake); service
+// tests now run against a real *db.DB opened at ":memory:" with all migrations
+// applied. The seed* helpers insert rows directly with explicit IDs so tests
+// keep the fixed identifiers (user 1, channel 10, …) they relied on under the
+// old fake. Migration 001 pre-seeds the four default roles, so seedRole upserts
+// by id to let a test redefine a role's permission bits.
+
+// newTestDB opens an in-memory database with the full migration set applied and
+// registers cleanup. Each test gets an isolated database.
+func newTestDB(t *testing.T) *db.DB {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ if err := db.Migrate(database); err != nil {
+ t.Fatalf("db.Migrate: %v", err)
+ }
+ return database
+}
+
+// seedRole upserts a role by id (overriding a default role's bits when the id
+// collides with one of the migration-seeded defaults).
+func seedRole(t *testing.T, database *db.DB, r *db.Role) {
+ t.Helper()
+ _, err := database.Exec(
+ `INSERT INTO roles (id, name, color, permissions, position, is_default)
+ VALUES (?, ?, ?, ?, ?, 0)
+ ON CONFLICT(id) DO UPDATE SET
+ name=excluded.name,
+ color=excluded.color,
+ permissions=excluded.permissions,
+ position=excluded.position`,
+ r.ID, r.Name, r.Color, r.Permissions, r.Position,
+ )
+ if err != nil {
+ t.Fatalf("seedRole(%d): %v", r.ID, err)
+ }
+}
+
+// seedUserRole assigns a role to a user, creating a minimal user row when one
+// does not already exist. It preserves any identity fields set by an earlier
+// seedUser call and only writes role_id.
+func seedUserRole(t *testing.T, database *db.DB, userID, roleID int64) {
+ t.Helper()
+ _, err := database.Exec(
+ `INSERT INTO users (id, username, password, role_id)
+ VALUES (?, ?, '', ?)
+ ON CONFLICT(id) DO UPDATE SET role_id=excluded.role_id`,
+ userID, seedUsername(userID), roleID,
+ )
+ if err != nil {
+ t.Fatalf("seedUserRole(%d,%d): %v", userID, roleID, err)
+ }
+}
+
+// seedUser upserts a user's identity fields by id without clobbering a role_id
+// assigned by a prior seedUserRole call.
+func seedUser(t *testing.T, database *db.DB, u *db.User) {
+ t.Helper()
+ username := u.Username
+ if username == "" {
+ username = seedUsername(u.ID)
+ }
+ status := u.Status
+ if status == "" {
+ status = "offline"
+ }
+ var banReason any
+ if u.BanReason != nil {
+ banReason = *u.BanReason
+ }
+ var avatar any
+ if u.Avatar != nil {
+ avatar = *u.Avatar
+ }
+ banned := 0
+ if u.Banned {
+ banned = 1
+ }
+ _, err := database.Exec(
+ `INSERT INTO users (id, username, password, avatar, status, banned, ban_reason)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ username=excluded.username,
+ password=excluded.password,
+ avatar=excluded.avatar,
+ status=excluded.status,
+ banned=excluded.banned,
+ ban_reason=excluded.ban_reason`,
+ u.ID, username, u.PasswordHash, avatar, status, banned, banReason,
+ )
+ if err != nil {
+ t.Fatalf("seedUser(%d): %v", u.ID, err)
+ }
+}
+
+// seedChannel upserts a channel by id.
+func seedChannel(t *testing.T, database *db.DB, ch *db.Channel) {
+ t.Helper()
+ ctype := ch.Type
+ if ctype == "" {
+ ctype = "text"
+ }
+ _, err := database.Exec(
+ `INSERT INTO channels (id, name, type, category, topic, position)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ name=excluded.name,
+ type=excluded.type,
+ category=excluded.category,
+ topic=excluded.topic,
+ position=excluded.position`,
+ ch.ID, ch.Name, ctype, nullStr(ch.Category), nullStr(ch.Topic), ch.Position,
+ )
+ if err != nil {
+ t.Fatalf("seedChannel(%d): %v", ch.ID, err)
+ }
+}
+
+// seedChannelOverride sets a per-channel permission override for a role.
+func seedChannelOverride(t *testing.T, database *db.DB, roleID, channelID, allow, deny int64) {
+ t.Helper()
+ _, err := database.Exec(
+ `INSERT INTO channel_overrides (channel_id, role_id, allow, deny)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(channel_id, role_id) DO UPDATE SET
+ allow=excluded.allow,
+ deny=excluded.deny`,
+ channelID, roleID, allow, deny,
+ )
+ if err != nil {
+ t.Fatalf("seedChannelOverride(role=%d,chan=%d): %v", roleID, channelID, err)
+ }
+}
+
+// seedDMParticipant adds a user as a participant of a DM channel.
+func seedDMParticipant(t *testing.T, database *db.DB, channelID, userID int64) {
+ t.Helper()
+ _, err := database.Exec(
+ `INSERT OR IGNORE INTO dm_participants (channel_id, user_id) VALUES (?, ?)`,
+ channelID, userID,
+ )
+ if err != nil {
+ t.Fatalf("seedDMParticipant(chan=%d,user=%d): %v", channelID, userID, err)
+ }
+}
+
+// seedBlock records that blockerID has blocked blockedID.
+func seedBlock(t *testing.T, database *db.DB, blockerID, blockedID int64) {
+ t.Helper()
+ _, err := database.Exec(
+ `INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`,
+ blockerID, blockedID,
+ )
+ if err != nil {
+ t.Fatalf("seedBlock(%d,%d): %v", blockerID, blockedID, err)
+ }
+}
+
+func seedUsername(id int64) string {
+ return "seeduser" + strconv.FormatInt(id, 10)
+}
+
+func nullStr(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
diff --git a/Server/service/service.go b/Server/service/service.go
index 113fa924..2799ec41 100644
--- a/Server/service/service.go
+++ b/Server/service/service.go
@@ -7,7 +7,6 @@ package service
import (
"github.com/owncord/server/auth"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
)
// Services bundles all domain services for dependency injection.
@@ -25,7 +24,7 @@ type Services struct {
}
// New creates all domain services wired together.
-func New(st store.Store, limiter *auth.RateLimiter) *Services {
+func New(st Store, limiter *auth.RateLimiter) *Services {
permChecker := permissions.NewChecker(st)
permSvc := NewPermissionService(st, permChecker)
return &Services{
diff --git a/Server/service/user.go b/Server/service/user.go
index 4332556d..76948726 100644
--- a/Server/service/user.go
+++ b/Server/service/user.go
@@ -8,17 +8,16 @@ import (
"time"
"github.com/owncord/server/db"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// UserService handles user profile and session operations.
type UserService struct {
- st store.Store
+ st Store
}
// NewUserService creates a UserService.
-func NewUserService(st store.Store) *UserService {
+func NewUserService(st Store) *UserService {
return &UserService{st: st}
}
diff --git a/Server/service/user_test.go b/Server/service/user_test.go
index 34c7674f..13864de6 100644
--- a/Server/service/user_test.go
+++ b/Server/service/user_test.go
@@ -6,14 +6,15 @@ import (
"testing"
"github.com/owncord/server/db"
- "github.com/owncord/server/store"
)
-// pwStore wraps MemStore with controllable DeleteOtherSessions behavior and
-// audit capture, so the committed-password partial-success contract (W2-2)
-// is testable.
+// pwStore wraps a real *db.DB with controllable DeleteOtherSessions behavior
+// and audit capture, so the committed-password partial-success contract (W2-2)
+// is testable. Embedding *db.DB satisfies the service Store interface; the two
+// overridden methods intercept the calls the contract turns on while every
+// other call (UpdateUserPassword, GetUserByID) hits the real database.
type pwStore struct {
- *store.MemStore
+ *db.DB
failRevokes int // number of DeleteOtherSessions calls that fail before succeeding
revokeCalls int
audits []string
@@ -37,9 +38,9 @@ func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error {
// error (the old password is dead; a "failed" report walks the user into the
// confirm lockout), and the audit row must still be written.
func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"})
- fs := &pwStore{MemStore: ms, failRevokes: 99}
+ database := newTestDB(t)
+ seedUser(t, database, &db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"})
+ fs := &pwStore{DB: database, failRevokes: 99}
svc := NewUserService(fs)
res, err := svc.ChangePassword(7, "newhash", 1)
@@ -49,7 +50,7 @@ func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) {
if !res.RevokeFailed {
t.Fatal("RevokeFailed should be set when revocation keeps failing")
}
- if u, _ := ms.GetUserByID(7); u.PasswordHash != "newhash" {
+ if u, _ := database.GetUserByID(7); u.PasswordHash != "newhash" {
t.Fatal("password should be committed")
}
if !slices.Contains(fs.audits, "password_change") {
@@ -60,9 +61,9 @@ func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) {
// TestChangePassword_RetryRecoversRevocation: a single transient revocation
// failure is absorbed by the bounded compensating retry.
func TestChangePassword_RetryRecoversRevocation(t *testing.T) {
- ms := store.NewMemStore()
- ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"})
- fs := &pwStore{MemStore: ms, failRevokes: 1}
+ database := newTestDB(t)
+ seedUser(t, database, &db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"})
+ fs := &pwStore{DB: database, failRevokes: 1}
svc := NewUserService(fs)
res, err := svc.ChangePassword(7, "newhash", 1)
diff --git a/Server/service/voice.go b/Server/service/voice.go
index f8579579..c0a42866 100644
--- a/Server/service/voice.go
+++ b/Server/service/voice.go
@@ -9,18 +9,17 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
// VoiceService handles voice state business logic.
type VoiceService struct {
- st store.Store
+ st Store
perm *PermissionService
}
// NewVoiceService creates a VoiceService.
-func NewVoiceService(st store.Store, perm *PermissionService) *VoiceService {
+func NewVoiceService(st Store, perm *PermissionService) *VoiceService {
return &VoiceService{st: st, perm: perm}
}
diff --git a/Server/store/memstore.go b/Server/store/memstore.go
deleted file mode 100644
index 1837c54a..00000000
--- a/Server/store/memstore.go
+++ /dev/null
@@ -1,824 +0,0 @@
-package store
-
-import (
- "context"
- "database/sql"
- "fmt"
- "sync"
- "time"
-
- "github.com/owncord/server/db"
-)
-
-// compile-time interface check
-var _ Store = (*MemStore)(nil)
-
-// MemStore is a lightweight in-memory Store implementation for testing.
-// Only the methods needed by service-layer tests are implemented;
-// everything else panics with a descriptive message.
-type MemStore struct {
- mu sync.Mutex
-
- // auto-increment counters
- nextMsgID int64
- nextChannelID int64
-
- channels map[int64]*db.Channel
- messages map[int64]*db.Message
- users map[int64]*db.User
- roles map[int64]*db.Role
- // userID -> roleID
- userRoles map[int64]int64
- // roleID -> channelID -> override
- channelOverrides map[int64]map[int64]db.ChannelOverride
- // messageID -> userID -> emoji -> bool
- reactions map[int64]map[int64]map[string]bool
- // channelID -> set of participant userIDs
- dmParticipants map[int64]map[int64]bool
- // blockerID -> blockedID -> bool (bidirectional checked via IsEitherBlocked)
- blocks map[int64]map[int64]bool
- // userID -> channelID -> lastReadMessageID
- readStates map[int64]map[int64]int64
- // attachment id -> row. Tracks uploader_id/message_id so the atomic
- // link-ownership guard is exercised against a store that really records
- // ownership instead of a (nil, nil) stub.
- attachments map[string]*db.Attachment
-
- // Phase B Step 7 / Phase C Step 9 — events + plugin KV. Lazily initialised
- // via ensureEvents() so existing tests that constructed a bare MemStore
- // without these fields keep working.
- eventsOnce sync.Once
- eventStore *memEventStore
-}
-
-// NewMemStore creates an empty MemStore ready for use.
-func NewMemStore() *MemStore {
- return &MemStore{
- channels: make(map[int64]*db.Channel),
- messages: make(map[int64]*db.Message),
- users: make(map[int64]*db.User),
- roles: make(map[int64]*db.Role),
- userRoles: make(map[int64]int64),
- channelOverrides: make(map[int64]map[int64]db.ChannelOverride),
- reactions: make(map[int64]map[int64]map[string]bool),
- dmParticipants: make(map[int64]map[int64]bool),
- attachments: make(map[string]*db.Attachment),
- blocks: make(map[int64]map[int64]bool),
- readStates: make(map[int64]map[int64]int64),
- }
-}
-
-// ---------- helpers for test setup ----------
-
-// SeedChannel inserts a channel directly (for test setup).
-func (m *MemStore) SeedChannel(ch *db.Channel) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.channels[ch.ID] = ch
- if ch.ID >= m.nextChannelID {
- m.nextChannelID = ch.ID + 1
- }
-}
-
-// SeedUser inserts a user directly (for test setup).
-func (m *MemStore) SeedUser(u *db.User) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.users[u.ID] = u
-}
-
-// SeedRole inserts a role directly (for test setup).
-func (m *MemStore) SeedRole(r *db.Role) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.roles[r.ID] = r
-}
-
-// SeedUserRole assigns a role to a user (for test setup).
-func (m *MemStore) SeedUserRole(userID, roleID int64) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.userRoles[userID] = roleID
-}
-
-// SeedChannelOverride sets a channel permission override for a role (for test setup).
-func (m *MemStore) SeedChannelOverride(roleID, channelID int64, allow, deny int64) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.channelOverrides[roleID] == nil {
- m.channelOverrides[roleID] = make(map[int64]db.ChannelOverride)
- }
- m.channelOverrides[roleID][channelID] = db.ChannelOverride{Allow: allow, Deny: deny}
-}
-
-// SeedDMParticipant adds a user as a DM participant in a channel (for test setup).
-func (m *MemStore) SeedDMParticipant(channelID, userID int64) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.dmParticipants[channelID] == nil {
- m.dmParticipants[channelID] = make(map[int64]bool)
- }
- m.dmParticipants[channelID][userID] = true
-}
-
-// SeedBlock records a block relationship (for test setup).
-func (m *MemStore) SeedBlock(blockerID, blockedID int64) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.blocks[blockerID] == nil {
- m.blocks[blockerID] = make(map[int64]bool)
- }
- m.blocks[blockerID][blockedID] = true
-}
-
-// ---------- Store interface: top-level ----------
-
-func (m *MemStore) Close() error { return nil }
-func (m *MemStore) SQLDb() *sql.DB { return nil }
-func (m *MemStore) WithTx(_ context.Context, fn func(Store) error) error { return fn(m) }
-
-// ---------- MessageStore ----------
-
-func (m *MemStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.nextMsgID++
- id := m.nextMsgID
- m.messages[id] = &db.Message{
- ID: id,
- ChannelID: channelID,
- UserID: userID,
- Content: content,
- ReplyTo: replyTo,
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- }
- return id, nil
-}
-
-func (m *MemStore) GetMessage(id int64) (*db.Message, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- msg, ok := m.messages[id]
- if !ok {
- return nil, fmt.Errorf("message %d not found", id)
- }
- // Return a copy to avoid aliasing.
- cp := *msg
- return &cp, nil
-}
-
-func (m *MemStore) GetMessages(_ int64, _ int64, _ int) ([]db.MessageWithUser, error) {
- panic("memstore: not implemented: GetMessages")
-}
-
-func (m *MemStore) GetMessagesForAPI(_ int64, _ int64, _ int, _ int64) ([]db.MessageAPIResponse, error) {
- return []db.MessageAPIResponse{}, nil
-}
-
-func (m *MemStore) EditMessage(id, userID int64, content string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- msg, ok := m.messages[id]
- if !ok {
- return fmt.Errorf("message %d not found", id)
- }
- if msg.UserID != userID {
- return fmt.Errorf("not message owner")
- }
- now := time.Now().UTC().Format(time.RFC3339)
- msg.Content = content
- msg.EditedAt = &now
- return nil
-}
-
-func (m *MemStore) DeleteMessage(id, userID int64, isMod bool) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- msg, ok := m.messages[id]
- if !ok {
- return fmt.Errorf("message %d not found", id)
- }
- if !isMod && msg.UserID != userID {
- return fmt.Errorf("not message owner")
- }
- msg.Deleted = true
- return nil
-}
-
-func (m *MemStore) SearchMessages(_ string, _ *int64, _ int) ([]db.MessageSearchResult, error) {
- return []db.MessageSearchResult{}, nil
-}
-
-func (m *MemStore) SearchMessagesInChannels(_ string, _ []int64, _ int) ([]db.MessageSearchResult, error) {
- return []db.MessageSearchResult{}, nil
-}
-
-func (m *MemStore) GetPinnedMessages(_ int64, _ int64) ([]db.MessageAPIResponse, error) {
- return []db.MessageAPIResponse{}, nil
-}
-
-func (m *MemStore) SetMessagePinned(_ int64, _ bool) error { return nil }
-
-func (m *MemStore) AddReaction(messageID, userID int64, emoji string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if _, ok := m.messages[messageID]; !ok {
- return fmt.Errorf("message %d not found", messageID)
- }
- if m.reactions[messageID] == nil {
- m.reactions[messageID] = make(map[int64]map[string]bool)
- }
- if m.reactions[messageID][userID] == nil {
- m.reactions[messageID][userID] = make(map[string]bool)
- }
- if m.reactions[messageID][userID][emoji] {
- return fmt.Errorf("reaction already exists")
- }
- m.reactions[messageID][userID][emoji] = true
- return nil
-}
-
-func (m *MemStore) RemoveReaction(messageID, userID int64, emoji string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.reactions[messageID] == nil || m.reactions[messageID][userID] == nil || !m.reactions[messageID][userID][emoji] {
- return fmt.Errorf("reaction not found")
- }
- delete(m.reactions[messageID][userID], emoji)
- return nil
-}
-
-func (m *MemStore) GetReactions(_ int64) ([]db.ReactionCount, error) {
- return []db.ReactionCount{}, nil
-}
-
-func (m *MemStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.readStates[userID] == nil {
- m.readStates[userID] = make(map[int64]int64)
- }
- m.readStates[userID][channelID] = lastReadMessageID
- return nil
-}
-
-func (m *MemStore) GetChannelUnreadCounts(_ int64) (map[int64]db.ChannelUnread, error) {
- return map[int64]db.ChannelUnread{}, nil
-}
-
-func (m *MemStore) GetLatestMessageID(channelID int64) (int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- var latest int64
- for _, msg := range m.messages {
- if msg.ChannelID == channelID && msg.ID > latest {
- latest = msg.ID
- }
- }
- return latest, nil
-}
-
-// LinkAttachmentsToMessage mirrors the SQL guard in db.LinkAttachmentsToMessage:
-// only unlinked attachments owned by uploaderID (or legacy rows with a nil
-// uploader) are claimed; everything else is skipped, not an error.
-func (m *MemStore) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- var n int64
- for _, id := range attachmentIDs {
- att, ok := m.attachments[id]
- if !ok || att.MessageID != nil {
- continue
- }
- if att.UploaderID != nil && *att.UploaderID != uploaderID {
- continue
- }
- mid := messageID
- att.MessageID = &mid
- n++
- }
- return n, nil
-}
-
-func (m *MemStore) GetAttachmentsByMessageIDs(_ []int64) (map[int64][]db.AttachmentInfo, error) {
- return map[int64][]db.AttachmentInfo{}, nil
-}
-
-// ---------- ChannelStore ----------
-
-func (m *MemStore) ListChannels() ([]db.Channel, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- out := make([]db.Channel, 0, len(m.channels))
- for _, ch := range m.channels {
- out = append(out, *ch)
- }
- return out, nil
-}
-
-func (m *MemStore) GetChannel(id int64) (*db.Channel, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- ch, ok := m.channels[id]
- if !ok {
- return nil, fmt.Errorf("channel %d not found", id)
- }
- cp := *ch
- return &cp, nil
-}
-
-func (m *MemStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.nextChannelID++
- id := m.nextChannelID
- m.channels[id] = &db.Channel{
- ID: id,
- Name: name,
- Type: chanType,
- Category: category,
- Topic: topic,
- Position: position,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- }
- return id, nil
-}
-
-func (m *MemStore) UpdateChannel(_ int64, _, _ string, _ int) error {
- panic("memstore: not implemented: UpdateChannel")
-}
-
-func (m *MemStore) DeleteChannel(_ int64) error {
- panic("memstore: not implemented: DeleteChannel")
-}
-
-func (m *MemStore) SetChannelSlowMode(_ int64, _ int) error {
- panic("memstore: not implemented: SetChannelSlowMode")
-}
-
-func (m *MemStore) SetChannelVoiceMaxUsers(_ int64, _ int) error {
- panic("memstore: not implemented: SetChannelVoiceMaxUsers")
-}
-
-func (m *MemStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- overrides, ok := m.channelOverrides[roleID]
- if !ok {
- return 0, 0, nil
- }
- o, ok := overrides[channelID]
- if !ok {
- return 0, 0, nil
- }
- return o.Allow, o.Deny, nil
-}
-
-func (m *MemStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- overrides, ok := m.channelOverrides[roleID]
- if !ok {
- return map[int64]db.ChannelOverride{}, nil
- }
- // Copy map.
- out := make(map[int64]db.ChannelOverride, len(overrides))
- for k, v := range overrides {
- out[k] = v
- }
- return out, nil
-}
-
-func (m *MemStore) GetChannelTypes(_ []int64) (map[int64]string, error) {
- panic("memstore: not implemented: GetChannelTypes")
-}
-
-// ---------- UserStore ----------
-
-func (m *MemStore) GetUserByID(id int64) (*db.User, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- u, ok := m.users[id]
- if !ok {
- return nil, fmt.Errorf("user %d not found", id)
- }
- cp := *u
- return &cp, nil
-}
-
-func (m *MemStore) GetUserByUsername(_ string) (*db.User, error) {
- panic("memstore: not implemented: GetUserByUsername")
-}
-
-func (m *MemStore) CreateUser(_, _ string, _ int) (int64, error) {
- panic("memstore: not implemented: CreateUser")
-}
-
-func (m *MemStore) CreateOwnerIfEmpty(_, _ string, _ int) (int64, error) {
- panic("memstore: not implemented: CreateOwnerIfEmpty")
-}
-
-func (m *MemStore) CreateUserWithInvite(_, _ string, _ int, _ string) (int64, error) {
- panic("memstore: not implemented: CreateUserWithInvite")
-}
-
-func (m *MemStore) UpdateUserProfile(_ int64, _ string, _ *string) error {
- panic("memstore: not implemented: UpdateUserProfile")
-}
-
-func (m *MemStore) UpdateUserPassword(userID int64, hash string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if u, ok := m.users[userID]; ok {
- u.PasswordHash = hash
- }
- return nil
-}
-
-func (m *MemStore) UpdateUserStatus(id int64, status string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- u, ok := m.users[id]
- if !ok {
- return fmt.Errorf("user %d not found", id)
- }
- u.Status = status
- return nil
-}
-
-func (m *MemStore) UpdateUserTOTPSecret(_ int64, _ *string) error {
- panic("memstore: not implemented: UpdateUserTOTPSecret")
-}
-
-func (m *MemStore) UpdateUserRole(_ int64, _ int64) error {
- panic("memstore: not implemented: UpdateUserRole")
-}
-
-func (m *MemStore) ResetAllUserStatuses() error {
- panic("memstore: not implemented: ResetAllUserStatuses")
-}
-
-func (m *MemStore) DeleteAccount(_ context.Context, _ int64) error {
- panic("memstore: not implemented: DeleteAccount")
-}
-
-func (m *MemStore) ListMembers() ([]db.MemberSummary, error) {
- panic("memstore: not implemented: ListMembers")
-}
-
-// ---------- SessionStore ----------
-
-func (m *MemStore) CreateSession(_ int64, _, _, _ string) (int64, error) {
- panic("memstore: not implemented: CreateSession")
-}
-
-func (m *MemStore) GetSessionByTokenHash(_ string) (*db.Session, error) {
- panic("memstore: not implemented: GetSessionByTokenHash")
-}
-
-func (m *MemStore) GetSessionWithBanStatus(_ string) (*db.SessionWithBanStatus, error) {
- panic("memstore: not implemented: GetSessionWithBanStatus")
-}
-
-func (m *MemStore) DeleteSession(_ string) error {
- panic("memstore: not implemented: DeleteSession")
-}
-
-func (m *MemStore) DeleteOtherSessions(_ int64, _ int64) (int64, error) {
- panic("memstore: not implemented: DeleteOtherSessions")
-}
-
-func (m *MemStore) DeleteExpiredSessions() error {
- panic("memstore: not implemented: DeleteExpiredSessions")
-}
-
-func (m *MemStore) DeleteSessionByID(_ int64, _ int64) error {
- panic("memstore: not implemented: DeleteSessionByID")
-}
-
-func (m *MemStore) TouchSession(_ string) error {
- panic("memstore: not implemented: TouchSession")
-}
-
-func (m *MemStore) ListUserSessions(_ int64) ([]db.Session, error) {
- panic("memstore: not implemented: ListUserSessions")
-}
-
-func (m *MemStore) ForceLogoutUser(_ int64) error {
- panic("memstore: not implemented: ForceLogoutUser")
-}
-
-func (m *MemStore) GetUserSessions(_ int64) ([]db.Session, error) {
- panic("memstore: not implemented: GetUserSessions")
-}
-
-// ---------- RoleStore ----------
-
-func (m *MemStore) GetRoleByID(id int64) (*db.Role, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- r, ok := m.roles[id]
- if !ok {
- return nil, fmt.Errorf("role %d not found", id)
- }
- cp := *r
- return &cp, nil
-}
-
-func (m *MemStore) GetRoleForUser(userID int64) (*db.Role, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- roleID, ok := m.userRoles[userID]
- if !ok {
- return nil, fmt.Errorf("no role for user %d", userID)
- }
- r, ok := m.roles[roleID]
- if !ok {
- return nil, fmt.Errorf("role %d not found", roleID)
- }
- cp := *r
- return &cp, nil
-}
-
-func (m *MemStore) GetUserWithRole(_ int64) (*db.User, *db.Role, error) {
- panic("memstore: not implemented: GetUserWithRole")
-}
-
-func (m *MemStore) ListRoles() ([]*db.Role, error) {
- panic("memstore: not implemented: ListRoles")
-}
-
-// ---------- InviteStore ----------
-
-func (m *MemStore) CreateInvite(_ int64, _ int, _ *time.Time) (string, error) {
- panic("memstore: not implemented: CreateInvite")
-}
-
-func (m *MemStore) GetInvite(_ string) (*db.Invite, error) {
- panic("memstore: not implemented: GetInvite")
-}
-
-func (m *MemStore) ListInvites() ([]*db.Invite, error) {
- panic("memstore: not implemented: ListInvites")
-}
-
-func (m *MemStore) UseInviteAtomic(_ string) error {
- panic("memstore: not implemented: UseInviteAtomic")
-}
-
-func (m *MemStore) RevokeInvite(_ string) error {
- panic("memstore: not implemented: RevokeInvite")
-}
-
-// ---------- VoiceStore ----------
-
-func (m *MemStore) JoinVoiceChannel(_ int64, _ int64) error {
- panic("memstore: not implemented: JoinVoiceChannel")
-}
-
-func (m *MemStore) JoinVoiceChannelIfCapacity(_ int64, _ int64, _ int) error {
- panic("memstore: not implemented: JoinVoiceChannelIfCapacity")
-}
-
-func (m *MemStore) LeaveVoiceChannel(_ int64) error {
- panic("memstore: not implemented: LeaveVoiceChannel")
-}
-
-func (m *MemStore) LeaveVoiceChannelIfMatch(_ int64, _ int64, _ string) (bool, error) {
- panic("memstore: not implemented: LeaveVoiceChannelIfMatch")
-}
-
-func (m *MemStore) GetVoiceState(_ int64) (*db.VoiceState, error) {
- panic("memstore: not implemented: GetVoiceState")
-}
-
-func (m *MemStore) GetChannelVoiceStates(_ int64) ([]db.VoiceState, error) {
- panic("memstore: not implemented: GetChannelVoiceStates")
-}
-
-func (m *MemStore) GetAllVoiceStates() ([]db.VoiceState, error) {
- panic("memstore: not implemented: GetAllVoiceStates")
-}
-
-func (m *MemStore) UpdateVoiceMute(_ int64, _ bool) error {
- panic("memstore: not implemented: UpdateVoiceMute")
-}
-
-func (m *MemStore) UpdateVoiceDeafen(_ int64, _ bool) error {
- panic("memstore: not implemented: UpdateVoiceDeafen")
-}
-
-func (m *MemStore) ClearVoiceState(_ int64) error {
- panic("memstore: not implemented: ClearVoiceState")
-}
-
-func (m *MemStore) ClearAllVoiceStates() error {
- panic("memstore: not implemented: ClearAllVoiceStates")
-}
-
-func (m *MemStore) CountActiveCameras(_ int64) (int, error) {
- panic("memstore: not implemented: CountActiveCameras")
-}
-
-func (m *MemStore) UpdateVoiceCamera(_ int64, _ bool) error {
- panic("memstore: not implemented: UpdateVoiceCamera")
-}
-
-func (m *MemStore) EnableCameraIfUnderLimit(_ int64, _ int64, _ int) (bool, error) {
- panic("memstore: not implemented: EnableCameraIfUnderLimit")
-}
-
-func (m *MemStore) UpdateVoiceScreenshare(_ int64, _ bool) error {
- panic("memstore: not implemented: UpdateVoiceScreenshare")
-}
-
-func (m *MemStore) CountChannelVoiceUsers(_ int64) (int, error) {
- panic("memstore: not implemented: CountChannelVoiceUsers")
-}
-
-// ---------- DMStore ----------
-
-func (m *MemStore) GetOrCreateDMChannel(_ int64, _ int64) (*db.Channel, bool, error) {
- panic("memstore: not implemented: GetOrCreateDMChannel")
-}
-
-func (m *MemStore) GetUserDMChannels(_ int64) ([]db.DMChannelInfo, error) {
- return []db.DMChannelInfo{}, nil
-}
-
-func (m *MemStore) OpenDM(_, _ int64) error { return nil }
-func (m *MemStore) CloseDM(_, _ int64) error { return nil }
-
-func (m *MemStore) IsDMParticipant(userID, channelID int64) (bool, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- participants, ok := m.dmParticipants[channelID]
- if !ok {
- return false, nil
- }
- return participants[userID], nil
-}
-
-func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- participants, ok := m.dmParticipants[channelID]
- if !ok {
- return []int64{}, nil
- }
- ids := make([]int64, 0, len(participants))
- for id := range participants {
- ids = append(ids, id)
- }
- return ids, nil
-}
-
-func (m *MemStore) GetDMRecipient(channelID, userID int64) (*db.User, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- for uid := range m.dmParticipants[channelID] {
- if uid != userID {
- return m.users[uid], nil
- }
- }
- return nil, nil
-}
-
-// ---------- BlockStore ----------
-
-func (m *MemStore) BlockUser(_, _ int64) error { return nil }
-func (m *MemStore) UnblockUser(_, _ int64) error { return nil }
-
-func (m *MemStore) IsBlocked(blockerID, blockedID int64) (bool, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.blocks[blockerID] != nil && m.blocks[blockerID][blockedID] {
- return true, nil
- }
- return false, nil
-}
-
-func (m *MemStore) IsEitherBlocked(userA, userB int64) (bool, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.blocks[userA] != nil && m.blocks[userA][userB] {
- return true, nil
- }
- if m.blocks[userB] != nil && m.blocks[userB][userA] {
- return true, nil
- }
- return false, nil
-}
-
-func (m *MemStore) ListBlockedUsers(_ int64) ([]int64, error) {
- return []int64{}, nil
-}
-
-// ---------- AttachmentStore ----------
-
-func (m *MemStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, _, _ *int) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- uid := uploaderID
- m.attachments[id] = &db.Attachment{
- ID: id, UploaderID: &uid, Filename: filename,
- StoredAs: storedAs, MimeType: mimeType, Size: size,
- }
- return nil
-}
-
-func (m *MemStore) GetAttachmentByID(id string) (*db.Attachment, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- return m.attachments[id], nil
-}
-
-func (m *MemStore) GetAttachmentWithChannel(_ string) (*db.AttachmentAccess, error) {
- panic("memstore: not implemented: GetAttachmentWithChannel")
-}
-
-func (m *MemStore) DeleteOrphanedAttachments(_ string) ([]string, error) {
- panic("memstore: not implemented: DeleteOrphanedAttachments")
-}
-
-// ---------- AdminStore ----------
-
-func (m *MemStore) UserCount() (int64, error) {
- panic("memstore: not implemented: UserCount")
-}
-
-func (m *MemStore) GetServerStats() (*db.ServerStats, error) {
- panic("memstore: not implemented: GetServerStats")
-}
-
-func (m *MemStore) ListAllUsers(_ int, _ int) ([]db.UserWithRole, error) {
- panic("memstore: not implemented: ListAllUsers")
-}
-
-func (m *MemStore) BanUser(userID int64, reason string, _ *time.Time) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if u, ok := m.users[userID]; ok {
- u.Banned = true
- r := reason
- u.BanReason = &r
- }
- return nil
-}
-
-func (m *MemStore) UnbanUser(userID int64) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if u, ok := m.users[userID]; ok {
- u.Banned = false
- u.BanReason = nil
- u.BanExpires = nil
- }
- return nil
-}
-
-func (m *MemStore) LogAudit(_ int64, _, _ string, _ int64, _ string) error {
- return nil
-}
-
-func (m *MemStore) GetAuditLog(_ int, _ int) ([]db.AuditEntry, error) {
- panic("memstore: not implemented: GetAuditLog")
-}
-
-func (m *MemStore) AdminCreateChannel(_, _, _, _ string, _ int) (int64, error) {
- panic("memstore: not implemented: AdminCreateChannel")
-}
-
-func (m *MemStore) AdminUpdateChannel(_ int64, _, _ string, _, _ int, _ bool) error {
- panic("memstore: not implemented: AdminUpdateChannel")
-}
-
-func (m *MemStore) AdminDeleteChannel(_ int64) error {
- panic("memstore: not implemented: AdminDeleteChannel")
-}
-
-func (m *MemStore) BackupTo(_ string) error {
- panic("memstore: not implemented: BackupTo")
-}
-
-func (m *MemStore) BackupToSafe(_, _ string) error {
- panic("memstore: not implemented: BackupToSafe")
-}
-
-func (m *MemStore) CountUsersWithoutTOTP() (int, error) {
- panic("memstore: not implemented: CountUsersWithoutTOTP")
-}
-
-// ---------- SettingsStore ----------
-
-func (m *MemStore) GetSetting(_ string) (string, error) {
- panic("memstore: not implemented: GetSetting")
-}
-
-func (m *MemStore) SetSetting(_, _ string) error {
- panic("memstore: not implemented: SetSetting")
-}
-
-func (m *MemStore) GetAllSettings() (map[string]string, error) {
- panic("memstore: not implemented: GetAllSettings")
-}
diff --git a/Server/store/memstore_events.go b/Server/store/memstore_events.go
deleted file mode 100644
index 8a9905b0..00000000
--- a/Server/store/memstore_events.go
+++ /dev/null
@@ -1,289 +0,0 @@
-package store
-
-import (
- "context"
- "fmt"
- "sort"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/owncord/server/db"
-)
-
-// memEventStore is an in-memory EventStore + PluginStore implementation
-// embedded into MemStore via the field below.
-type memEventStore struct {
- mu sync.Mutex
- // nextSeq tracks the high-water mark of caller-supplied event seqs so
- // GetMaxEventSeq is O(1). PersistEvent updates it after each insert.
- nextSeq atomic.Int64
- events []db.PersistedEvent
- plugins map[int64]*db.PluginRow
- nextPID int64
- pluginKV map[int64]map[string][]byte
-}
-
-func newMemEventStore() *memEventStore {
- return &memEventStore{
- plugins: make(map[int64]*db.PluginRow),
- pluginKV: make(map[int64]map[string][]byte),
- }
-}
-
-// ---------- EventStore ----------
-
-func (m *MemStore) ensureEvents() *memEventStore {
- m.eventsOnce.Do(func() {
- m.eventStore = newMemEventStore()
- })
- return m.eventStore
-}
-
-func (m *MemStore) PersistEvent(_ context.Context, seq int64, eventType string, channelID int64, payload []byte) error {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- cp := make([]byte, len(payload))
- copy(cp, payload)
- es.events = append(es.events, db.PersistedEvent{
- Seq: seq,
- EventType: eventType,
- ChannelID: channelID,
- Payload: cp,
- CreatedAt: time.Now().UTC(),
- })
- // Track the high water mark so GetMaxEventSeq is O(1).
- if seq > es.nextSeq.Load() {
- es.nextSeq.Store(seq)
- }
- return nil
-}
-
-func (m *MemStore) GetEventsSince(_ context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- out := make([]db.PersistedEvent, 0)
- for _, e := range es.events {
- if e.Seq > afterSeq {
- out = append(out, e)
- if len(out) >= limit {
- break
- }
- }
- }
- return out, nil
-}
-
-func (m *MemStore) GetEventsSinceForChannels(_ context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- allowed := make(map[int64]bool, len(channelIDs))
- for _, cid := range channelIDs {
- allowed[cid] = true
- }
- out := make([]db.PersistedEvent, 0)
- for _, e := range es.events {
- if e.Seq <= afterSeq {
- continue
- }
- if e.ChannelID == 0 || allowed[e.ChannelID] {
- out = append(out, e)
- if len(out) >= limit {
- break
- }
- }
- }
- return out, nil
-}
-
-func (m *MemStore) GetMaxEventSeq(_ context.Context) (int64, error) {
- es := m.ensureEvents()
- return es.nextSeq.Load(), nil
-}
-
-func (m *MemStore) PruneEventsOlderThan(_ context.Context, cutoff time.Time) (int64, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- kept := es.events[:0]
- var deleted int64
- for _, e := range es.events {
- if e.CreatedAt.Before(cutoff) {
- deleted++
- continue
- }
- kept = append(kept, e)
- }
- es.events = kept
- return deleted, nil
-}
-
-// ---------- PluginStore ----------
-
-func (m *MemStore) InstallPlugin(_ context.Context, name, version, manifestJSON string) (int64, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- for _, p := range es.plugins {
- if p.Name == name {
- p.Version = version
- p.ManifestJSON = manifestJSON
- return p.ID, nil
- }
- }
- es.nextPID++
- id := es.nextPID
- es.plugins[id] = &db.PluginRow{
- ID: id,
- Name: name,
- Version: version,
- Enabled: false,
- ManifestJSON: manifestJSON,
- InstalledAt: time.Now().UTC(),
- }
- return id, nil
-}
-
-func (m *MemStore) EnablePlugin(_ context.Context, id int64) error {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- p, ok := es.plugins[id]
- if !ok {
- return fmt.Errorf("plugin %d not found", id)
- }
- p.Enabled = true
- return nil
-}
-
-func (m *MemStore) DisablePlugin(_ context.Context, id int64) error {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- p, ok := es.plugins[id]
- if !ok {
- return fmt.Errorf("plugin %d not found", id)
- }
- p.Enabled = false
- return nil
-}
-
-func (m *MemStore) UninstallPlugin(_ context.Context, id int64) error {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- delete(es.plugins, id)
- delete(es.pluginKV, id)
- return nil
-}
-
-func (m *MemStore) GetPlugin(_ context.Context, id int64) (*db.PluginRow, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- p, ok := es.plugins[id]
- if !ok {
- return nil, fmt.Errorf("plugin %d not found", id)
- }
- cp := *p
- return &cp, nil
-}
-
-func (m *MemStore) GetPluginByName(_ context.Context, name string) (*db.PluginRow, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- for _, p := range es.plugins {
- if p.Name == name {
- cp := *p
- return &cp, nil
- }
- }
- return nil, fmt.Errorf("plugin %q not found", name)
-}
-
-func (m *MemStore) ListPlugins(_ context.Context) ([]db.PluginRow, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- out := make([]db.PluginRow, 0, len(es.plugins))
- for _, p := range es.plugins {
- out = append(out, *p)
- }
- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
- return out, nil
-}
-
-func (m *MemStore) PluginKVGet(_ context.Context, pluginID int64, key string) ([]byte, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- bucket, ok := es.pluginKV[pluginID]
- if !ok {
- return nil, fmt.Errorf("kv: plugin %d not found", pluginID)
- }
- v, ok := bucket[key]
- if !ok {
- return nil, fmt.Errorf("kv: key %q not found", key)
- }
- cp := make([]byte, len(v))
- copy(cp, v)
- return cp, nil
-}
-
-func (m *MemStore) PluginKVSet(_ context.Context, pluginID int64, key string, value []byte) error {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- bucket, ok := es.pluginKV[pluginID]
- if !ok {
- bucket = make(map[string][]byte)
- es.pluginKV[pluginID] = bucket
- }
- cp := make([]byte, len(value))
- copy(cp, value)
- bucket[key] = cp
- return nil
-}
-
-func (m *MemStore) PluginKVDelete(_ context.Context, pluginID int64, key string) error {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- if bucket, ok := es.pluginKV[pluginID]; ok {
- delete(bucket, key)
- }
- return nil
-}
-
-func (m *MemStore) PluginKVScan(_ context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) {
- es := m.ensureEvents()
- es.mu.Lock()
- defer es.mu.Unlock()
- out := make(map[string][]byte)
- bucket, ok := es.pluginKV[pluginID]
- if !ok {
- return out, nil
- }
- keys := make([]string, 0, len(bucket))
- for k := range bucket {
- if strings.HasPrefix(k, prefix) {
- keys = append(keys, k)
- }
- }
- sort.Strings(keys)
- if limit > 0 && len(keys) > limit {
- keys = keys[:limit]
- }
- for _, k := range keys {
- v := bucket[k]
- cp := make([]byte, len(v))
- copy(cp, v)
- out[k] = cp
- }
- return out, nil
-}
diff --git a/Server/store/sqlite.go b/Server/store/sqlite.go
deleted file mode 100644
index 64f7aba5..00000000
--- a/Server/store/sqlite.go
+++ /dev/null
@@ -1,364 +0,0 @@
-package store
-
-import (
- "context"
- "database/sql"
- "time"
-
- "github.com/owncord/server/db"
-)
-
-// SQLiteStore wraps *db.DB to implement the Store interface.
-// It delegates all operations to the existing db package methods.
-type SQLiteStore struct {
- db *db.DB
-}
-
-// NewSQLiteStore creates a SQLiteStore from an existing *db.DB.
-func NewSQLiteStore(database *db.DB) *SQLiteStore {
- return &SQLiteStore{db: database}
-}
-
-// Open opens a SQLite database at path and returns a ready-to-use Store.
-func Open(path string) (*SQLiteStore, error) {
- database, err := db.Open(path)
- if err != nil {
- return nil, err
- }
- return &SQLiteStore{db: database}, nil
-}
-
-// DB returns the underlying *db.DB for callers that need raw access.
-func (s *SQLiteStore) DB() *db.DB { return s.db }
-
-// Close releases the underlying database connection.
-func (s *SQLiteStore) Close() error { return s.db.Close() }
-
-// SQLDb returns the underlying *sql.DB.
-func (s *SQLiteStore) SQLDb() *sql.DB { return s.db.SQLDb() }
-
-// WithTx executes fn within a transaction. For SQLite, all writes are
-// serialized through a single connection (MaxOpenConns=1), so the transaction
-// is started and committed on the same underlying connection that fn's
-// store calls use.
-//
-// TODO: implement properly with a transaction-scoped Store wrapper when
-// services need multi-statement transactions.
-func (s *SQLiteStore) WithTx(ctx context.Context, fn func(Store) error) error {
- // SQLite serializes all writes through one connection, so starting a
- // transaction and calling fn(s) effectively wraps fn's DB calls in
- // that transaction — provided MaxOpenConns remains 1.
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- if txErr := fn(s); txErr != nil {
- _ = tx.Rollback()
- return txErr
- }
- return tx.Commit()
-}
-
-// ── MessageStore ────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) {
- return s.db.CreateMessage(channelID, userID, content, replyTo)
-}
-func (s *SQLiteStore) GetMessage(id int64) (*db.Message, error) {
- return s.db.GetMessage(id)
-}
-func (s *SQLiteStore) GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) {
- return s.db.GetMessages(channelID, before, limit)
-}
-func (s *SQLiteStore) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) {
- return s.db.GetMessagesForAPI(channelID, before, limit, requestingUserID)
-}
-func (s *SQLiteStore) EditMessage(id, userID int64, content string) error {
- return s.db.EditMessage(id, userID, content)
-}
-func (s *SQLiteStore) DeleteMessage(id, userID int64, isMod bool) error {
- return s.db.DeleteMessage(id, userID, isMod)
-}
-func (s *SQLiteStore) SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) {
- return s.db.SearchMessages(query, channelID, limit)
-}
-func (s *SQLiteStore) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) {
- return s.db.SearchMessagesInChannels(query, channelIDs, limit)
-}
-func (s *SQLiteStore) GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) {
- return s.db.GetPinnedMessages(channelID, requestingUserID)
-}
-func (s *SQLiteStore) SetMessagePinned(id int64, pinned bool) error {
- return s.db.SetMessagePinned(id, pinned)
-}
-func (s *SQLiteStore) AddReaction(messageID, userID int64, emoji string) error {
- return s.db.AddReaction(messageID, userID, emoji)
-}
-func (s *SQLiteStore) RemoveReaction(messageID, userID int64, emoji string) error {
- return s.db.RemoveReaction(messageID, userID, emoji)
-}
-func (s *SQLiteStore) GetReactions(messageID int64) ([]db.ReactionCount, error) {
- return s.db.GetReactions(messageID)
-}
-func (s *SQLiteStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error {
- return s.db.UpdateReadState(userID, channelID, lastReadMessageID)
-}
-func (s *SQLiteStore) GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) {
- return s.db.GetChannelUnreadCounts(userID)
-}
-func (s *SQLiteStore) GetLatestMessageID(channelID int64) (int64, error) {
- return s.db.GetLatestMessageID(channelID)
-}
-func (s *SQLiteStore) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) {
- return s.db.LinkAttachmentsToMessage(messageID, uploaderID, attachmentIDs)
-}
-func (s *SQLiteStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) {
- return s.db.GetAttachmentsByMessageIDs(msgIDs)
-}
-
-// ── ChannelStore ────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) ListChannels() ([]db.Channel, error) { return s.db.ListChannels() }
-func (s *SQLiteStore) GetChannel(id int64) (*db.Channel, error) { return s.db.GetChannel(id) }
-func (s *SQLiteStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) {
- return s.db.CreateChannel(name, chanType, category, topic, position)
-}
-func (s *SQLiteStore) UpdateChannel(id int64, name, topic string, slowMode int) error {
- return s.db.UpdateChannel(id, name, topic, slowMode)
-}
-func (s *SQLiteStore) DeleteChannel(id int64) error { return s.db.DeleteChannel(id) }
-func (s *SQLiteStore) SetChannelSlowMode(id int64, sm int) error {
- return s.db.SetChannelSlowMode(id, sm)
-}
-func (s *SQLiteStore) SetChannelVoiceMaxUsers(id int64, max int) error {
- return s.db.SetChannelVoiceMaxUsers(id, max)
-}
-func (s *SQLiteStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) {
- return s.db.GetChannelPermissions(channelID, roleID)
-}
-func (s *SQLiteStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) {
- return s.db.GetAllChannelPermissionsForRole(roleID)
-}
-func (s *SQLiteStore) GetChannelTypes(ids []int64) (map[int64]string, error) {
- return s.db.GetChannelTypes(ids)
-}
-
-// ── UserStore ───────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) GetUserByID(id int64) (*db.User, error) { return s.db.GetUserByID(id) }
-func (s *SQLiteStore) GetUserByUsername(username string) (*db.User, error) {
- return s.db.GetUserByUsername(username)
-}
-func (s *SQLiteStore) CreateUser(username, passwordHash string, roleID int) (int64, error) {
- return s.db.CreateUser(username, passwordHash, roleID)
-}
-func (s *SQLiteStore) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) {
- return s.db.CreateOwnerIfEmpty(username, passwordHash, roleID)
-}
-func (s *SQLiteStore) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) {
- return s.db.CreateUserWithInvite(username, passwordHash, roleID, inviteCode)
-}
-func (s *SQLiteStore) UpdateUserProfile(userID int64, username string, avatar *string) error {
- return s.db.UpdateUserProfile(userID, username, avatar)
-}
-func (s *SQLiteStore) UpdateUserPassword(userID int64, hash string) error {
- return s.db.UpdateUserPassword(userID, hash)
-}
-func (s *SQLiteStore) UpdateUserStatus(id int64, status string) error {
- return s.db.UpdateUserStatus(id, status)
-}
-func (s *SQLiteStore) UpdateUserTOTPSecret(id int64, secret *string) error {
- return s.db.UpdateUserTOTPSecret(id, secret)
-}
-func (s *SQLiteStore) UpdateUserRole(userID, roleID int64) error {
- return s.db.UpdateUserRole(userID, roleID)
-}
-func (s *SQLiteStore) ResetAllUserStatuses() error { return s.db.ResetAllUserStatuses() }
-func (s *SQLiteStore) DeleteAccount(ctx context.Context, userID int64) error {
- return s.db.DeleteAccount(ctx, userID)
-}
-func (s *SQLiteStore) ListMembers() ([]db.MemberSummary, error) { return s.db.ListMembers() }
-
-// ── SessionStore ────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) {
- return s.db.CreateSession(userID, tokenHash, device, ip)
-}
-func (s *SQLiteStore) GetSessionByTokenHash(tokenHash string) (*db.Session, error) {
- return s.db.GetSessionByTokenHash(tokenHash)
-}
-func (s *SQLiteStore) GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) {
- return s.db.GetSessionWithBanStatus(tokenHash)
-}
-func (s *SQLiteStore) DeleteSession(tokenHash string) error { return s.db.DeleteSession(tokenHash) }
-func (s *SQLiteStore) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) {
- return s.db.DeleteOtherSessions(userID, keepSessionID)
-}
-func (s *SQLiteStore) DeleteExpiredSessions() error { return s.db.DeleteExpiredSessions() }
-func (s *SQLiteStore) DeleteSessionByID(sid, uid int64) error {
- return s.db.DeleteSessionByID(sid, uid)
-}
-func (s *SQLiteStore) TouchSession(tokenHash string) error { return s.db.TouchSession(tokenHash) }
-func (s *SQLiteStore) ListUserSessions(userID int64) ([]db.Session, error) {
- return s.db.ListUserSessions(userID)
-}
-func (s *SQLiteStore) ForceLogoutUser(userID int64) error { return s.db.ForceLogoutUser(userID) }
-func (s *SQLiteStore) GetUserSessions(userID int64) ([]db.Session, error) {
- return s.db.GetUserSessions(userID)
-}
-
-// ── RoleStore ───────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) GetRoleByID(id int64) (*db.Role, error) { return s.db.GetRoleByID(id) }
-func (s *SQLiteStore) GetRoleForUser(userID int64) (*db.Role, error) {
- return s.db.GetRoleForUser(userID)
-}
-func (s *SQLiteStore) GetUserWithRole(userID int64) (*db.User, *db.Role, error) {
- return s.db.GetUserWithRole(userID)
-}
-func (s *SQLiteStore) ListRoles() ([]*db.Role, error) { return s.db.ListRoles() }
-
-// ── InviteStore ─────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) {
- return s.db.CreateInvite(createdBy, maxUses, expiresAt)
-}
-func (s *SQLiteStore) GetInvite(code string) (*db.Invite, error) { return s.db.GetInvite(code) }
-func (s *SQLiteStore) ListInvites() ([]*db.Invite, error) { return s.db.ListInvites() }
-func (s *SQLiteStore) UseInviteAtomic(code string) error { return s.db.UseInviteAtomic(code) }
-func (s *SQLiteStore) RevokeInvite(code string) error { return s.db.RevokeInvite(code) }
-
-// ── VoiceStore ──────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) JoinVoiceChannel(userID, channelID int64) error {
- return s.db.JoinVoiceChannel(userID, channelID)
-}
-func (s *SQLiteStore) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error {
- return s.db.JoinVoiceChannelIfCapacity(userID, channelID, maxUsers)
-}
-func (s *SQLiteStore) LeaveVoiceChannel(userID int64) error { return s.db.LeaveVoiceChannel(userID) }
-func (s *SQLiteStore) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) {
- return s.db.LeaveVoiceChannelIfMatch(userID, expectedChannelID, expectedJoinedAt)
-}
-func (s *SQLiteStore) GetVoiceState(userID int64) (*db.VoiceState, error) {
- return s.db.GetVoiceState(userID)
-}
-func (s *SQLiteStore) GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) {
- return s.db.GetChannelVoiceStates(channelID)
-}
-func (s *SQLiteStore) GetAllVoiceStates() ([]db.VoiceState, error) { return s.db.GetAllVoiceStates() }
-func (s *SQLiteStore) UpdateVoiceMute(userID int64, m bool) error {
- return s.db.UpdateVoiceMute(userID, m)
-}
-func (s *SQLiteStore) UpdateVoiceDeafen(userID int64, d bool) error {
- return s.db.UpdateVoiceDeafen(userID, d)
-}
-func (s *SQLiteStore) ClearVoiceState(userID int64) error { return s.db.ClearVoiceState(userID) }
-func (s *SQLiteStore) ClearAllVoiceStates() error { return s.db.ClearAllVoiceStates() }
-func (s *SQLiteStore) CountActiveCameras(channelID int64) (int, error) {
- return s.db.CountActiveCameras(channelID)
-}
-func (s *SQLiteStore) UpdateVoiceCamera(userID int64, c bool) error {
- return s.db.UpdateVoiceCamera(userID, c)
-}
-func (s *SQLiteStore) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) {
- return s.db.EnableCameraIfUnderLimit(userID, channelID, maxVideo)
-}
-func (s *SQLiteStore) UpdateVoiceScreenshare(userID int64, ss bool) error {
- return s.db.UpdateVoiceScreenshare(userID, ss)
-}
-func (s *SQLiteStore) CountChannelVoiceUsers(channelID int64) (int, error) {
- return s.db.CountChannelVoiceUsers(channelID)
-}
-
-// ── DMStore ─────────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) {
- return s.db.GetOrCreateDMChannel(user1ID, user2ID)
-}
-func (s *SQLiteStore) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) {
- return s.db.GetUserDMChannels(userID)
-}
-func (s *SQLiteStore) OpenDM(userID, channelID int64) error { return s.db.OpenDM(userID, channelID) }
-func (s *SQLiteStore) CloseDM(userID, channelID int64) error { return s.db.CloseDM(userID, channelID) }
-func (s *SQLiteStore) IsDMParticipant(userID, channelID int64) (bool, error) {
- return s.db.IsDMParticipant(userID, channelID)
-}
-func (s *SQLiteStore) GetDMParticipantIDs(channelID int64) ([]int64, error) {
- return s.db.GetDMParticipantIDs(channelID)
-}
-func (s *SQLiteStore) GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) {
- return s.db.GetDMRecipient(channelID, requestingUserID)
-}
-
-// ── BlockStore ──────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) BlockUser(blockerID, blockedID int64) error {
- return s.db.BlockUser(blockerID, blockedID)
-}
-func (s *SQLiteStore) UnblockUser(blockerID, blockedID int64) error {
- return s.db.UnblockUser(blockerID, blockedID)
-}
-func (s *SQLiteStore) IsBlocked(blockerID, blockedID int64) (bool, error) {
- return s.db.IsBlocked(blockerID, blockedID)
-}
-func (s *SQLiteStore) IsEitherBlocked(userA, userB int64) (bool, error) {
- return s.db.IsEitherBlocked(userA, userB)
-}
-func (s *SQLiteStore) ListBlockedUsers(blockerID int64) ([]int64, error) {
- return s.db.ListBlockedUsers(blockerID)
-}
-
-// ── AttachmentStore ─────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error {
- return s.db.CreateAttachment(id, uploaderID, filename, storedAs, mimeType, size, width, height)
-}
-func (s *SQLiteStore) GetAttachmentByID(id string) (*db.Attachment, error) {
- return s.db.GetAttachmentByID(id)
-}
-func (s *SQLiteStore) GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) {
- return s.db.GetAttachmentWithChannel(id)
-}
-func (s *SQLiteStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) {
- return s.db.DeleteOrphanedAttachments(cutoff)
-}
-
-// ── AdminStore ──────────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) UserCount() (int64, error) { return s.db.UserCount() }
-func (s *SQLiteStore) GetServerStats() (*db.ServerStats, error) { return s.db.GetServerStats() }
-func (s *SQLiteStore) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) {
- return s.db.ListAllUsers(limit, offset)
-}
-func (s *SQLiteStore) BanUser(id int64, reason string, expires *time.Time) error {
- return s.db.BanUser(id, reason, expires)
-}
-func (s *SQLiteStore) UnbanUser(id int64) error { return s.db.UnbanUser(id) }
-func (s *SQLiteStore) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error {
- return s.db.LogAudit(actorID, action, targetType, targetID, detail)
-}
-func (s *SQLiteStore) GetAuditLog(limit, offset int) ([]db.AuditEntry, error) {
- return s.db.GetAuditLog(limit, offset)
-}
-func (s *SQLiteStore) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) {
- return s.db.AdminCreateChannel(name, chanType, category, topic, position)
-}
-func (s *SQLiteStore) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error {
- return s.db.AdminUpdateChannel(id, name, topic, slowMode, position, archived)
-}
-func (s *SQLiteStore) AdminDeleteChannel(id int64) error { return s.db.AdminDeleteChannel(id) }
-func (s *SQLiteStore) BackupTo(path string) error { return s.db.BackupTo(path) }
-func (s *SQLiteStore) BackupToSafe(path, safeRoot string) error {
- return s.db.BackupToSafe(path, safeRoot)
-}
-func (s *SQLiteStore) CountUsersWithoutTOTP() (int, error) { return s.db.CountUsersWithoutTOTP() }
-
-// ── SettingsStore ───────────────────────────────────────────────────────────
-
-func (s *SQLiteStore) GetSetting(key string) (string, error) { return s.db.GetSetting(key) }
-func (s *SQLiteStore) SetSetting(key, value string) error { return s.db.SetSetting(key, value) }
-func (s *SQLiteStore) GetAllSettings() (map[string]string, error) { return s.db.GetAllSettings() }
-
-// Compile-time interface check.
-var _ Store = (*SQLiteStore)(nil)
diff --git a/Server/store/sqlite_events.go b/Server/store/sqlite_events.go
deleted file mode 100644
index 39af874d..00000000
--- a/Server/store/sqlite_events.go
+++ /dev/null
@@ -1,302 +0,0 @@
-package store
-
-import (
- "context"
- "database/sql"
- "fmt"
- "strings"
- "time"
-
- "github.com/owncord/server/db"
-)
-
-// ── EventStore (Phase B Step 7) ─────────────────────────────────────────────
-
-// PersistEvent appends a single event to the events table with the
-// caller-supplied seq. The hub assigns seq before this is called so the row
-// seq always matches the wrapped-payload seq, even if the persister drops
-// some events under load.
-func (s *SQLiteStore) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error {
- _, err := s.db.SQLDb().ExecContext(ctx,
- `INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?)`,
- seq, eventType, channelID, payload,
- )
- if err != nil {
- return fmt.Errorf("PersistEvent: %w", err)
- }
- return nil
-}
-
-// GetEventsSince returns events with seq > afterSeq up to limit, ordered ASC.
-func (s *SQLiteStore) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) {
- rows, err := s.db.SQLDb().QueryContext(ctx,
- `SELECT seq, event_type, channel_id, payload, created_at
- FROM events
- WHERE seq > ?
- ORDER BY seq ASC
- LIMIT ?`,
- afterSeq, limit,
- )
- if err != nil {
- return nil, fmt.Errorf("GetEventsSince: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanEventRows(rows)
-}
-
-// GetEventsSinceForChannels filters events to those whose channel_id is 0
-// (global broadcast) or in channelIDs.
-func (s *SQLiteStore) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) {
- // Build IN clause manually since database/sql does not expand slices.
- if len(channelIDs) == 0 {
- // Only global broadcasts.
- rows, err := s.db.SQLDb().QueryContext(ctx,
- `SELECT seq, event_type, channel_id, payload, created_at
- FROM events
- WHERE seq > ? AND channel_id = 0
- ORDER BY seq ASC
- LIMIT ?`,
- afterSeq, limit,
- )
- if err != nil {
- return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanEventRows(rows)
- }
-
- placeholders := make([]string, len(channelIDs))
- args := make([]any, 0, len(channelIDs)+2)
- args = append(args, afterSeq)
- for i, cid := range channelIDs {
- placeholders[i] = "?"
- args = append(args, cid)
- }
- args = append(args, limit)
-
- query := fmt.Sprintf(
- `SELECT seq, event_type, channel_id, payload, created_at
- FROM events
- WHERE seq > ?
- AND (channel_id = 0 OR channel_id IN (%s))
- ORDER BY seq ASC
- LIMIT ?`,
- strings.Join(placeholders, ","),
- )
- rows, err := s.db.SQLDb().QueryContext(ctx, query, args...)
- if err != nil {
- return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err)
- }
- defer func() { _ = rows.Close() }()
- return scanEventRows(rows)
-}
-
-// GetMaxEventSeq returns the largest seq in the events table, or 0 if empty.
-func (s *SQLiteStore) GetMaxEventSeq(ctx context.Context) (int64, error) {
- var maxSeq sql.NullInt64
- err := s.db.SQLDb().QueryRowContext(ctx, `SELECT MAX(seq) FROM events`).Scan(&maxSeq)
- if err != nil {
- return 0, fmt.Errorf("GetMaxEventSeq: %w", err)
- }
- if !maxSeq.Valid {
- return 0, nil
- }
- return maxSeq.Int64, nil
-}
-
-// PruneEventsOlderThan deletes events older than cutoff. Returns rows deleted.
-func (s *SQLiteStore) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
- res, err := s.db.SQLDb().ExecContext(ctx,
- `DELETE FROM events WHERE created_at < ?`,
- cutoff.UTC().Format("2006-01-02 15:04:05"),
- )
- if err != nil {
- return 0, fmt.Errorf("PruneEventsOlderThan: %w", err)
- }
- n, err := res.RowsAffected()
- if err != nil {
- return 0, fmt.Errorf("PruneEventsOlderThan RowsAffected: %w", err)
- }
- return n, nil
-}
-
-// ── PluginStore (Phase C Step 9) ────────────────────────────────────────────
-
-func (s *SQLiteStore) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) {
- res, err := s.db.SQLDb().ExecContext(ctx,
- `INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?)
- ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json`,
- name, version, manifestJSON,
- )
- if err != nil {
- return 0, fmt.Errorf("InstallPlugin: %w", err)
- }
- id, err := res.LastInsertId()
- if err != nil || id == 0 {
- // On conflict path LastInsertId may be 0; look up by name.
- row := s.db.SQLDb().QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, name)
- if scanErr := row.Scan(&id); scanErr != nil {
- return 0, fmt.Errorf("InstallPlugin lookup: %w", scanErr)
- }
- }
- return id, nil
-}
-
-func (s *SQLiteStore) EnablePlugin(ctx context.Context, id int64) error {
- _, err := s.db.SQLDb().ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id)
- return err
-}
-
-func (s *SQLiteStore) DisablePlugin(ctx context.Context, id int64) error {
- _, err := s.db.SQLDb().ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id)
- return err
-}
-
-func (s *SQLiteStore) UninstallPlugin(ctx context.Context, id int64) error {
- _, err := s.db.SQLDb().ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id)
- return err
-}
-
-func (s *SQLiteStore) GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) {
- row := s.db.SQLDb().QueryRowContext(ctx,
- `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?`,
- id,
- )
- return scanPluginRow(row)
-}
-
-func (s *SQLiteStore) GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) {
- row := s.db.SQLDb().QueryRowContext(ctx,
- `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?`,
- name,
- )
- return scanPluginRow(row)
-}
-
-func (s *SQLiteStore) ListPlugins(ctx context.Context) ([]db.PluginRow, error) {
- rows, err := s.db.SQLDb().QueryContext(ctx,
- `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name`,
- )
- if err != nil {
- return nil, fmt.Errorf("ListPlugins: %w", err)
- }
- defer func() { _ = rows.Close() }()
- var out []db.PluginRow
- for rows.Next() {
- var p db.PluginRow
- var enabledInt int64
- var installedAt string
- if err := rows.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil {
- return nil, fmt.Errorf("ListPlugins scan: %w", err)
- }
- p.Enabled = enabledInt != 0
- p.InstalledAt = parseSQLiteTime(installedAt)
- out = append(out, p)
- }
- return out, rows.Err()
-}
-
-func (s *SQLiteStore) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) {
- row := s.db.SQLDb().QueryRowContext(ctx,
- `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`,
- pluginID, key,
- )
- var v []byte
- if err := row.Scan(&v); err != nil {
- return nil, err
- }
- return v, nil
-}
-
-func (s *SQLiteStore) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error {
- _, err := s.db.SQLDb().ExecContext(ctx,
- `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?)
- ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`,
- pluginID, key, value,
- )
- return err
-}
-
-func (s *SQLiteStore) PluginKVDelete(ctx context.Context, pluginID int64, key string) error {
- _, err := s.db.SQLDb().ExecContext(ctx,
- `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`,
- pluginID, key,
- )
- return err
-}
-
-func (s *SQLiteStore) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) {
- rows, err := s.db.SQLDb().QueryContext(ctx,
- `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`,
- pluginID, prefix+"%", limit,
- )
- if err != nil {
- return nil, fmt.Errorf("PluginKVScan: %w", err)
- }
- defer func() { _ = rows.Close() }()
- out := make(map[string][]byte)
- for rows.Next() {
- var k string
- var v []byte
- if err := rows.Scan(&k, &v); err != nil {
- return nil, err
- }
- out[k] = v
- }
- return out, rows.Err()
-}
-
-// ── helpers ─────────────────────────────────────────────────────────────────
-
-type rowScanner interface {
- Scan(dest ...any) error
-}
-
-func scanPluginRow(row rowScanner) (*db.PluginRow, error) {
- var p db.PluginRow
- var enabledInt int64
- var installedAt string
- if err := row.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil {
- return nil, err
- }
- p.Enabled = enabledInt != 0
- p.InstalledAt = parseSQLiteTime(installedAt)
- return &p, nil
-}
-
-type rowsScanner interface {
- Next() bool
- Scan(dest ...any) error
- Err() error
-}
-
-func scanEventRows(rows rowsScanner) ([]db.PersistedEvent, error) {
- var out []db.PersistedEvent
- for rows.Next() {
- var e db.PersistedEvent
- var createdAt string
- if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &createdAt); err != nil {
- return nil, fmt.Errorf("scanEventRows: %w", err)
- }
- e.CreatedAt = parseSQLiteTime(createdAt)
- out = append(out, e)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func parseSQLiteTime(s string) time.Time {
- // SQLite CURRENT_TIMESTAMP returns "YYYY-MM-DD HH:MM:SS" in UTC.
- for _, layout := range []string{
- "2006-01-02 15:04:05",
- time.RFC3339,
- time.RFC3339Nano,
- } {
- if t, err := time.Parse(layout, s); err == nil {
- return t.UTC()
- }
- }
- return time.Time{}
-}
diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go
index 4d887594..12060914 100644
--- a/Server/ws/coverage_boost_test.go
+++ b/Server/ws/coverage_boost_test.go
@@ -15,7 +15,6 @@ import (
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
"github.com/owncord/server/ws"
)
@@ -79,7 +78,7 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
t.Helper()
database := openCoverageDB(t)
limiter := auth.NewRateLimiter()
- st := store.NewSQLiteStore(database)
+ st := database
svc := service.New(st, limiter)
hub := ws.NewHub(database, limiter, svc)
diff --git a/Server/ws/event_persister.go b/Server/ws/event_persister.go
index 3fecc361..90ea2dd3 100644
--- a/Server/ws/event_persister.go
+++ b/Server/ws/event_persister.go
@@ -16,7 +16,6 @@ import (
"sync/atomic"
"time"
- "github.com/owncord/server/store"
"github.com/owncord/server/telemetry"
)
@@ -32,7 +31,7 @@ type pendingEvent struct {
// EventPersister batches broadcast events and writes them to an EventStore.
type EventPersister struct {
- store store.EventStore
+ store EventStore
queue chan pendingEvent
batchSize int
flushEvery time.Duration
@@ -58,9 +57,9 @@ type EventPersister struct {
// queueSize sets the channel buffer; once full, Enqueue increments the
// dropped counter without blocking. batchSize and flushEvery control the
// flush triggers.
-func NewEventPersister(s store.EventStore, queueSize, batchSize int, flushEvery time.Duration) *EventPersister {
+func NewEventPersister(s EventStore, queueSize, batchSize int, flushEvery time.Duration) *EventPersister {
if s == nil {
- panic("ws: NewEventPersister requires a non-nil store.EventStore")
+ panic("ws: NewEventPersister requires a non-nil EventStore")
}
if queueSize <= 0 {
queueSize = 1024
diff --git a/Server/ws/event_persister_test.go b/Server/ws/event_persister_test.go
index 576a02a9..c105fb52 100644
--- a/Server/ws/event_persister_test.go
+++ b/Server/ws/event_persister_test.go
@@ -11,11 +11,27 @@ import (
"testing"
"time"
- "github.com/owncord/server/store"
+ "github.com/owncord/server/db"
)
+// openPersisterTestDB opens an in-memory database with migrations applied for
+// EventPersister tests. *db.DB satisfies the EventStore interface the persister
+// depends on (D3 removed the store abstraction).
+func openPersisterTestDB(t *testing.T) *db.DB {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ if err := db.Migrate(database); err != nil {
+ t.Fatalf("db.Migrate: %v", err)
+ }
+ return database
+}
+
func TestEventPersisterFlushesBatch(t *testing.T) {
- mem := store.NewMemStore()
+ mem := openPersisterTestDB(t)
p := NewEventPersister(mem, 1024, 4, 50*time.Millisecond)
ctx := context.Background()
p.Start(ctx)
@@ -56,7 +72,7 @@ func TestEventPersisterFlushesBatch(t *testing.T) {
}
func TestEventPersisterDropsOnFullQueue(t *testing.T) {
- mem := store.NewMemStore()
+ mem := openPersisterTestDB(t)
// Tiny queue, very long flush interval — guarantees drops because the
// flusher won't drain fast enough.
p := NewEventPersister(mem, 2, 1024, time.Hour)
@@ -75,7 +91,7 @@ func TestEventPersisterDropsOnFullQueue(t *testing.T) {
}
func TestEventPersisterStopDrains(t *testing.T) {
- mem := store.NewMemStore()
+ mem := openPersisterTestDB(t)
p := NewEventPersister(mem, 256, 100, time.Hour)
p.Start(context.Background())
diff --git a/Server/ws/event_pruner.go b/Server/ws/event_pruner.go
index f9b62574..4ae7cd58 100644
--- a/Server/ws/event_pruner.go
+++ b/Server/ws/event_pruner.go
@@ -10,8 +10,6 @@ import (
"context"
"log/slog"
"time"
-
- "github.com/owncord/server/store"
)
// maxStartupDelay caps how long StartEventPruner waits before its first
@@ -23,7 +21,7 @@ const maxStartupDelay = time.Minute
// StartEventPruner launches a goroutine that wakes every interval and deletes
// events older than retention. The goroutine exits when ctx is cancelled.
-func StartEventPruner(ctx context.Context, s store.EventStore, retention, interval time.Duration) {
+func StartEventPruner(ctx context.Context, s EventStore, retention, interval time.Duration) {
if s == nil {
return
}
@@ -63,7 +61,7 @@ func StartEventPruner(ctx context.Context, s store.EventStore, retention, interv
}()
}
-func runPrune(ctx context.Context, s store.EventStore, retention time.Duration) {
+func runPrune(ctx context.Context, s EventStore, retention time.Duration) {
cutoff := time.Now().Add(-retention)
deleted, err := s.PruneEventsOlderThan(ctx, cutoff)
if err != nil {
diff --git a/Server/ws/eventstore.go b/Server/ws/eventstore.go
new file mode 100644
index 00000000..7085b95c
--- /dev/null
+++ b/Server/ws/eventstore.go
@@ -0,0 +1,20 @@
+package ws
+
+import (
+ "context"
+ "time"
+
+ "github.com/owncord/server/db"
+)
+
+// EventStore persists broadcast events for cold-tier replay during reconnection
+// when the in-memory ring buffer no longer covers the client's last_seq.
+// *db.DB satisfies it (the methods moved into the db package when the store
+// abstraction was removed in D3).
+type EventStore interface {
+ PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error
+ GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error)
+ GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error)
+ PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
+ GetMaxEventSeq(ctx context.Context) (int64, error)
+}
diff --git a/Server/ws/handler_v2_channel_focus_test.go b/Server/ws/handler_v2_channel_focus_test.go
index 8f08560d..d44f1b2c 100644
--- a/Server/ws/handler_v2_channel_focus_test.go
+++ b/Server/ws/handler_v2_channel_focus_test.go
@@ -8,7 +8,6 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
)
// newFocusTestDeps creates an in-memory DB with a user (role=Owner, id=1) and
@@ -33,7 +32,7 @@ func newFocusTestDeps(t *testing.T) (PresenceDeps, int64, int64) {
t.Fatalf("CreateChannel: %v", err)
}
- st := store.NewSQLiteStore(database)
+ st := database
svc := service.New(st, auth.NewRateLimiter())
deps := PresenceDeps{Limiter: nil, ChannelSvc: svc.Channels}
return deps, userID, chID
@@ -111,7 +110,7 @@ func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) {
t.Fatalf("INSERT channel_overrides: %v", err)
}
- st := store.NewSQLiteStore(database)
+ st := database
svc := service.New(st, auth.NewRateLimiter())
deps := PresenceDeps{Limiter: nil, ChannelSvc: svc.Channels}
diff --git a/Server/ws/handlers_command_test.go b/Server/ws/handlers_command_test.go
index abde5e14..29734e44 100644
--- a/Server/ws/handlers_command_test.go
+++ b/Server/ws/handlers_command_test.go
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/owncord/server/plugin"
- "github.com/owncord/server/store"
"github.com/owncord/server/ws"
)
@@ -55,14 +54,12 @@ func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
// plugin owning the command.
func TestChatCommand_UnknownCommand_ReturnsError(t *testing.T) {
hub, database := newTestHub(t)
- _ = database
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
- mem := store.NewMemStore()
- reg, err := plugin.NewRegistry(plugin.Config{Store: mem})
+ reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go
index 2726cb0e..688a81c8 100644
--- a/Server/ws/handlers_test.go
+++ b/Server/ws/handlers_test.go
@@ -11,7 +11,6 @@ import (
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
"github.com/owncord/server/ws"
)
@@ -74,7 +73,7 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) {
t.Helper()
database := openHandlerDB(t)
limiter := auth.NewRateLimiter()
- st := store.NewSQLiteStore(database)
+ st := database
svc := service.New(st, limiter)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
diff --git a/Server/ws/hub.go b/Server/ws/hub.go
index ba12e9ea..919f2f56 100644
--- a/Server/ws/hub.go
+++ b/Server/ws/hub.go
@@ -16,7 +16,6 @@ import (
"github.com/owncord/server/permissions"
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
"github.com/owncord/server/syncutil"
)
@@ -65,7 +64,7 @@ type Hub struct {
// because main.go wires these after NewRouter has already started the
// Run loop, which reads them on the broadcast/replay paths.
eventPersister atomic.Pointer[EventPersister]
- eventStore atomic.Pointer[store.EventStore] // read path for cold-tier replay
+ eventStore atomic.Pointer[EventStore] // read path for cold-tier replay
// Phase C Step 9 — plugin wiring.
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; wire before Run
@@ -773,7 +772,7 @@ func (h *Hub) SetEventPersister(p *EventPersister) {
// reconnect replay path. Typically the same store backing SetEventPersister.
// Pass nil to disable. Safe to call at any time, including after Run has
// started.
-func (h *Hub) SetEventStore(s store.EventStore) {
+func (h *Hub) SetEventStore(s EventStore) {
if s == nil {
h.eventStore.Store(nil)
return
diff --git a/Server/ws/reconnect_db_test.go b/Server/ws/reconnect_db_test.go
index e6661f99..5a3e48d0 100644
--- a/Server/ws/reconnect_db_test.go
+++ b/Server/ws/reconnect_db_test.go
@@ -19,17 +19,33 @@ import (
"nhooyr.io/websocket"
"github.com/owncord/server/auth"
- "github.com/owncord/server/store"
+ "github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
+// openEventStoreDB opens an in-memory database with the full migration set so
+// the events table exists. *db.DB satisfies the hub's EventStore interface
+// (D3 removed the store abstraction and its MemStore fake).
+func openEventStoreDB(t *testing.T) *db.DB {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ if err := db.Migrate(database); err != nil {
+ t.Fatalf("db.Migrate: %v", err)
+ }
+ return database
+}
+
// TestReconnect_BufferMiss_FallsBackToDBTier verifies that when a client
// reconnects with a last_seq that is older than the ring buffer's oldest entry,
// the hub falls back to the EventStore (DB tier) and sends the missed events.
//
// Setup:
// - Ring buffer size = 1000; push seqs 501..1500 → oldestSeq = 501.
-// - MemStore contains 100 global events at seqs 501..600.
+// - The DB event store contains 100 global events at seqs 501..600.
// - Client reconnects with last_seq = 500.
// - Buffer: 500 <= 501 → returns nil.
// - DB: returns seqs > 500 with channelID = 0 (global, no permission filter).
@@ -43,8 +59,8 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) {
// Create a user. role_id=1 intentionally does not exist in the test DB so
// computeAllowedChannels returns an empty channel set — but events with
- // channelID=0 (global) bypass the per-channel filter in MemStore and in
- // EventsSinceFiltered, so they are always returned.
+ // channelID=0 (global) bypass the per-channel filter in the DB event store
+ // and in EventsSinceFiltered, so they are always returned.
userID, err := database.CreateUser("reconnect-db-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
@@ -58,20 +74,21 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) {
}
// Pre-populate the event store with 100 global events (seqs 501..600).
- // channelID=0 means "global broadcast" — MemStore.GetEventsSinceForChannels
- // returns them regardless of the allowed-channel filter.
- memStore := store.NewMemStore()
+ // channelID=0 means "global broadcast" — the DB event store's
+ // GetEventsSinceForChannels returns them regardless of the allowed-channel
+ // filter.
+ eventStore := openEventStoreDB(t)
bgCtx := context.Background()
for seq := int64(501); seq <= 600; seq++ {
payload := []byte(fmt.Sprintf(`{"seq":%d,"type":"broadcast"}`, seq))
- if err := memStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil {
+ if err := eventStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil {
t.Fatalf("PersistEvent seq=%d: %v", seq, err)
}
}
- // Build hub, attach the MemStore as the cold-tier read path.
+ // Build hub, attach the DB event store as the cold-tier read path.
hub := ws.NewHub(database, limiter, nil)
- hub.SetEventStore(memStore)
+ hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go
index 18cbd76b..ea850e87 100644
--- a/Server/ws/ws_integration_test.go
+++ b/Server/ws/ws_integration_test.go
@@ -17,7 +17,6 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/service"
- "github.com/owncord/server/store"
"github.com/owncord/server/ws"
)
@@ -998,7 +997,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) {
func TestIntegration_MessageRoundTrip(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
- st := store.NewSQLiteStore(database)
+ st := database
svc := service.New(st, limiter)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md
index ba2072eb..423a449b 100644
--- a/docs/architecture/data-model.md
+++ b/docs/architecture/data-model.md
@@ -105,21 +105,24 @@ erDiagram
| Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. |
| Ops | `settings`, `audit_log`, `sounds` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. |
-### How the schema is accessed (three coexisting styles)
+### How the schema is accessed
-1. **Raw SQL in `Server/db`** — hand-written queries (`*_queries.go`, ~178 call
- sites). This is what actually runs, used directly by `Server/api` handlers,
- `Server/admin`, and the `ws.Hub`.
-2. **`store.Store` interface** (`Server/store`) — a 14-domain composed interface
- with `SQLiteStore` (delegates to `db.DB`) and `MemStore` (test double).
- Consumed by the `Server/service` layer only.
-3. **sqlc-generated `Server/db/dbgen`** (~3.5k LOC, from `Server/db/queries/sqlite/`
- per `sqlc.yaml`) — **imported by nothing**; dead code kept verified by the
- `sqlc-verify` CI job.
+The `Server/db` package is the single data layer. Its methods live in
+`*_queries.go` and mostly delegate to the sqlc-generated `Server/db/dbgen` code
+(D2), so sqlc is the type-checked query layer rather than dead generated code; a
+documented remainder of raw queries stays hand-written where sqlc can't express
+them (variable-length `IN` lists, FTS, multi-statement transactions,
+PRAGMA/VACUUM) — see [plans/sqlc-adoption.md](../plans/sqlc-adoption.md).
-The coexistence of all three is the largest structural finding of the audit —
-see [audit-2026-07-19.md §3](../audit-2026-07-19.md).
+Consumers depend on narrow interfaces that `*db.DB` satisfies rather than on the
+concrete type: `service.Store` (the service layer), `ws.EventStore` (cold-tier
+replay), and `plugin.PluginStore` (plugin registry). D3 removed the former
+`store` package (a pass-through `SQLiteStore` plus a `MemStore` test double);
+tests now run against a real in-memory SQLite `db`. The residual style issue is
+that many `Server/api` handlers and the `Server/admin` package still take a raw
+`*db.DB` directly instead of going through the service layer — the remaining
+consolidation work (audit A-2026-07-06).
**Source of truth:** `Server/migrations/*.sql` (schema), `Server/db/migrate.go`
-(runner), `Server/db/*_queries.go` (live queries), `Server/store/store.go`
-(interface), `sqlc.yaml` + `Server/db/dbgen/` (dormant generated layer).
+(runner), `Server/db/*_queries.go` (live queries), `Server/service/datastore.go`
+(service interface), `sqlc.yaml` + `Server/db/dbgen/` (generated query layer).
diff --git a/docs/architecture/server.md b/docs/architecture/server.md
index d36d405f..a0abb77e 100644
--- a/docs/architecture/server.md
+++ b/docs/architecture/server.md
@@ -31,10 +31,9 @@ flowchart TB
end
subgraph data ["Data"]
- STORE["store
Store interface,
SQLiteStore, MemStore"]
- DB[("db
raw SQL queries,
migration runner, models")]
- DBGEN["db/dbgen
sqlc-generated"]
- MIG["migrations
001–015 embedded SQL"]
+ DB[("db
query methods (sqlc-backed),
migration runner, models")]
+ DBGEN["db/dbgen
sqlc-generated queries"]
+ MIG["migrations
001–016 embedded SQL"]
end
subgraph support ["Support"]
@@ -48,7 +47,6 @@ flowchart TB
MAIN --> CFG
MAIN --> API
MAIN --> DB
- MAIN --> STORE
API --> ADMIN
API --> WS
API --> SVC
@@ -56,32 +54,28 @@ flowchart TB
API --> STORAGE
API --> UPD
API --> PLUGIN
- SVC --> STORE
+ SVC --> DB
SVC --> PERM
- STORE --> DB
+ DB --> DBGEN
DB --> MIG
WS --> SVC
WS --> PLUGIN
- %% Layering violations (dashed red): raw *db.DB used above the store seam
+ %% Residual layering seam (dashed): raw *db.DB used above the service layer
API -.->|"handlers take *db.DB directly"| DB
ADMIN -.->|"raw *db.DB"| DB
- WS -.->|"inline SQL for settings"| DB
- DBGEN -.-|"generated but imported by nothing"| DB
-
- classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6
- class DBGEN dead
```
-**What this shows.** The intended layering is
-`api → service → store → db`, and the `service` layer does follow it. But three
-dashed edges mark where the seam is bypassed: nearly every REST handler receives
-both `svc` *and* a raw `*db.DB`; the `admin` package operates on `*db.DB`
-almost exclusively; and the `ws.Hub` runs inline SQL against the `settings`
-table. `db/dbgen` (sqlc output) is generated and CI-verified but referenced by
-no code. In effect the server has three coexisting data-access styles — see
-[data-model.md](data-model.md) and the audit for the consolidation
-recommendation.
+**What this shows.** The layering is `api → service → db` (D3 removed the former
+`store` seam). The `service` layer depends on a narrow `service.Store` interface
+that `*db.DB` satisfies; `ws` and `plugin` depend on their own small interfaces
+(`ws.EventStore`, `plugin.PluginStore`) the same way. The `db` package's query
+methods delegate to the sqlc-generated `db/dbgen` code (D2), so sqlc is now the
+type-checked query layer rather than dead generated code. Two dashed edges mark
+the residual seam: many REST handlers still receive a raw `*db.DB` alongside
+`svc`, and the `admin` package operates on `*db.DB` almost exclusively —
+consolidating those behind the service layer is the remaining work (audit
+A-2026-07-06). See [data-model.md](data-model.md).
`api.NewRouter` (`Server/api/router.go`) is the composition root: it constructs
the rate limiter, TOTP key, storage, `service.New`, the `ws.Hub`, the LiveKit
@@ -91,7 +85,7 @@ process-level wiring (config, TLS, DB, event persistence, HTTP server,
shutdown).
**Source of truth:** `Server/main.go`, `Server/api/router.go`, package import
-graph (`go list -deps`), `Server/store/store.go`, `sqlc.yaml`.
+graph (`go list -deps`), `Server/service/datastore.go`, `sqlc.yaml`.
## D3 — REST request lifecycle
@@ -103,7 +97,7 @@ sequenceDiagram
participant RT as Route mount
(Mount*Routes)
participant H as Handler
participant S as service.*
- participant ST as store.Store
+ participant ST as service.Store
(*db.DB)
participant DB as SQLite
C->>MW: HTTPS request
@@ -112,12 +106,12 @@ sequenceDiagram
Note over RT: AuthMiddleware(database)
+ per-route rate limits
+ RequirePermission(...) where mounted
RT->>H: authenticated request
H->>S: domain call (svc.Messages, svc.Permissions, …)
- S->>ST: Store interface method
- ST->>DB: SQL (via db.DB)
+ S->>ST: narrow Store interface method
+ ST->>DB: SQL (db.DB query method → sqlc dbgen)
DB-->>C: JSON response (errorResponse envelope on failure)
rect rgba(200,120,120,0.15)
- Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)
bypasses service/store and queries *db.DB directly.
Admin REST (Server/admin) does the same behind
AdminIPRestrict + RequireAdminAuth.
+ Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)
bypasses the service layer and queries *db.DB directly.
Admin REST (Server/admin) does the same behind
AdminIPRestrict + RequireAdminAuth.
end
```
diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md
index c5a1ab49..8ee85dbc 100644
--- a/docs/audit-2026-07-19.md
+++ b/docs/audit-2026-07-19.md
@@ -19,7 +19,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog
| A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies |
| A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope |
| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | RESOLVED 2026-07-19 — `dbgen` wired into `db.DB`; 97 methods across all domains delegate to it (no longer dead). Remaining raw queries (variable IN / FTS / tx) tracked in [plans/sqlc-adoption.md](plans/sqlc-adoption.md) |
-| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | DECIDED 2026-07-19 — single data layer: sqlc-backed db pkg, remove store/ (D2+D3) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
+| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | RESOLVED 2026-07-19 — collapsed to a single sqlc-backed `db` package: dbgen wired in (D2) and the `store` seam deleted (D3). The service layer depends on a narrow `service.Store` interface `*db.DB` satisfies; ws/plugin similarly. Broadening service-only access above the remaining direct-`db` handlers is the residual layering work (A-2026-07-06 backlog item 12) |
| A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | OPEN |
| A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | CLOSED 2026-07-19 — codegen implemented: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-verify` CI gate |
| A-2026-07-09 | MEDIUM | Dual V1+V2 WS dispatch (strangler-fig) still live; two parsers/registries to keep in sync | OPEN |
@@ -50,7 +50,7 @@ audit's table; details stay in [audit-2026-04-07.md](audit-2026-04-07.md).
| Prior # | Sev | Finding (one-line) | Re-verification (2026-07-19) |
|---------|-----|--------------------|------------------------------|
| 1–5 | CRITICAL | Plugin governance (timeout, storage isolation, ACL, event rate limit, HTTP exfiltration) | Unchanged since prior closure table; plugins still default-disabled (`plugins.enabled: false`), which is the standing mitigation |
-| 6 | HIGH | `Server/store/` untested | **Still zero `_test.go` files** in `Server/store/`. The "SUPERSEDED — remove in P4" plan has not executed; the package remains the designated abstraction seam with no direct tests |
+| 6 | HIGH | `Server/store/` untested | **RESOLVED 2026-07-19 (D3)** — the `store/` package is deleted rather than tested. `SQLiteStore` was a pure pass-through to `*db.DB`; its event/plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`). Consumers now depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`), and the former `MemStore`-based unit tests run against a real in-memory SQLite `db` — so the code paths that were untested through the seam are now exercised directly |
| 7 | HIGH | Client unit coverage | Suite is large (157 test files) but currently KNOWN RED and non-blocking — see A-2026-07-04 |
| 9 | MEDIUM | auth_handler bypasses service layer | **Confirmed open** — `Server/api/router.go:101` passes `database *db.DB` to `MountAuthRoutes` while sibling mounts receive `svc` |
| 10 | MEDIUM | Audit-trail write failures silently ignored | **Fixed 2026-07-19** at the two flagged backup-handler sites (errors now logged). Wider scope discovered: the `_ = LogAudit` pattern exists at 23 call sites across admin/api/ws/service — appears to be a deliberate best-effort convention; policy decision tracked in the decisions doc (D8 note) |
@@ -110,10 +110,10 @@ a decision on A first.
## 3. Server architecture findings
-Verdict: the intended layering (`api → service → store → db`) is sound and the
-`service` layer respects it; test discipline is excellent (test LOC ≈ 1.6×
-source; race + deadlock + mutation tooling). The findings are about the seams
-that grew around that design.
+Verdict: the intended layering (`api → service → db`, since D3 collapsed the
+former `store` seam) is sound and the `service` layer respects it; test
+discipline is excellent (test LOC ≈ 1.6× source; race + deadlock + mutation
+tooling). The findings are about the seams that grew around that design.
| ID | Sev | Area | Evidence | Finding | Recommendation | Effort |
|----|-----|------|----------|---------|----------------|--------|
@@ -164,7 +164,7 @@ Ranked by severity × effort; quick wins float within tier. S/M/L ≈ hours / da
| 2 | Delete (or finally adopt) the dead `db/dbgen` sqlc layer; adjust the `sqlc-verify` CI job to match | A-2026-07-05 | MEDIUM | S |
| 3 | Extract the single channel-visibility function replacing the 4 "must mirror" copies; add REST/WS agreement test | A-2026-07-07 | MEDIUM | M |
| 4 | One-PR refresh of api.md / protocol.md / schema.md against §2, incl. E2EE + plugins + migrations 009–015; then enforce spec-updates-with-code via PR checklist | A-2026-07-03 | HIGH | M |
-| 5 | Execute the store-layer decision from prior #6: remove `Server/store/` (P4 plan) or test it directly | prior #6 | HIGH | M |
+| ~~5~~ | ~~Execute the store-layer decision from prior #6: remove `Server/store/` (P4 plan) or test it directly~~ **DONE 2026-07-19 (D3)** — `store/` removed; consumers on narrow `*db.DB` interfaces; tests on real in-memory SQLite | prior #6 | HIGH | M |
| 6 | Client HTTP TOFU pinning (Rust proxy mirroring `ws_proxy.rs`) | A-2026-07-02 | HIGH | M |
| 7 | Stop discarding `LogAudit` errors in `admin/handlers_backup.go`; fix the contradictory upload `Cache-Control` | prior #10, W3-4 | MEDIUM | S |
| 8 | Remove the SolidJS beachhead + adapters; retire or rewrite `docs/client-architecture.md` | A-2026-07-12 | MEDIUM | S |
diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md
index 53818afe..703d5832 100644
--- a/docs/plans/audit-2026-07-19-decisions.md
+++ b/docs/plans/audit-2026-07-19-decisions.md
@@ -16,7 +16,7 @@ here (and the audit's closure table) as items land.
|---|----------------|----------|----------|--------|
| D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | **Implemented 2026-07-19**: migration 016 allows `announcement`; posting requires MANAGE_MESSAGES (readable like text); admin already offered it; client renders a megaphone icon + unread counts; specs updated. |
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **Largely done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; 97 methods across all domains delegate to sqlc (no longer dead code). ~43 raw calls remain by design (variable IN, FTS, multi-statement tx, PRAGMA/VACUUM) — tracked in [sqlc-adoption.md](sqlc-adoption.md). |
-| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) |
+| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`** via interface segregation: delete `SQLiteStore` + `MemStore` + the `store` package; port the event/plugin methods into `db`; each consumer depends on a small interface `*db.DB` satisfies. | **Implemented 2026-07-19**: the `store/` package is deleted. Event and plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`); consumers depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`). All service/ws/plugin/api tests now run against a real in-memory SQLite `db` with seed helpers; fault-injection tests embed a real `*db.DB` and override the one method under test. Full server suite + `sqlc-verify` green. |
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19** — `src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). |
| D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | **Implemented 2026-07-19** — solid/ dir, solidMount/solidAdapter, setup-solid tests, vite-plugin-solid, jsx tsconfig settings, and solid-js/@solidjs deps all removed; client-architecture.md is now a pointer. |