fix(sync): websocket events are per-user — chokepoint the fan-out

Owner-flagged after the dev-docs audit: BroadcastProgressUpdate,
BroadcastAnnotationUpdate, and BroadcastConflictNotification fanned out
through the untargeted Broadcast, delivering one user's book progress,
conflict notices, and — worst — annotation payloads (which can carry
note/highlight text) to EVERY connected client on the instance. A
family-lens violation of exactly the kind the RLS move eliminated for
database reads: user B's client received events for user A's books,
including books in libraries hidden from B.

Fix is the RLS philosophy applied to the fan-out layer: enforcement at
one choke point. The untargeted Broadcast method and its channel/loop
machinery are deleted — BroadcastToUser is now the only door, and the
compiler forces every future emitter to name its audience instead of
relying on review. The three helpers take the acting user's ID; the
four sync call sites (progress.go x2, annotations.go x2) already had
the acting user in scope. Scan events were already per-triggering-
identity and collections already per-user — unchanged. Logging is
untouched: the only log line in the diff is the deleted method's own,
so docker logs remains the complete administrator audit trail.

TestBroadcastIsPerUser pins the invariant (alice's two devices receive
her three events; bob's client receives nothing — any message there is
a cross-user leak). Both websocket docs updated in the same commit:
the wire doc's fan-out section now states per-user delivery and the
chokepoint, and the gated endpoint doc's intro matches. Full make test
green.
This commit is contained in:
John O'Keefe committed 2026-11-02 18:06:45 -05:00
1 parent e957d385ec
commit a431dfa10b
6 files changed
+122 -62

No files matched your search

+23 -44
View File
@@ -51,49 +51,23 @@ type DeviceConnection struct {
}
// ConnectionManager manages WebSocket connections
//
// CHOKEPOINT (2026-11-02): there is deliberately NO untargeted broadcast
// method. Every send must name its audience — the user whose data the
// event carries. The pre-fix Broadcast fanned progress, annotation, and
// conflict events out to every connected client on the instance,
// crossing user boundaries (a family-lens violation: hidden-library
// book progress and annotation payloads reached unrelated clients).
// The compiler now enforces what review used to.
type ConnectionManager struct {
connections map[string]*DeviceConnection
broadcast chan BroadcastMessage
mu sync.RWMutex
}
// NewConnectionManager creates a new connection manager
func NewConnectionManager() *ConnectionManager {
cm := &ConnectionManager{
return &ConnectionManager{
connections: make(map[string]*DeviceConnection),
broadcast: make(chan BroadcastMessage, 100),
}
// Start broadcast goroutine
go cm.broadcastLoop()
return cm
}
// broadcastLoop handles broadcasting messages to all connected clients
func (m *ConnectionManager) broadcastLoop() {
for {
select {
case msg := <-m.broadcast:
m.mu.RLock()
for _, conn := range m.connections {
select {
case conn.Send <- msg:
default:
// Channel full, skip this connection
}
}
m.mu.RUnlock()
}
}
}
// Broadcast sends a message to all connected clients
func (m *ConnectionManager) Broadcast(msg BroadcastMessage) {
select {
case m.broadcast <- msg:
default:
log.Println("Broadcast channel full, message dropped")
}
}
@@ -114,8 +88,10 @@ func (m *ConnectionManager) BroadcastToUser(userID string, msg BroadcastMessage)
}
}
// BroadcastProgressUpdate broadcasts a progress update to all connected clients
func (m *ConnectionManager) BroadcastProgressUpdate(bookID uuid.UUID, percentage float64, source SourceDevice) {
// BroadcastProgressUpdate broadcasts a progress update to the acting
// user's connections. Progress is per-user data — nobody else's client
// receives it.
func (m *ConnectionManager) BroadcastProgressUpdate(userID string, bookID uuid.UUID, percentage float64, source SourceDevice) {
msg := BroadcastMessage{
Type: MessageTypeProgressUpdate,
Timestamp: time.Now().Format(time.RFC3339),
@@ -125,11 +101,13 @@ func (m *ConnectionManager) BroadcastProgressUpdate(bookID uuid.UUID, percentage
},
Source: &source,
}
m.Broadcast(msg)
m.BroadcastToUser(userID, msg)
}
// BroadcastAnnotationUpdate broadcasts an annotation update to all connected clients
func (m *ConnectionManager) BroadcastAnnotationUpdate(bookID uuid.UUID, annotationType string, data interface{}, source SourceDevice) {
// BroadcastAnnotationUpdate broadcasts an annotation update to the acting
// user's connections. Annotation payloads can carry note and highlight
// content — strictly per-user.
func (m *ConnectionManager) BroadcastAnnotationUpdate(userID string, bookID uuid.UUID, annotationType string, data interface{}, source SourceDevice) {
msg := BroadcastMessage{
Type: MessageTypeAnnotationUpdate,
Timestamp: time.Now().Format(time.RFC3339),
@@ -140,11 +118,12 @@ func (m *ConnectionManager) BroadcastAnnotationUpdate(bookID uuid.UUID, annotati
},
Source: &source,
}
m.Broadcast(msg)
m.BroadcastToUser(userID, msg)
}
// BroadcastConflictNotification broadcasts a conflict notification to all connected clients
func (m *ConnectionManager) BroadcastConflictNotification(bookID [16]byte, notificationType string, conflictID string) {
// BroadcastConflictNotification broadcasts a conflict notification to the
// acting user's connections.
func (m *ConnectionManager) BroadcastConflictNotification(userID string, bookID [16]byte, notificationType string, conflictID string) {
msg := BroadcastMessage{
Type: MessageTypeConflict,
Timestamp: time.Now().Format(time.RFC3339),
@@ -154,7 +133,7 @@ func (m *ConnectionManager) BroadcastConflictNotification(bookID [16]byte, notif
"conflict_id": conflictID,
},
}
m.Broadcast(msg)
m.BroadcastToUser(userID, msg)
}
// AddConnection adds a new WebSocket connection