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.
2.1 KiB
2.1 KiB
WebSocket Protocol
Real-time sync events, delivered only to the acting user's connected devices.
Connect to WebSocket
Endpoint: WS /ws/sync?token=<token>
Connection Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | JWT authentication token |
Example Connection
const ws = new WebSocket("wss://bookhoard.com/ws/sync?token=eyJhbG...");
Message Format
All messages are JSON objects with a type field.
Client → Server Messages
None are interpreted. The socket is a one-directional push feed — the server logs (but ignores) anything a client sends. All reads and writes go through the REST API.
Keepalive
The server sends a WebSocket ping frame every 30 seconds; a connection that misses pongs for 90 seconds is removed. Standard WebSocket clients (browsers included) answer pongs automatically — there is nothing to implement.
Server → Client Messages
Progress Update
{
"type": "progress_update",
"timestamp": "2026-01-31T10:00:00Z",
"data": {
"book_id": "uuid",
"progress": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3
},
"annotations": {}
},
"source_device": {
"id": "device-uuid",
"name": "My Kobo",
"type": "kobo"
}
}
Broadcast when any device updates reading progress.
Conflict Detected
{
"type": "conflict",
"timestamp": "2026-01-31T10:00:00Z",
"data": {
"book_id": "uuid",
"conflict_id": "uuid",
"conflict_type": "progress"
}
}
Broadcast when a sync conflict is detected.
Pong
{
"type": "pong"
}
Server response to client ping.
Connection Management
- Heartbeat: Send
pingevery 30 seconds - Reconnect: Use exponential backoff if connection drops
- Authentication: Token must be valid for connection
- Rate Limits: 60 messages/minute per connection
Error Handling
{
"type": "error",
"message": "Invalid token",
"code": "AUTH_FAILED"
}