@fieldnotes/sync-server 0.18.0 → 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
 
@@ -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
 
package/dist/index.cjs CHANGED
@@ -22,8 +22,11 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  InMemoryHubFanout: () => InMemoryHubFanout,
24
24
  MemoryHubBackend: () => MemoryHubBackend,
25
+ ROOM_NAME_PATTERN: () => ROOM_NAME_PATTERN,
25
26
  SyncHub: () => SyncHub,
26
27
  createSyncServer: () => createSyncServer,
28
+ isValidRoomName: () => isValidRoomName,
29
+ readBearerToken: () => readBearerToken,
27
30
  startHeartbeat: () => startHeartbeat
28
31
  });
29
32
  module.exports = __toCommonJS(index_exports);
@@ -150,6 +153,11 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
150
153
  var DEFAULT_MESSAGES_PER_SECOND = 120;
151
154
  var DEFAULT_MESSAGE_BURST = 240;
152
155
  var DEFAULT_PRESENCE_THROTTLE_MS = 50;
156
+ var DEFAULT_MAX_PRESENCE_BYTES = 4 * 1024;
157
+ var DEFAULT_BYTES_PER_SECOND = 4 * 1024 * 1024;
158
+ var DEFAULT_BYTE_BURST = 8 * 1024 * 1024;
159
+ var DEFAULT_MAX_CONNECTIONS_PER_IP = 64;
160
+ var DEFAULT_MAX_CONNECTIONS_PER_ROOM = 256;
153
161
  var DEFAULT_MAX_PRESENCE_LANES = 16;
154
162
  function hasJsonDepthAtMost(message, maxDepth) {
155
163
  let depth = 0;
@@ -182,29 +190,30 @@ var MessageRateLimiter = class {
182
190
  }
183
191
  tokens;
184
192
  updatedAt;
185
- take(now = Date.now()) {
193
+ /** Charges `cost` tokens (frames or bytes); a cost above `burst` never fits. */
194
+ take(now = Date.now(), cost = 1) {
186
195
  const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
187
196
  this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
188
197
  this.updatedAt = now;
189
- if (this.tokens < 1) return false;
190
- this.tokens -= 1;
198
+ if (this.tokens < cost) return false;
199
+ this.tokens -= cost;
191
200
  return true;
192
201
  }
193
202
  };
194
203
 
195
204
  // src/sync-hub.ts
196
205
  var HUB_FROM = "hub";
206
+ var utf8 = new TextEncoder();
207
+ function utf8ByteLength(text) {
208
+ return utf8.encode(text).byteLength;
209
+ }
197
210
  function generateInstanceId() {
198
211
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
199
212
  return crypto.randomUUID();
200
213
  return `i-${Math.random().toString(36).slice(2)}`;
201
214
  }
202
215
  function isFanoutOp(op) {
203
- if (typeof op !== "object" || op === null) return false;
204
- const o = op;
205
- if (o.kind === "upsert") return (0, import_sync2.isValidWireElement)(o.element);
206
- if (o.kind === "remove") return typeof o.id === "string";
207
- return o.kind === "clear";
216
+ return op.kind === "upsert" || op.kind === "remove" || op.kind === "clear";
208
217
  }
209
218
  var FALLBACK_PRESENCE_LANE = "";
210
219
  var MAX_PRESENCE_LANE_LENGTH = 64;
@@ -217,9 +226,7 @@ function presenceLaneOf(data) {
217
226
  return kind;
218
227
  }
219
228
  function isLayerOp(op) {
220
- if (typeof op !== "object" || op === null) return false;
221
- const k = op.kind;
222
- return k === "layer-upsert" || k === "layer-remove";
229
+ return op.kind === "layer-upsert" || op.kind === "layer-remove";
223
230
  }
224
231
  function layerOpToRecord(op) {
225
232
  return op.kind === "layer-upsert" ? { id: op.layer.id, version: op.version, editor: op.editor, definition: op.layer } : { id: op.id, version: op.version, editor: op.editor };
@@ -232,13 +239,22 @@ function layerRecordToOp(record) {
232
239
  editor: record.editor
233
240
  } : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
234
241
  }
235
- function capabilityProfile(capabilities) {
236
- return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds]);
242
+ function encodingProfile(capabilities, revealOwner) {
243
+ return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds, revealOwner]);
244
+ }
245
+ function withoutOwnerId(element) {
246
+ if (element.ownerId === void 0) return element;
247
+ const rest = { ...element };
248
+ delete rest.ownerId;
249
+ return rest;
250
+ }
251
+ function stripOwnerId(op) {
252
+ if (op.kind === "upsert") return { ...op, element: withoutOwnerId(op.element) };
253
+ if (op.kind === "snapshot") return { ...op, elements: op.elements.map(withoutOwnerId) };
254
+ return op;
237
255
  }
238
256
  function isPresenceOp(op) {
239
- if (typeof op !== "object" || op === null) return false;
240
- const k = op.kind;
241
- return k === "presence" || k === "presence-leave";
257
+ return op.kind === "presence" || op.kind === "presence-leave";
242
258
  }
243
259
  var SyncHub = class {
244
260
  backend;
@@ -257,10 +273,13 @@ var SyncHub = class {
257
273
  elementRegistry;
258
274
  peerCapabilities = /* @__PURE__ */ new Map();
259
275
  canRead;
276
+ canReadOwnerId;
277
+ resolveAudience;
260
278
  memoryLayers = /* @__PURE__ */ new Map();
261
279
  maxJsonDepth;
262
280
  presenceThrottleMs;
263
281
  maxPresenceLanes;
282
+ maxPresenceBytes;
264
283
  /**
265
284
  * Presence throttle state keyed by connection, then by lane. A lane is the
266
285
  * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
@@ -280,6 +299,8 @@ var SyncHub = class {
280
299
  this.authorize = options.authorize;
281
300
  this.authorizeLayer = options.authorizeLayer;
282
301
  this.canRead = options.canRead;
302
+ this.canReadOwnerId = options.canReadOwnerId;
303
+ this.resolveAudience = options.resolveAudience;
283
304
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
284
305
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
285
306
  const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
@@ -287,6 +308,11 @@ var SyncHub = class {
287
308
  throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
288
309
  }
289
310
  this.maxPresenceLanes = Math.floor(maxPresenceLanes);
311
+ const maxPresenceBytes = options.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
312
+ if (!Number.isFinite(maxPresenceBytes) || maxPresenceBytes < 0) {
313
+ throw new RangeError("maxPresenceBytes must be a non-negative finite number");
314
+ }
315
+ this.maxPresenceBytes = maxPresenceBytes;
290
316
  this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
291
317
  }
292
318
  addConnection(conn) {
@@ -325,6 +351,7 @@ var SyncHub = class {
325
351
  * forwards the same event to other instances on a best-effort basis.
326
352
  */
327
353
  broadcastPresence(room, data) {
354
+ if (!this.isPresenceWithinLimit(data)) return 0;
328
355
  const op = { kind: "presence", data };
329
356
  const sent = this.relayToRoom(room, void 0, JSON.stringify({ from: HUB_FROM, op }));
330
357
  this.safePublish(JSON.stringify({ o: this.instanceId, room, from: HUB_FROM, op }));
@@ -345,6 +372,7 @@ var SyncHub = class {
345
372
  return Promise.resolve();
346
373
  }
347
374
  if (env.op.kind === "presence") {
375
+ if (!this.isPresenceWithinLimit(env.op.data)) return Promise.resolve();
348
376
  this.schedulePresence(conn, env.op.data);
349
377
  return Promise.resolve();
350
378
  }
@@ -404,9 +432,22 @@ var SyncHub = class {
404
432
  await this.deliverPluginResult(conn, result);
405
433
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
406
434
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
407
- const needCurrent = (this.authorize || this.canRead) && id !== void 0;
435
+ const needCurrent = (this.authorize || this.canRead || this.resolveAudience) && id !== void 0;
408
436
  const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
409
437
  const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
438
+ if (op.kind === "upsert" && this.resolveAudience) {
439
+ const audience = this.resolveAudience({
440
+ userId: conn.userId,
441
+ role: conn.role,
442
+ room: conn.room,
443
+ element: op.element,
444
+ currentElement: current
445
+ });
446
+ const element = { ...op.element };
447
+ if (audience === void 0) delete element.audience;
448
+ else element.audience = audience;
449
+ op = { kind: "upsert", element };
450
+ }
410
451
  let outboundOp = op;
411
452
  if (this.authorize) {
412
453
  const allowed = await this.authorize({
@@ -567,6 +608,10 @@ var SyncHub = class {
567
608
  }
568
609
  map.set(record.id, record);
569
610
  }
611
+ mayReadOwnerId(conn) {
612
+ if (!this.canReadOwnerId) return false;
613
+ return this.canReadOwnerId({ userId: conn.userId, role: conn.role, room: conn.room });
614
+ }
570
615
  mayRead(conn, audience) {
571
616
  if (!this.canRead) return true;
572
617
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -601,10 +646,11 @@ var SyncHub = class {
601
646
  */
602
647
  sendToConnection(conn, from, op, encoded) {
603
648
  const capabilities = this.peerCapabilities.get(conn.id) ?? (0, import_sync2.createLegacyCapabilities)();
604
- const profile = encoded ? capabilityProfile(capabilities) : void 0;
649
+ const revealOwner = this.mayReadOwnerId(conn);
650
+ const profile = encoded ? encodingProfile(capabilities, revealOwner) : void 0;
605
651
  let message = profile === void 0 ? void 0 : encoded?.get(profile);
606
652
  if (message === void 0) {
607
- message = this.encodeForPeer(from, op, capabilities);
653
+ message = this.encodeForPeer(from, op, capabilities, revealOwner);
608
654
  if (profile !== void 0) encoded?.set(profile, message);
609
655
  }
610
656
  if (message === null) return false;
@@ -615,11 +661,15 @@ var SyncHub = class {
615
661
  return false;
616
662
  }
617
663
  }
618
- /** Returns the wire frame for `op` translated for `capabilities`, or null when lossy. */
619
- encodeForPeer(from, op, capabilities) {
664
+ /**
665
+ * Returns the wire frame for `op` translated for `capabilities`, or null
666
+ * when lossy. The server-stamped `ownerId` is a server-side authorization
667
+ * fact: it leaves the hub only for peers `canReadOwnerId` admits.
668
+ */
669
+ encodeForPeer(from, op, capabilities, revealOwner) {
620
670
  try {
621
671
  const translated = (0, import_sync2.translateOpForPeer)(
622
- op,
672
+ revealOwner ? op : stripOwnerId(op),
623
673
  capabilities,
624
674
  this.elementRegistry,
625
675
  this.pluginRegistry.extensionDefinitions
@@ -799,6 +849,7 @@ var SyncHub = class {
799
849
  if (correction) this.sendToConnection(conn, HUB_FROM, correction);
800
850
  }
801
851
  onFanout(payload) {
852
+ if (!hasJsonDepthAtMost(payload, this.maxJsonDepth)) return;
802
853
  let env;
803
854
  try {
804
855
  env = JSON.parse(payload);
@@ -808,8 +859,11 @@ var SyncHub = class {
808
859
  if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
809
860
  return;
810
861
  if (env.o === this.instanceId) return;
811
- const op = env.op;
862
+ const envelope = { from: env.from, op: env.op };
863
+ if (!(0, import_sync2.isValidEnvelope)(envelope)) return;
864
+ const op = envelope.op;
812
865
  if (isPresenceOp(op)) {
866
+ if (op.kind === "presence" && !this.isPresenceWithinLimit(op.data)) return;
813
867
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
814
868
  return;
815
869
  }
@@ -819,8 +873,16 @@ var SyncHub = class {
819
873
  this.relayOpToRoom(env.room, void 0, env.from, op);
820
874
  return;
821
875
  }
822
- const plugin = typeof op === "object" && op !== null && op.kind === "extension" ? this.pluginRegistry.extension(op.extensionKind ?? "")?.plugin : typeof op === "object" && op !== null && typeof op.kind === "string" ? this.pluginRegistry.ownerOf(op.kind) : void 0;
823
- if (plugin && typeof op === "object" && op !== null) {
876
+ let plugin;
877
+ if (op.kind === "extension") {
878
+ const entry = this.pluginRegistry.extension(op.extensionKind);
879
+ if (!entry || !entry.kind.codec.validate(op.payload)) return;
880
+ plugin = entry.plugin;
881
+ } else {
882
+ plugin = this.pluginRegistry.ownerOf(op.kind);
883
+ }
884
+ if (plugin) {
885
+ const owner = plugin;
824
886
  const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
825
887
  const operation = previous.then(async () => {
826
888
  const context = {
@@ -829,7 +891,7 @@ var SyncHub = class {
829
891
  backend: this.backend,
830
892
  backendPlugin: (key) => this.backend.getService?.(key)
831
893
  };
832
- const accepted = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
894
+ const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
833
895
  if (accepted) {
834
896
  this.relayOpToRoom(env.room, void 0, env.from, accepted);
835
897
  }
@@ -851,6 +913,9 @@ var SyncHub = class {
851
913
  const prevExisted = env.existed === true;
852
914
  this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
853
915
  }
916
+ isPresenceWithinLimit(data) {
917
+ return utf8ByteLength(JSON.stringify(data) ?? "") <= this.maxPresenceBytes;
918
+ }
854
919
  async applyFanoutLayerOp(room, op) {
855
920
  const record = layerOpToRecord(op);
856
921
  const current = await this.getLayerRecord(room, record.id);
@@ -866,6 +931,23 @@ var SyncHub = class {
866
931
  // src/create-sync-server.ts
867
932
  var import_ws = require("ws");
868
933
 
934
+ // src/authenticate.ts
935
+ var import_sync3 = require("@fieldnotes/sync");
936
+ function readBearerToken(req) {
937
+ const fromProtocol = (0, import_sync3.readBearerSubprotocol)(headerValue(req.headers["sec-websocket-protocol"]));
938
+ if (fromProtocol) return fromProtocol;
939
+ const authorization = headerValue(req.headers["authorization"]);
940
+ if (authorization) {
941
+ const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
942
+ if (match?.[1]) return match[1];
943
+ }
944
+ const query = new URL(req.url ?? "", "http://localhost").searchParams.get("token");
945
+ return query || void 0;
946
+ }
947
+ function headerValue(value) {
948
+ return Array.isArray(value) ? value.join(",") : value;
949
+ }
950
+
869
951
  // src/heartbeat.ts
870
952
  function startHeartbeat(wss, intervalMs) {
871
953
  if (intervalMs <= 0) {
@@ -897,6 +979,9 @@ function startHeartbeat(wss, intervalMs) {
897
979
  return { track, stop: () => clearInterval(interval) };
898
980
  }
899
981
 
982
+ // src/create-sync-server.ts
983
+ var import_sync4 = require("@fieldnotes/sync");
984
+
900
985
  // src/shutdown.ts
901
986
  var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
902
987
  function drainWebSocketServer(wss, graceMs) {
@@ -931,16 +1016,78 @@ function drainWebSocketServer(wss, graceMs) {
931
1016
  });
932
1017
  }
933
1018
 
1019
+ // src/room-name.ts
1020
+ var ROOM_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
1021
+ function isValidRoomName(room) {
1022
+ return typeof room === "string" && ROOM_NAME_PATTERN.test(room);
1023
+ }
1024
+
934
1025
  // src/create-sync-server.ts
1026
+ var ConcurrencyCounter = class {
1027
+ constructor(limit) {
1028
+ this.limit = limit;
1029
+ }
1030
+ counts = /* @__PURE__ */ new Map();
1031
+ /** Reserves a slot for `key`; returns a release function, or null when the cap is reached. */
1032
+ acquire(key) {
1033
+ const current = this.counts.get(key) ?? 0;
1034
+ if (current >= this.limit) return null;
1035
+ this.counts.set(key, current + 1);
1036
+ let released = false;
1037
+ return () => {
1038
+ if (released) return;
1039
+ released = true;
1040
+ const remaining = (this.counts.get(key) ?? 1) - 1;
1041
+ if (remaining <= 0) this.counts.delete(key);
1042
+ else this.counts.set(key, remaining);
1043
+ };
1044
+ }
1045
+ };
935
1046
  function rawDataByteLength(data) {
936
1047
  if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
937
1048
  return data.byteLength;
938
1049
  }
1050
+ function requirePositiveFinite(name, value) {
1051
+ if (!Number.isFinite(value) || value <= 0) {
1052
+ throw new RangeError(`${name} must be a positive finite number`);
1053
+ }
1054
+ return value;
1055
+ }
1056
+ function requirePositiveIntegerOrInfinity(name, value) {
1057
+ if (value !== Infinity && (!Number.isSafeInteger(value) || value <= 0)) {
1058
+ throw new RangeError(`${name} must be a positive safe integer or Infinity`);
1059
+ }
1060
+ return value;
1061
+ }
939
1062
  function createSyncServer(options = {}) {
940
1063
  const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
941
1064
  if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
942
1065
  throw new RangeError("shutdownGraceMs must be a non-negative finite number");
943
1066
  }
1067
+ const messagesPerSecond = requirePositiveFinite(
1068
+ "messagesPerSecond",
1069
+ options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND
1070
+ );
1071
+ const messageBurst = requirePositiveFinite(
1072
+ "messageBurst",
1073
+ options.messageBurst ?? DEFAULT_MESSAGE_BURST
1074
+ );
1075
+ const bytesPerSecond = requirePositiveFinite(
1076
+ "bytesPerSecond",
1077
+ options.bytesPerSecond ?? DEFAULT_BYTES_PER_SECOND
1078
+ );
1079
+ const byteBurst = requirePositiveFinite("byteBurst", options.byteBurst ?? DEFAULT_BYTE_BURST);
1080
+ const maxConnectionsPerIp = requirePositiveIntegerOrInfinity(
1081
+ "maxConnectionsPerIp",
1082
+ options.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP
1083
+ );
1084
+ const maxConnectionsPerRoom = requirePositiveIntegerOrInfinity(
1085
+ "maxConnectionsPerRoom",
1086
+ options.maxConnectionsPerRoom ?? DEFAULT_MAX_CONNECTIONS_PER_ROOM
1087
+ );
1088
+ if (options.authorize && !options.authenticate) {
1089
+ throw new Error("createSyncServer: `authorize` requires an `authenticate` hook");
1090
+ }
944
1091
  const hub = new SyncHub({
945
1092
  backend: options.backend,
946
1093
  fanout: options.fanout,
@@ -949,17 +1096,34 @@ function createSyncServer(options = {}) {
949
1096
  authorizeLayer: options.authorizeLayer,
950
1097
  plugins: options.plugins,
951
1098
  canRead: options.canRead,
1099
+ canReadOwnerId: options.canReadOwnerId,
1100
+ resolveAudience: options.resolveAudience,
952
1101
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
953
1102
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
954
1103
  maxPresenceLanes: options.maxPresenceLanes,
1104
+ maxPresenceBytes: options.maxPresenceBytes,
955
1105
  elementRegistry: options.elementRegistry
956
1106
  });
957
1107
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
958
- const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new import_ws.WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
1108
+ const handleProtocols = (protocols) => {
1109
+ if (protocols.has(import_sync4.SYNC_WS_SUBPROTOCOL)) return import_sync4.SYNC_WS_SUBPROTOCOL;
1110
+ for (const protocol of protocols) {
1111
+ if (!protocol.startsWith(import_sync4.BEARER_SUBPROTOCOL_PREFIX)) return protocol;
1112
+ }
1113
+ return false;
1114
+ };
1115
+ const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes, handleProtocols }) : new import_ws.WebSocketServer({
1116
+ port: options.port ?? 0,
1117
+ maxPayload: maxMessageBytes,
1118
+ handleProtocols
1119
+ });
959
1120
  const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
960
1121
  let shuttingDown = false;
961
1122
  let closePromise;
962
1123
  let counter = 0;
1124
+ const perIp = new ConcurrencyCounter(maxConnectionsPerIp);
1125
+ const perRoom = new ConcurrencyCounter(maxConnectionsPerRoom);
1126
+ const clientAddress = options.clientAddress ?? ((req) => req.socket.remoteAddress);
963
1127
  wss.on("connection", (ws, req) => {
964
1128
  if (shuttingDown) {
965
1129
  ws.close(1001, "server shutting down");
@@ -973,6 +1137,22 @@ function createSyncServer(options = {}) {
973
1137
  ws.close(4400, "room required");
974
1138
  return;
975
1139
  }
1140
+ if (!isValidRoomName(room)) {
1141
+ ws.close(4400, "invalid room");
1142
+ return;
1143
+ }
1144
+ const address = clientAddress(req);
1145
+ const releaseIp = address ? perIp.acquire(address) : () => void 0;
1146
+ if (!releaseIp) {
1147
+ ws.close(4429, "too many connections");
1148
+ return;
1149
+ }
1150
+ const releaseRoom = perRoom.acquire(room);
1151
+ if (!releaseRoom) {
1152
+ releaseIp();
1153
+ ws.close(4429, "too many connections");
1154
+ return;
1155
+ }
976
1156
  const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
977
1157
  let state = "pending";
978
1158
  let closed = false;
@@ -981,10 +1161,8 @@ function createSyncServer(options = {}) {
981
1161
  let queuedBytes = 0;
982
1162
  const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
983
1163
  const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
984
- const limiter = new MessageRateLimiter(
985
- options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,
986
- options.messageBurst ?? DEFAULT_MESSAGE_BURST
987
- );
1164
+ const limiter = new MessageRateLimiter(messagesPerSecond, messageBurst);
1165
+ const byteLimiter = new MessageRateLimiter(bytesPerSecond, byteBurst);
988
1166
  const send = (m) => {
989
1167
  try {
990
1168
  ws.send(m);
@@ -999,7 +1177,8 @@ function createSyncServer(options = {}) {
999
1177
  ws.close(1009, "message too large");
1000
1178
  return;
1001
1179
  }
1002
- if (!limiter.take()) {
1180
+ const now = Date.now();
1181
+ if (!limiter.take(now) || !byteLimiter.take(now, messageBytes)) {
1003
1182
  state = "rejected";
1004
1183
  ws.close(4408, "rate limit exceeded");
1005
1184
  return;
@@ -1019,9 +1198,13 @@ function createSyncServer(options = {}) {
1019
1198
  });
1020
1199
  ws.on("close", () => {
1021
1200
  closed = true;
1201
+ releaseIp();
1202
+ releaseRoom();
1022
1203
  if (admitted) hub.removeConnection(connId);
1023
1204
  });
1024
- Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
1205
+ Promise.resolve(
1206
+ options.authenticate ? options.authenticate({ req, room, token: readBearerToken(req) }) : { userId: connId }
1207
+ ).then((result) => {
1025
1208
  if (closed || state === "rejected" || shuttingDown) return;
1026
1209
  if (!result) {
1027
1210
  state = "rejected";
@@ -1062,8 +1245,11 @@ function createSyncServer(options = {}) {
1062
1245
  0 && (module.exports = {
1063
1246
  InMemoryHubFanout,
1064
1247
  MemoryHubBackend,
1248
+ ROOM_NAME_PATTERN,
1065
1249
  SyncHub,
1066
1250
  createSyncServer,
1251
+ isValidRoomName,
1252
+ readBearerToken,
1067
1253
  startHeartbeat
1068
1254
  });
1069
1255
  //# sourceMappingURL=index.cjs.map