feat(settings): publish user_settings change events

Add a user_settings realtime channel so clients learn when a setting
changed on another device without polling. The channel is modeled on
user_state: non-admin subscribable, per-user addressed envelopes, null
snapshot.

SettingValuesHandler gains an EventsHub and publishes
user_settings.changed after every successful PUT and DELETE on
/settings/values/{key}. The payload carries only key, scope and
profile_id — never the value. Admins receive every user's user-scoped
events, so a value in the payload would leak private settings to
admins; interested clients re-fetch over the scoped REST API instead.
The payload is always non-empty because an empty Data falls back to a
null snapshot in the hub. A nil hub (tests) skips publishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-28 02:36:38 +00:00
co-authored by Claude Fable 5
parent 40e0f77a74
commit 2d74be984f
6 changed files with 172 additions and 0 deletions
+1
View File
@@ -334,6 +334,7 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelUserState,
evt.ChannelUserSettings,
evt.ChannelNotifications,
}
if role == "admin" {
+9
View File
@@ -14,6 +14,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -31,6 +32,10 @@ type SettingValuesHandler struct {
storeProvider userstore.UserStoreProvider
contract *settingscontract.Manifest
resolver *settingsresolve.Resolver
// EventsHub, when set, receives a user_settings.changed event after every
// successful write or delete. Nil (as in tests) simply skips publishing.
EventsHub *evt.Hub
}
// NewSettingValuesHandler builds the handler over the embedded contract.
@@ -241,6 +246,8 @@ func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Req
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return
}
publishUserSettingsEvent(r.Context(), h.EventsHub,
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
writeJSON(w, http.StatusOK, settingValueToResponse(*stored))
}
@@ -265,6 +272,8 @@ func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http.
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
publishUserSettingsEvent(r.Context(), h.EventsHub,
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,41 @@
package handlers
import (
"context"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// userSettingsEventPayload deliberately carries the identity of what changed
// and never the value. Admins receive every user's user-scoped events (see
// allowsEventForClaims in events_ws.go), so a value here would leak private
// settings to admins. Clients that care about the new value re-fetch it over
// the REST API, where access is scoped to the caller's own session.
type userSettingsEventPayload struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
}
const userSettingsChangedEvent = "user_settings.changed"
func publishUserSettingsEvent(
ctx context.Context,
hub *evt.Hub,
userID int,
profileID, key, scope string,
) {
if hub == nil || userID == 0 || key == "" || scope == "" {
return
}
// The payload is always non-empty: an empty Data would fall back to a null
// snapshot frame in the hub, telling subscribers nothing at all.
_ = hub.PublishJSON(ctx, evt.ChannelUserSettings, userSettingsChangedEvent, userSettingsEventPayload{
Key: key,
Scope: scope,
ProfileID: profileID,
}, evt.PublishOptions{
UserID: userID,
ProfileID: profileID,
})
}
@@ -0,0 +1,118 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/Silo-Server/silo-server/internal/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// receiveUserSettingsEvent drains one envelope from the subscription, which is
// already buffered by the time the handler returns because local fan-out is
// synchronous.
func receiveUserSettingsEvent(t *testing.T, events <-chan evt.Envelope) evt.Envelope {
t.Helper()
select {
case env := <-events:
return env
default:
t.Fatal("no event was published to the hub")
return evt.Envelope{}
}
}
func assertUserSettingsEnvelope(t *testing.T, env evt.Envelope, wantKey, wantScope string) {
t.Helper()
if env.Channel != evt.ChannelUserSettings {
t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelUserSettings)
}
if env.Event != userSettingsChangedEvent {
t.Errorf("event = %q, want %q", env.Event, userSettingsChangedEvent)
}
if env.UserID != 1 || env.ProfileID != "profile-1" {
t.Errorf("addressed to user %d profile %q, want 1/profile-1", env.UserID, env.ProfileID)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(env.Data, &payload); err != nil {
t.Fatalf("payload is not a JSON object: %v", err)
}
if string(payload["key"]) != `"`+wantKey+`"` {
t.Errorf("payload key = %s, want %q", payload["key"], wantKey)
}
if string(payload["scope"]) != `"`+wantScope+`"` {
t.Errorf("payload scope = %s, want %q", payload["scope"], wantScope)
}
if string(payload["profile_id"]) != `"profile-1"` {
t.Errorf("payload profile_id = %s, want \"profile-1\"", payload["profile_id"])
}
// The value must never ride along: admins receive every user's user-scoped
// events, so a value here would leak private settings to admins.
if raw, present := payload["value"]; present {
t.Errorf("payload carries a value (%s); it must never leak into events", raw)
}
}
func TestSetValuePublishesUserSettingsEvent(t *testing.T) {
handler, _ := newValuesTestHandler(t)
handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := handler.EventsHub.Subscribe()
defer unsubscribe()
rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile", []byte(`{"value":"ja"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
env := receiveUserSettingsEvent(t, events)
assertUserSettingsEnvelope(t, env, "playback.subtitle_language", "profile")
}
func TestDeleteValuePublishesUserSettingsEvent(t *testing.T) {
handler, _ := newValuesTestHandler(t)
handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := handler.EventsHub.Subscribe()
defer unsubscribe()
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile", []byte(`{"value":"ja"}`)); rec.Code != http.StatusOK {
t.Fatalf("seeding PUT = %d: %s", rec.Code, rec.Body.String())
}
<-events // drain the write's own event
rec := routeValues(t, handler, http.MethodDelete, "playback.subtitle_language",
"scope=profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
env := receiveUserSettingsEvent(t, events)
assertUserSettingsEnvelope(t, env, "playback.subtitle_language", "profile")
}
// TestFailedMutationsPublishNothing: a refused write and a delete of nothing
// must not tell clients something changed.
func TestFailedMutationsPublishNothing(t *testing.T) {
handler, _ := newValuesTestHandler(t)
handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := handler.EventsHub.Subscribe()
defer unsubscribe()
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile", []byte(`{"value":"!!!"}`)); rec.Code != http.StatusBadRequest {
t.Fatalf("invalid PUT = %d, want 400", rec.Code)
}
if rec := routeValues(t, handler, http.MethodDelete, "playback.subtitle_language",
"scope=profile", nil); rec.Code != http.StatusNotFound {
t.Fatalf("DELETE of nothing = %d, want 404", rec.Code)
}
select {
case env := <-events:
t.Errorf("a failed mutation published %s on %s", env.Event, env.Channel)
default:
}
}
+1
View File
@@ -790,6 +790,7 @@ func NewRouter(deps Dependencies) chi.Router {
// which degrades to "no typed settings routes" instead of no server.
if contract, err := settingscontract.Load(); err == nil {
settingValuesHandler = handlers.NewSettingValuesHandler(deps.UserStoreProvider, contract)
settingValuesHandler.EventsHub = deps.EventsHub
}
homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider)
homeDismissalHandler.EventsHub = deps.EventsHub
+2
View File
@@ -15,6 +15,7 @@ const (
ChannelScans EventChannel = "scans"
ChannelHistoryImport EventChannel = "history_import"
ChannelUserState EventChannel = "user_state"
ChannelUserSettings EventChannel = "user_settings"
ChannelSettings EventChannel = "settings"
ChannelPlugins EventChannel = "plugins"
// ChannelNotifications carries profile-scoped user notifications
@@ -31,6 +32,7 @@ var AllChannels = []EventChannel{
ChannelScans,
ChannelHistoryImport,
ChannelUserState,
ChannelUserSettings,
ChannelSettings,
ChannelPlugins,
ChannelNotifications,