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.
90 lines
4.0 KiB
Markdown
90 lines
4.0 KiB
Markdown
<!-- Ground truth: internal/sync/websocket.go (ConnectionManager, message
|
|
constants, broadcast fan-out + drop-on-full backpressure),
|
|
internal/handlers/websocket.go (dual auth, initial_state on connect,
|
|
30s ping / 90s read deadline, handleMessage logs-only),
|
|
internal/router/sync.go (route group), internal/services/worker.go
|
|
(scan event emitters). Re-verify against these when the wire
|
|
changes. Rewritten 2026-11-02 from source; the Feb-era version
|
|
documented client->server messages that the server never acted on. -->
|
|
|
|
# WebSocket API (real-time events)
|
|
|
|
One channel, one direction: `/ws/sync` is a **server → client push feed**
|
|
for sync and scan events. It is not a command channel — clients do all
|
|
reading and writing through the REST API, and the server logs (but does
|
|
not act on) anything a client sends. The one client obligation is the
|
|
standard WebSocket pong, below.
|
|
|
|
## Connecting
|
|
|
|
`WS /ws/sync` — two accepted identities:
|
|
|
|
| Method | How |
|
|
| --- | --- |
|
|
| User (JWT) | `?token=<jwt>` query parameter |
|
|
| Device (KOReader, Kobo) | `Authorization: Bearer <device-token>` header |
|
|
|
|
No token → `401`. On connect the server immediately sends one
|
|
**`initial_state`** message: the connecting identity's media items for
|
|
sync, read under that identity's own data visibility.
|
|
|
|
## Message envelope
|
|
|
|
Every server message is a JSON object:
|
|
|
|
```json
|
|
{
|
|
"type": "progress_update",
|
|
"timestamp": "2026-11-02T12:00:00Z",
|
|
"data": { "...": "..." },
|
|
"source_device": { "id": "…", "name": "Living-room Kobo", "type": "kobo" }
|
|
}
|
|
```
|
|
|
|
`source_device` is present on events that originate from a device
|
|
(progress and annotation updates), so clients can ignore their own echo.
|
|
|
|
## Message catalog
|
|
|
|
All ten types defined by `internal/sync/websocket.go`:
|
|
|
|
| Type | Emitted | `data` carries |
|
|
| --- | --- | --- |
|
|
| `initial_state` | Once, on connect | The connecting user's media items for sync |
|
|
| `progress_update` | On any device's progress save | `book_id`, `percentage` |
|
|
| `annotation_update` | On annotation changes (highlights, notes, bookmarks) | `book_id`, `annotation_type`, payload |
|
|
| `conflict` | When a sync conflict is detected | `book_id`, `notification_type`, `conflict_id` |
|
|
| `scan_progress` | During scan jobs | Job progress for the admin scan panel |
|
|
| `scan_complete` | When a scan job finishes | Job summary |
|
|
| `scan_error` | When a scan job fails | Job error |
|
|
| `scan_cancelled` | When a scan job is cancelled | Job identity |
|
|
| `collection_updated` | When books are added to / removed from one of *your* collections | `collection_id`, `action` (`books_added` / `book_removed` / `books_removed`), affected book ids |
|
|
| `heartbeat` | *Defined, not currently emitted* | — |
|
|
| `sync_complete` | *Defined, not currently emitted* | — |
|
|
|
|
(`collection_updated` is currently the only message type defined as a raw
|
|
string in the collections handler rather than in the sync package's
|
|
constants block.)
|
|
|
|
## Keepalive
|
|
|
|
The server sends a WebSocket **ping frame every 30 seconds** and expects
|
|
the standard pong within a **90-second read deadline**. Browsers and
|
|
every mainstream WebSocket library answer pings automatically — there is
|
|
nothing to implement. A connection that stops answering is removed.
|
|
|
|
## Fan-out and backpressure — read this before building a client
|
|
|
|
- **Every event is delivered only to the acting user's connections.**
|
|
Progress, annotation, and conflict events go to the user whose sync
|
|
activity produced them; scan events go to the identity that triggered
|
|
the job; collection events to the collection's owner. The
|
|
ConnectionManager has no untargeted broadcast — the compiler enforces
|
|
an audience on every emitter (2026-11-02, after cross-user delivery
|
|
was found and fixed).
|
|
- Every connection has a bounded send queue. **If a client falls behind,
|
|
messages for that connection are skipped, not queued.** Clients must
|
|
therefore treat the socket as a *hint* — on reconnect (or any gap),
|
|
refetch real state over REST. The fresh `initial_state` on reconnect is
|
|
designed to be that gap-healer.
|