refactor(server): remove the store abstraction layer (D3)

Deletes Server/store (SQLiteStore, MemStore, the composed Store
interface) and collapses to a single sqlc-backed db package, executing
the prior audit's P4 "single data layer" direction (finding #6).

SQLiteStore was a pure pass-through to *db.DB, so consumers now depend
on narrow interfaces that *db.DB satisfies directly:

  - service.Store   (service/datastore.go, renamed from store/store.go)
  - ws.EventStore   (ws/eventstore.go)
  - plugin.PluginStore (plugin/pluginstore.go)

The event- and plugin-KV methods that lived in the store's SQLite
implementation move into the db package (db/event_queries.go,
db/plugin_queries.go), keeping their raw-SQL form.

Tests: the MemStore-based unit tests now run against a real in-memory
SQLite db opened per-test with migrations applied, via package-local
seed helpers. Fault-injection tests embed a real *db.DB and override the
single method under test, preserving error-path coverage. Full server
suite and sqlc-verify are green.

Docs: audit finding #6 and A-2026-07-06 marked resolved; decisions D3
updated; architecture server.md / data-model.md diagrams and prose
updated to the api -> service -> db layering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 16:33:58 +00:00
parent 071426c0d8
commit 5f1d6fc287
54 changed files with 936 additions and 2241 deletions
+1 -2
View File
@@ -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))
}
+2 -3
View File
@@ -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)
+1 -2
View File
@@ -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)
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
+2 -3
View File
@@ -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)
+36 -20
View File
@@ -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,
+1 -2
View File
@@ -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
}
+4 -4
View File
@@ -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))
})
})
+1 -2
View File
@@ -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 ─────────────────────────────────────────────────
+163
View File
@@ -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{}
}
+152
View File
@@ -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
}
+5 -12
View File
@@ -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()
+24
View File
@@ -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
}
+2 -2
View File
@@ -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.
+3 -5
View File
@@ -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)
+25
View File
@@ -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)
}
+1 -3
View File
@@ -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.
+2 -4
View File
@@ -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,
+2 -3
View File
@@ -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}
}
+2 -3
View File
@@ -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,
@@ -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)
}
+2 -3
View File
@@ -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}
}
+2 -3
View File
@@ -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}
}
+2 -3
View File
@@ -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,
+93 -92
View File
@@ -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,
+2 -3
View File
@@ -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}
}
+18 -18
View File
@@ -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")
}
+2 -3
View File
@@ -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,
+37 -37
View File
@@ -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) {
+181
View File
@@ -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
}
+1 -2
View File
@@ -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{
+2 -3
View File
@@ -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}
}
+13 -12
View File
@@ -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)
+2 -3
View File
@@ -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}
}
-824
View File
@@ -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")
}
-289
View File
@@ -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
}
-364
View File
@@ -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)
-302
View File
@@ -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{}
}
+1 -2
View File
@@ -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)
+3 -4
View File
@@ -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
+20 -4
View File
@@ -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())
+2 -4
View File
@@ -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 {
+20
View File
@@ -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)
}
+2 -3
View File
@@ -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}
+1 -4
View File
@@ -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)
}
+1 -2
View File
@@ -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()
+2 -3
View File
@@ -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
+27 -10
View File
@@ -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()
+1 -2
View File
@@ -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()
+17 -14
View File
@@ -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).
+21 -27
View File
@@ -31,10 +31,9 @@ flowchart TB
end
subgraph data ["Data"]
STORE["store<br/>Store interface,<br/>SQLiteStore, MemStore"]
DB[("db<br/>raw SQL queries,<br/>migration runner, models")]
DBGEN["db/dbgen<br/>sqlc-generated"]
MIG["migrations<br/>001015 embedded SQL"]
DB[("db<br/>query methods (sqlc-backed),<br/>migration runner, models")]
DBGEN["db/dbgen<br/>sqlc-generated queries"]
MIG["migrations<br/>001016 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<br/>(Mount*Routes)
participant H as Handler
participant S as service.*
participant ST as store.Store
participant ST as service.Store<br/>(*db.DB)
participant DB as SQLite
C->>MW: HTTPS request
@@ -112,12 +106,12 @@ sequenceDiagram
Note over RT: AuthMiddleware(database)<br/>+ per-route rate limits<br/>+ 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, …)<br/>bypasses service/store and queries *db.DB directly.<br/>Admin REST (Server/admin) does the same behind<br/>AdminIPRestrict + RequireAdminAuth.
Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)<br/>bypasses the service layer and queries *db.DB directly.<br/>Admin REST (Server/admin) does the same behind<br/>AdminIPRestrict + RequireAdminAuth.
end
```
+7 -7
View File
@@ -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) |
|---------|-----|--------------------|------------------------------|
| 15 | 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 009015; 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 |
+1 -1
View File
@@ -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. |