@fieldnotes/sync-server 0.17.1 → 0.19.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
@@ -97,9 +97,11 @@ await server.close();
97
97
  ## Resource limits
98
98
 
99
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.
100
+ close with code `1009`; message-rate, byte-rate and pending-auth queue violations close with code
101
+ `4408`; a socket over the per-address or per-room connection cap closes with `4429`. Messages with
102
+ excessive JSON nesting, and presence payloads over `maxPresenceBytes`, are dropped. Presence sends
103
+ immediately, then coalesces rapid updates so the latest state is forwarded at most once per throttle
104
+ interval.
103
105
 
104
106
  ```ts
105
107
  createSyncServer({
@@ -110,15 +112,27 @@ createSyncServer({
110
112
  maxPendingAuthBytes: 2 * 1024 * 1024,
111
113
  messagesPerSecond: 120,
112
114
  messageBurst: 240,
115
+ bytesPerSecond: 4 * 1024 * 1024,
116
+ byteBurst: 8 * 1024 * 1024,
113
117
  presenceThrottleMs: 50,
118
+ maxPresenceBytes: 4 * 1024,
119
+ maxConnectionsPerIp: 64,
120
+ maxConnectionsPerRoom: 256,
121
+ clientAddress: (req) => req.socket.remoteAddress,
114
122
  });
115
123
  ```
116
124
 
117
125
  These values are the defaults. Tune them to the largest legitimate board operation and expected
118
126
  client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
119
127
  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.
128
+ asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use token
129
+ buckets: `messageBurst` / `byteBurst` are the short spike allowances and `messagesPerSecond` /
130
+ `bytesPerSecond` the refill rates, so relay amplification is bounded in bytes, not just frames.
131
+ Connection caps are taken at the upgrade, before `authenticate` runs, and count pending-auth
132
+ sockets; behind a trusted proxy supply `clientAddress` to read the forwarded address, and return
133
+ `undefined` to exempt a connection from the per-address cap. Rate and burst values must be positive
134
+ finite numbers; connection caps must be positive safe integers, or `Infinity` to disable a cap.
135
+ Invalid values throw during `createSyncServer` construction.
122
136
 
123
137
  ## Authentication
124
138
 
@@ -161,11 +175,42 @@ continues to use the authenticated `userId` and `role`, not this transport ident
161
175
 
162
176
  ### Passing a token
163
177
 
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.
178
+ `authenticate` receives `token`, resolved by the exported `readBearerToken(req)` in this order:
179
+
180
+ 1. A `Sec-WebSocket-Protocol` entry `fieldnotes-bearer.<token>` the browser-safe channel. A
181
+ browser `WebSocket` can't set request headers, but it can offer subprotocols; the relay reads
182
+ the token, selects the `fieldnotes-sync` subprotocol and never echoes the bearer entry. On the
183
+ client, `bearerSubprotocols(token)` from `@fieldnotes/sync` builds the offer:
184
+
185
+ ```ts
186
+ import {
187
+ WebSocketTransport,
188
+ bearerSubprotocols,
189
+ createManagedSyncConnection,
190
+ } from '@fieldnotes/sync';
191
+
192
+ new WebSocketTransport('wss://relay?room=R', { protocols: bearerSubprotocols(token) });
193
+ // or, managed:
194
+ createManagedSyncConnection({
195
+ store,
196
+ clientId,
197
+ resolveUrl: async () => ({
198
+ url: 'wss://relay?room=R',
199
+ protocols: bearerSubprotocols(await mint()),
200
+ }),
201
+ });
202
+ ```
203
+
204
+ The token must be a valid subprotocol token (JWT and base64url alphabets qualify; base64 `=`
205
+ padding does not).
206
+
207
+ 2. An `Authorization: Bearer <token>` header, for non-browser clients.
208
+ 3. The `token` URL query parameter (`ws://relay?room=R&token=…`). Still supported so existing
209
+ clients keep working, but URLs land in access/proxy logs: prefer the channels above, and use
210
+ **short-lived / single-use** tokens if you must stay on the URL.
211
+
212
+ `req` is still passed, so an `authenticate` hook that reads `req.url` or `req.headers` itself
213
+ keeps working unchanged.
169
214
 
170
215
  ## Authorization
171
216
 
@@ -184,13 +229,13 @@ authorize(ctx) => boolean | Promise<boolean>
184
229
  userId?: string; // the connection's authenticated user (from authenticate)
185
230
  role?: string; // the connection's role (from authenticate)
186
231
  room: string;
187
- op: SyncOp; // the incoming upsert / remove / clear
232
+ op: WireSyncOp; // the incoming upsert / remove / clear
188
233
  currentElement?: OwnedElement; // the STORED element, if this id already exists
189
234
  }
190
235
  ```
191
236
 
192
237
  `currentElement` is the element currently in room state for an `upsert`/`remove` of an
193
- **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
238
+ **existing** id (typed `OwnedElement = WireSyncElement`), and
194
239
  `undefined` for a new/absent id.
195
240
 
196
241
  **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
@@ -198,8 +243,17 @@ authenticated creator; on edit the stored owner is **preserved**; a client-suppl
198
243
  `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
199
244
  to enforce "own elements only".
200
245
 
246
+ **`ownerId` stays on the server.** It is stripped from every outbound frame (live ops,
247
+ snapshots, corrections, legacy translations) so a viewer's save file never records who
248
+ created what. Pass `canReadOwnerId({ userId, role, room }) => boolean` to reveal it to
249
+ privileged viewers, e.g. `canReadOwnerId: ({ role }) => role === 'dm'`.
250
+
201
251
  With **no hook**, rooms are OPEN (allow-all — every op is accepted).
202
252
 
253
+ When `canRead` filtering is in use, `op.element.audience` is what the sender asserted unless a
254
+ `resolveAudience` hook is configured (see [Read filtering](#read-filtering)); an `authorize` policy
255
+ without that hook must validate the audience itself.
256
+
203
257
  A copy-paste DM / player / display policy:
204
258
 
205
259
  ```ts
@@ -224,7 +278,8 @@ createSyncServer({
224
278
 
225
279
  - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
226
280
  no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
227
- user would lose access to their own elements after reconnecting.
281
+ user would lose access to their own elements after reconnecting. `createSyncServer`
282
+ therefore **throws** when `authorize` is configured without `authenticate`.
228
283
  - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
229
284
  low-write use.
230
285
  - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
@@ -276,9 +331,17 @@ gets a synthetic **remove**, one who gains it gets an **add**.
276
331
  instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
277
332
  local members — so all relay instances must inject the **same** `canRead`, alongside the existing
278
333
  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.
334
+ - **Labeling integrity.** `audience` arrives client-asserted. Without a `resolveAudience` hook a
335
+ player can tag an upsert `dm` to hide it from the table, or retag a hidden element `shared` to
336
+ reveal it, so **one of the two following hooks is required** for read secrecy:
337
+ - `resolveAudience({ userId, role, room, element, currentElement }) => string | undefined`
338
+ (recommended) makes the hub the authority: its return value replaces the client's tag
339
+ (`undefined` clears it) before `authorize`, storage and relay. A policy such as
340
+ `({ role, currentElement }) => currentElement?.audience ?? (role === 'dm' ? 'dm' : 'shared')`
341
+ keeps an element's audience stable and lets only a DM create hidden content.
342
+ - Otherwise `authorize` **must** enforce the audience contract itself: reject an `upsert` whose
343
+ `op.element.audience` the sender may not write to, and reject one whose audience differs from
344
+ `currentElement.audience` unless the sender may move it.
282
345
 
283
346
  A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
284
347