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:
@@ -334,6 +334,7 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
evt.ChannelCatalog,
|
||||
evt.ChannelHistoryImport,
|
||||
evt.ChannelUserState,
|
||||
evt.ChannelUserSettings,
|
||||
evt.ChannelNotifications,
|
||||
}
|
||||
if role == "admin" {
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user