@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 +79 -16
- package/dist/index.cjs +353 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +126 -21
- package/dist/index.d.ts +126 -21
- package/dist/index.js +358 -65
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
`
|
|
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:
|
|
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 =
|
|
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`
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|