@fieldnotes/sync-server 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,283 +1,291 @@
1
- # @fieldnotes/sync-server
2
-
3
- Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
-
5
- The relay holds the canonical per-room canvas state and fans out element
6
- operations to every other connection in the room. Clients connect, request a
7
- snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
- applies and forwards.
9
-
10
- ## Pieces
11
-
12
- - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
- an async `HubBackend`; each room processes messages on its own serial queue so
14
- concurrent edits to the same room never race, while different rooms run
15
- independently.
16
- - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
- authenticated backends are planned. It reclaims a room's memory on `clear`, but
18
- **retains** state for uncleared / abandoned rooms — fine for dev / single-instance
19
- use. For a long-lived production process, use a Redis backend with a key TTL, or
20
- `clear` rooms you're done with.
21
- - **`createSyncServer`** — a runnable `ws` reference server. Connect with
22
- `?room=<id>` in the query string; a missing room closes the socket with WS code
23
- `4400`.
24
- - **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
25
- op to the fanout and forwards ops it receives from other instances to its local
26
- connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
27
- instance). For multiple relay instances to share live ops, pass a shared fanout
28
- via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
29
- [`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
30
- shared fanout alone leaves a new joiner's snapshot stale.
31
-
32
- `HubFanout.publish()` may return a promise. `SyncHub` awaits durable mutation publication before
33
- notifying other local connections, and `handleMessage()` rejects if publication fails after the
34
- backend commit. Custom server integrations must observe that rejection; `createSyncServer` reports
35
- it through its relay error path. The room queue recovers so later messages can continue. Presence
36
- publication remains best-effort because presence is ephemeral.
37
-
38
- ## Usage
39
-
40
- ```ts
41
- import { createSyncServer } from '@fieldnotes/sync-server';
42
-
43
- const { close } = createSyncServer({ port: 8080 });
44
- // ws://localhost:8080?room=my-room
45
- ```
46
-
47
- ## Server-originated presence
48
-
49
- Trusted server integrations can broadcast ephemeral data without accessing the hub's connection
50
- registries:
51
-
52
- ```ts
53
- const { hub } = createSyncServer({ port: 8080, fanout });
54
-
55
- const localDeliveries = hub.broadcastPresence('my-room', {
56
- kind: 'poke',
57
- feature: 'initiative',
58
- });
59
- ```
60
-
61
- Recipients receive the existing presence envelope with the server-owned identity
62
- `{ from: 'hub', op: { kind: 'presence', data } }`. The event is delivered to every local room
63
- member and published to the configured `HubFanout`, so other relay instances deliver it to their
64
- local members too. The return value counts successful sends on the originating hub only; it is not a
65
- cluster-wide acknowledgement.
66
-
67
- Server presence is best-effort, is not persisted, does not enter a room's durable operation queue,
68
- and is not filtered by `authorize` or `canRead`. Validate and authorize application requests before
69
- calling this trusted-host API. Data must be JSON-serializable; invalid data throws before delivery.
70
- The method constructs the presence operation itself and does not accept a raw sync operation or
71
- caller-controlled sender identity.
72
-
73
- ## Heartbeat
74
-
75
- The server pings every client on an interval and **terminates** any that miss a pong,
76
- so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
77
- leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
78
- disables):
79
-
80
- ```ts
81
- createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
82
- ```
83
-
84
- Browsers auto-pong at the protocol level, so **no client change** is needed.
85
-
86
- ## Graceful shutdown
87
-
88
- `close()` stops accepting connections, closes active clients with code `1001`, and waits up to
89
- `shutdownGraceMs` (default `5000`) before terminating clients that have not completed the close
90
- handshake. Repeated calls return the same promise, so shutdown hooks can safely converge on it.
91
-
92
- ```ts
93
- const server = createSyncServer({ port: 8080, shutdownGraceMs: 5000 });
94
- await server.close();
95
- ```
96
-
97
- ## Resource limits
98
-
99
- The reference server bounds work and memory per connection by default. Oversized WebSocket messages
100
- close with code `1009`; message-rate and pending-auth queue violations close with code `4408`.
101
- Messages with excessive JSON nesting are dropped. Presence sends immediately, then coalesces rapid
102
- updates so the latest state is forwarded at most once per throttle interval.
103
-
104
- ```ts
105
- createSyncServer({
106
- port: 8080,
107
- maxMessageBytes: 1024 * 1024,
108
- maxJsonDepth: 32,
109
- maxPendingAuthMessages: 100,
110
- maxPendingAuthBytes: 2 * 1024 * 1024,
111
- messagesPerSecond: 120,
112
- messageBurst: 240,
113
- presenceThrottleMs: 50,
114
- });
115
- ```
116
-
117
- These values are the defaults. Tune them to the largest legitimate board operation and expected
118
- client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
119
- is allocated, including fragmented messages. The pending-auth limits bound messages held while an
120
- asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use a token
121
- bucket: `messageBurst` is the short spike allowance and `messagesPerSecond` is the refill rate.
122
-
123
- ## Authentication
124
-
125
- Pass an `authenticate` hook to gate connections:
126
-
127
- ```ts
128
- import { createSyncServer } from '@fieldnotes/sync-server';
129
-
130
- createSyncServer({
131
- port: 8080,
132
- authenticate: async ({ req, room }) => {
133
- const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
134
- const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
135
- return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
136
- },
137
- });
138
- ```
139
-
140
- `authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
141
- may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
142
- socket is closed with WS code `4401` and the connection is never admitted to the room —
143
- no membership, no snapshot. A resolved result admits the connection carrying its
144
- `userId` and optional `role`.
145
-
146
- With **no hook**, rooms stay open: every connection is admitted anonymously with
147
- `userId = connId`. `role` is captured now and enforced in an upcoming release
148
- (role-based authorization and per-viewer visibility filtering).
149
-
150
- Messages that arrive during an async auth (notably the client's initial
151
- `request-snapshot`) are queued and replayed once the connection is admitted, so the
152
- first snapshot is never lost to the auth round-trip.
153
-
154
- ### Relay identity
155
-
156
- The relay assigns every admitted socket an opaque connection identity. That server-owned identity
157
- is used as `from` on all relayed mutations, presence updates, cross-instance fan-out, and disconnect
158
- leave events; the client-controlled envelope `from` cannot impersonate another connection. Presence
159
- identity is intentionally connection-scoped and changes after reconnecting. Application authorization
160
- continues to use the authenticated `userId` and `role`, not this transport identity.
161
-
162
- ### Passing a token
163
-
164
- A browser `WebSocket` can't set request headers, so pass the token as a URL query
165
- param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
166
- land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
167
- clients can instead put the token in `req.headers` (e.g. `Authorization`), which
168
- `authenticate` reads directly.
169
-
170
- ## Authorization
171
-
172
- Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
173
- op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
174
- to room state and not forwarded to any other connection:
175
-
176
- ```ts
177
- authorize(ctx) => boolean | Promise<boolean>
178
- ```
179
-
180
- `ctx` is an `AuthorizeContext`:
181
-
182
- ```ts
183
- {
184
- userId?: string; // the connection's authenticated user (from authenticate)
185
- role?: string; // the connection's role (from authenticate)
186
- room: string;
187
- op: SyncOp; // the incoming upsert / remove / clear
188
- currentElement?: OwnedElement; // the STORED element, if this id already exists
189
- }
190
- ```
191
-
192
- `currentElement` is the element currently in room state for an `upsert`/`remove` of an
193
- **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
194
- `undefined` for a new/absent id.
195
-
196
- **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
197
- authenticated creator; on edit the stored owner is **preserved**; a client-supplied
198
- `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
199
- to enforce "own elements only".
200
-
201
- With **no hook**, rooms are OPEN (allow-all — every op is accepted).
202
-
203
- A copy-paste DM / player / display policy:
204
-
205
- ```ts
206
- createSyncServer({
207
- port: 8080,
208
- authenticate: /* … supplies userId + role … */,
209
- authorize: ({ role, op, currentElement, userId }) => {
210
- if (role === 'dm') return true;
211
- if (role === 'display') return false; // read-only monitor
212
- if (role === 'player') { // own elements only, never destructive
213
- if (op.kind === 'clear') return false;
214
- if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
215
- if (op.kind === 'remove') return currentElement?.ownerId === userId;
216
- return false;
217
- }
218
- return false;
219
- },
220
- });
221
- ```
222
-
223
- **Important:**
224
-
225
- - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
226
- no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
227
- user would lose access to their own elements after reconnecting.
228
- - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
229
- low-write use.
230
- - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
231
- concern (D3). `authorize` gates writes only.
232
- - When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
233
- optimistic local edit self-corrects immediately — no client change, no waiting for reconnect:
234
- a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
235
- stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
236
- kinds and is sent only to the offending connection. When `canRead` is configured, corrections use
237
- the same viewer filter: unreadable canonical upserts become byte-free removes and corrective
238
- snapshots omit unreadable elements.
239
-
240
- ## Read filtering
241
-
242
- Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
243
- hiding, the hub filters ops before they leave the server, so hidden elements never reach an
244
- unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
245
-
246
- ```ts
247
- canRead({ userId, role, room, audience }) => boolean
248
- ```
249
-
250
- `audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
251
- `resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
252
- everyone sees everything.
253
-
254
- A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
255
-
256
- ```ts
257
- // relay
258
- createSyncServer({
259
- port: 8080,
260
- authenticate: /* … supplies userId + role … */,
261
- canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
262
- });
263
-
264
- // client (@fieldnotes/sync)
265
- new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
266
- ```
267
-
268
- Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
269
- gets a synthetic **remove**, one who gains it gets an **add**.
270
-
271
- **Preconditions:**
272
-
273
- - **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
274
- no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
275
- - **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
276
- instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
277
- local members — so all relay instances must inject the **same** `canRead`, alongside the existing
278
- shared-backend + shared-fanout requirement.
279
- - **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
280
- the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
281
- control.
282
-
283
- A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
1
+ # @fieldnotes/sync-server
2
+
3
+ Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
+
5
+ The relay holds the canonical per-room canvas state and fans out element
6
+ operations to every other connection in the room. Clients connect, request a
7
+ snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
+ applies and forwards.
9
+
10
+ ## Pieces
11
+
12
+ - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
+ an async `HubBackend`; each room processes messages on its own serial queue so
14
+ concurrent edits to the same room never race, while different rooms run
15
+ independently.
16
+ - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
+ authenticated backends are planned. It reclaims a room's memory on `clear`, but
18
+ **retains** state for uncleared / abandoned rooms — fine for dev / single-instance
19
+ use. For a long-lived production process, use a Redis backend with a key TTL, or
20
+ `clear` rooms you're done with.
21
+ - **`createSyncServer`** — a runnable `ws` reference server. Connect with
22
+ `?room=<id>` in the query string; a missing room closes the socket with WS code
23
+ `4400`.
24
+ - **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
25
+ op to the fanout and forwards ops it receives from other instances to its local
26
+ connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
27
+ instance). For multiple relay instances to share live ops, pass a shared fanout
28
+ via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
29
+ [`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
30
+ shared fanout alone leaves a new joiner's snapshot stale.
31
+
32
+ `HubFanout.publish()` may return a promise. `SyncHub` awaits durable mutation publication before
33
+ notifying other local connections, and `handleMessage()` rejects if publication fails after the
34
+ backend commit. Custom server integrations must observe that rejection; `createSyncServer` reports
35
+ it through its relay error path. The room queue recovers so later messages can continue. Presence
36
+ publication remains best-effort because presence is ephemeral.
37
+
38
+ ## Usage
39
+
40
+ ```ts
41
+ import { createSyncServer } from '@fieldnotes/sync-server';
42
+
43
+ const { close } = createSyncServer({ port: 8080 });
44
+ // ws://localhost:8080?room=my-room
45
+ ```
46
+
47
+ ## Server-originated presence
48
+
49
+ Trusted server integrations can broadcast ephemeral data without accessing the hub's connection
50
+ registries:
51
+
52
+ ```ts
53
+ const { hub } = createSyncServer({ port: 8080, fanout });
54
+
55
+ const localDeliveries = hub.broadcastPresence('my-room', {
56
+ kind: 'poke',
57
+ feature: 'initiative',
58
+ });
59
+ ```
60
+
61
+ Recipients receive the existing presence envelope with the server-owned identity
62
+ `{ from: 'hub', op: { kind: 'presence', data } }`. The event is delivered to every local room
63
+ member and published to the configured `HubFanout`, so other relay instances deliver it to their
64
+ local members too. The return value counts successful sends on the originating hub only; it is not a
65
+ cluster-wide acknowledgement.
66
+
67
+ Server presence is best-effort, is not persisted, does not enter a room's durable operation queue,
68
+ and is not filtered by `authorize` or `canRead`. Validate and authorize application requests before
69
+ calling this trusted-host API. Data must be JSON-serializable; invalid data throws before delivery.
70
+ The method constructs the presence operation itself and does not accept a raw sync operation or
71
+ caller-controlled sender identity.
72
+
73
+ ## Heartbeat
74
+
75
+ The server pings every client on an interval and **terminates** any that miss a pong,
76
+ so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
77
+ leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
78
+ disables):
79
+
80
+ ```ts
81
+ createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
82
+ ```
83
+
84
+ Browsers auto-pong at the protocol level, so **no client change** is needed.
85
+
86
+ ## Graceful shutdown
87
+
88
+ `close()` stops accepting connections, closes active clients with code `1001`, and waits up to
89
+ `shutdownGraceMs` (default `5000`) before terminating clients that have not completed the close
90
+ handshake. Repeated calls return the same promise, so shutdown hooks can safely converge on it.
91
+
92
+ ```ts
93
+ const server = createSyncServer({ port: 8080, shutdownGraceMs: 5000 });
94
+ await server.close();
95
+ ```
96
+
97
+ ## Resource limits
98
+
99
+ The reference server bounds work and memory per connection by default. Oversized WebSocket messages
100
+ close with code `1009`; message-rate and pending-auth queue violations close with code `4408`.
101
+ Messages with excessive JSON nesting are dropped. Presence sends immediately, then coalesces rapid
102
+ updates so the latest state is forwarded at most once per throttle interval.
103
+
104
+ ```ts
105
+ createSyncServer({
106
+ port: 8080,
107
+ maxMessageBytes: 1024 * 1024,
108
+ maxJsonDepth: 32,
109
+ maxPendingAuthMessages: 100,
110
+ maxPendingAuthBytes: 2 * 1024 * 1024,
111
+ messagesPerSecond: 120,
112
+ messageBurst: 240,
113
+ presenceThrottleMs: 50,
114
+ });
115
+ ```
116
+
117
+ These values are the defaults. Tune them to the largest legitimate board operation and expected
118
+ client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
119
+ is allocated, including fragmented messages. The pending-auth limits bound messages held while an
120
+ asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use a token
121
+ bucket: `messageBurst` is the short spike allowance and `messagesPerSecond` is the refill rate.
122
+
123
+ ## Authentication
124
+
125
+ Pass an `authenticate` hook to gate connections:
126
+
127
+ ```ts
128
+ import { createSyncServer } from '@fieldnotes/sync-server';
129
+
130
+ createSyncServer({
131
+ port: 8080,
132
+ authenticate: async ({ req, room }) => {
133
+ const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
134
+ const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
135
+ return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
136
+ },
137
+ });
138
+ ```
139
+
140
+ `authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
141
+ may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
142
+ socket is closed with WS code `4401` and the connection is never admitted to the room —
143
+ no membership, no snapshot. A resolved result admits the connection carrying its
144
+ `userId` and optional `role`.
145
+
146
+ With **no hook**, rooms stay open: every connection is admitted anonymously with
147
+ `userId = connId`. `role` is captured now and enforced in an upcoming release
148
+ (role-based authorization and per-viewer visibility filtering).
149
+
150
+ Messages that arrive during an async auth (notably the client's initial
151
+ `request-snapshot`) are queued and replayed once the connection is admitted, so the
152
+ first snapshot is never lost to the auth round-trip.
153
+
154
+ ### Relay identity
155
+
156
+ The relay assigns every admitted socket an opaque connection identity. That server-owned identity
157
+ is used as `from` on all relayed mutations, presence updates, cross-instance fan-out, and disconnect
158
+ leave events; the client-controlled envelope `from` cannot impersonate another connection. Presence
159
+ identity is intentionally connection-scoped and changes after reconnecting. Application authorization
160
+ continues to use the authenticated `userId` and `role`, not this transport identity.
161
+
162
+ ### Passing a token
163
+
164
+ A browser `WebSocket` can't set request headers, so pass the token as a URL query
165
+ param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
166
+ land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
167
+ clients can instead put the token in `req.headers` (e.g. `Authorization`), which
168
+ `authenticate` reads directly.
169
+
170
+ ## Authorization
171
+
172
+ Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
173
+ op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
174
+ to room state and not forwarded to any other connection:
175
+
176
+ ```ts
177
+ authorize(ctx) => boolean | Promise<boolean>
178
+ ```
179
+
180
+ `ctx` is an `AuthorizeContext`:
181
+
182
+ ```ts
183
+ {
184
+ userId?: string; // the connection's authenticated user (from authenticate)
185
+ role?: string; // the connection's role (from authenticate)
186
+ room: string;
187
+ op: SyncOp; // the incoming upsert / remove / clear
188
+ currentElement?: OwnedElement; // the STORED element, if this id already exists
189
+ }
190
+ ```
191
+
192
+ `currentElement` is the element currently in room state for an `upsert`/`remove` of an
193
+ **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
194
+ `undefined` for a new/absent id.
195
+
196
+ **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
197
+ authenticated creator; on edit the stored owner is **preserved**; a client-supplied
198
+ `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
199
+ to enforce "own elements only".
200
+
201
+ With **no hook**, rooms are OPEN (allow-all — every op is accepted).
202
+
203
+ A copy-paste DM / player / display policy:
204
+
205
+ ```ts
206
+ createSyncServer({
207
+ port: 8080,
208
+ authenticate: /* … supplies userId + role … */,
209
+ authorize: ({ role, op, currentElement, userId }) => {
210
+ if (role === 'dm') return true;
211
+ if (role === 'display') return false; // read-only monitor
212
+ if (role === 'player') { // own elements only, never destructive
213
+ if (op.kind === 'clear') return false;
214
+ if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
215
+ if (op.kind === 'remove') return currentElement?.ownerId === userId;
216
+ return false;
217
+ }
218
+ return false;
219
+ },
220
+ });
221
+ ```
222
+
223
+ **Important:**
224
+
225
+ - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
226
+ no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
227
+ user would lose access to their own elements after reconnecting.
228
+ - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
229
+ low-write use.
230
+ - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
231
+ concern (D3). `authorize` gates writes only.
232
+ - When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
233
+ optimistic local edit self-corrects immediately — no client change, no waiting for reconnect:
234
+ a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
235
+ stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
236
+ kinds and is sent only to the offending connection. When `canRead` is configured, corrections use
237
+ the same viewer filter: unreadable canonical upserts become byte-free removes and corrective
238
+ snapshots omit unreadable elements.
239
+
240
+ ## Read filtering
241
+
242
+ Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
243
+ hiding, the hub filters ops before they leave the server, so hidden elements never reach an
244
+ unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
245
+
246
+ ```ts
247
+ canRead({ userId, role, room, audience }) => boolean
248
+ ```
249
+
250
+ `audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
251
+ `resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
252
+ everyone sees everything.
253
+
254
+ A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
255
+
256
+ ```ts
257
+ // relay
258
+ createSyncServer({
259
+ port: 8080,
260
+ authenticate: /* … supplies userId + role … */,
261
+ canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
262
+ });
263
+
264
+ // client (@fieldnotes/sync)
265
+ new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
266
+ ```
267
+
268
+ Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
269
+ gets a synthetic **remove**, one who gains it gets an **add**.
270
+
271
+ **Preconditions:**
272
+
273
+ - **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
274
+ no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
275
+ - **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
276
+ instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
277
+ local members — so all relay instances must inject the **same** `canRead`, alongside the existing
278
+ shared-backend + shared-fanout requirement.
279
+ - **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
280
+ the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
281
+ control.
282
+
283
+ A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
284
+
285
+ ## Domain plugins
286
+
287
+ `SyncHubOptions.plugins` installs ordered `ServerSyncPlugin` middleware. Plugins can own legacy v3
288
+ kinds, register codec-validated extension kinds, provide versioned snapshots, return sender-only
289
+ corrections, and choose local or shared fanout per accepted operation. Fog authorization and state
290
+ application are supplied by `createFogServerPlugin()` from `@fieldnotes/vtt/server`; the generic
291
+ server has no runtime VTT dependency.