@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/dist/index.d.ts CHANGED
@@ -60,6 +60,34 @@ interface ReadContext {
60
60
  audience: string | undefined;
61
61
  }
62
62
  type CanRead = (ctx: ReadContext) => boolean;
63
+ interface OwnerReadContext {
64
+ userId?: string;
65
+ role?: string;
66
+ room: string;
67
+ }
68
+ /**
69
+ * Decides whether a viewer may see the server-stamped `ownerId` on elements it
70
+ * receives (live ops, snapshots and corrections). Without a hook `ownerId` is
71
+ * stripped from every outbound frame; it stays in the backend for `authorize`.
72
+ */
73
+ type CanReadOwnerId = (ctx: OwnerReadContext) => boolean;
74
+ interface ResolveAudienceContext {
75
+ userId?: string;
76
+ role?: string;
77
+ room: string;
78
+ /** The incoming element as the client sent it, client-asserted `audience` included. */
79
+ element: WireSyncElement;
80
+ /** The hub's stored element for the same id, if any. */
81
+ currentElement?: OwnedElement;
82
+ }
83
+ /**
84
+ * Decides the authoritative `audience` of an upserted element. The returned
85
+ * value replaces whatever the client asserted (`undefined` clears the field)
86
+ * before `authorize`, storage and relay, so a client can neither hide content
87
+ * by tagging it nor reveal content by retagging it. Without a hook the
88
+ * client's tag passes through and `authorize` must check it.
89
+ */
90
+ type ResolveAudience = (ctx: ResolveAudienceContext) => string | undefined;
63
91
 
64
92
  interface ApplyResult {
65
93
  readonly accepted: WireSyncOp | null;
@@ -108,9 +136,13 @@ interface SyncHubOptions {
108
136
  authorizeLayer?: AuthorizeLayer;
109
137
  plugins?: readonly ServerSyncPlugin[];
110
138
  canRead?: CanRead;
139
+ canReadOwnerId?: CanReadOwnerId;
140
+ resolveAudience?: ResolveAudience;
111
141
  maxJsonDepth?: number;
112
142
  presenceThrottleMs?: number;
113
143
  maxPresenceLanes?: number;
144
+ /** Largest `presence.data` payload relayed, in UTF-8 bytes of its JSON encoding. */
145
+ maxPresenceBytes?: number;
114
146
  /** Registry used to translate extension elements for legacy peers. */
115
147
  elementRegistry?: ElementRegistry;
116
148
  }
@@ -129,10 +161,13 @@ declare class SyncHub {
129
161
  private readonly elementRegistry;
130
162
  private readonly peerCapabilities;
131
163
  private readonly canRead?;
164
+ private readonly canReadOwnerId?;
165
+ private readonly resolveAudience?;
132
166
  private readonly memoryLayers;
133
167
  private readonly maxJsonDepth;
134
168
  private readonly presenceThrottleMs;
135
169
  private readonly maxPresenceLanes;
170
+ private readonly maxPresenceBytes;
136
171
  /**
137
172
  * Presence throttle state keyed by connection, then by lane. A lane is the
138
173
  * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
@@ -171,6 +206,7 @@ declare class SyncHub {
171
206
  private getLayerRecords;
172
207
  private getLayerRecord;
173
208
  private applyLayerRecord;
209
+ private mayReadOwnerId;
174
210
  private mayRead;
175
211
  private safePublish;
176
212
  private relayToRoom;
@@ -180,7 +216,11 @@ declare class SyncHub {
180
216
  * may reject the room operation that produced it.
181
217
  */
182
218
  private sendToConnection;
183
- /** Returns the wire frame for `op` translated for `capabilities`, or null when lossy. */
219
+ /**
220
+ * Returns the wire frame for `op` translated for `capabilities`, or null
221
+ * when lossy. The server-stamped `ownerId` is a server-side authorization
222
+ * fact: it leaves the hub only for peers `canReadOwnerId` admits.
223
+ */
184
224
  private encodeForPeer;
185
225
  /**
186
226
  * Normalize registered legacy wire elements before authorization, storage,
@@ -200,6 +240,7 @@ declare class SyncHub {
200
240
  private deliverToRoom;
201
241
  private sendCorrection;
202
242
  private onFanout;
243
+ private isPresenceWithinLimit;
203
244
  private applyFanoutLayerOp;
204
245
  close(): void;
205
246
  }
@@ -220,12 +261,25 @@ declare class MemoryHubBackend implements HubBackend {
220
261
  interface AuthInfo {
221
262
  req: IncomingMessage;
222
263
  room: string;
264
+ /**
265
+ * Bearer token resolved by `readBearerToken(req)`: a `fieldnotes-bearer.<token>`
266
+ * `Sec-WebSocket-Protocol` entry, else an `Authorization: Bearer` header, else the
267
+ * `token` URL query parameter (legacy; URLs land in proxy logs). `undefined` when
268
+ * none is present.
269
+ */
270
+ token?: string;
223
271
  }
224
272
  interface AuthResult {
225
273
  userId: string;
226
274
  role?: string;
227
275
  }
228
276
  type Authenticate = (info: AuthInfo) => AuthResult | null | Promise<AuthResult | null>;
277
+ /**
278
+ * Resolves the bearer token of an upgrade request, preferring channels that stay
279
+ * out of access logs: `Sec-WebSocket-Protocol` bearer entry, then
280
+ * `Authorization: Bearer`, then the `token` query parameter.
281
+ */
282
+ declare function readBearerToken(req: IncomingMessage): string | undefined;
229
283
 
230
284
  interface CreateSyncServerOptions {
231
285
  port?: number;
@@ -238,6 +292,8 @@ interface CreateSyncServerOptions {
238
292
  authorizeLayer?: AuthorizeLayer;
239
293
  plugins?: readonly ServerSyncPlugin[];
240
294
  canRead?: CanRead;
295
+ canReadOwnerId?: CanReadOwnerId;
296
+ resolveAudience?: ResolveAudience;
241
297
  heartbeatIntervalMs?: number;
242
298
  maxMessageBytes?: number;
243
299
  maxJsonDepth?: number;
@@ -245,8 +301,23 @@ interface CreateSyncServerOptions {
245
301
  maxPendingAuthBytes?: number;
246
302
  messagesPerSecond?: number;
247
303
  messageBurst?: number;
304
+ /** Sustained inbound bytes per second per connection (token bucket). */
305
+ bytesPerSecond?: number;
306
+ /** Inbound byte spike allowance per connection; a frame larger than this is never admitted. */
307
+ byteBurst?: number;
248
308
  presenceThrottleMs?: number;
249
309
  maxPresenceLanes?: number;
310
+ maxPresenceBytes?: number;
311
+ /** Concurrent sockets per client address; `Infinity` disables the cap. */
312
+ maxConnectionsPerIp?: number;
313
+ /** Concurrent sockets per room, pending-auth sockets included; `Infinity` disables the cap. */
314
+ maxConnectionsPerRoom?: number;
315
+ /**
316
+ * Resolves the client address the per-IP cap keys on. Defaults to the socket's
317
+ * remote address; behind a trusted proxy read the forwarded header here.
318
+ * Returning `undefined` or `''` exempts the connection from the per-IP cap.
319
+ */
320
+ clientAddress?: (req: IncomingMessage) => string | undefined;
250
321
  shutdownGraceMs?: number;
251
322
  /**
252
323
  * Registry used to translate extension envelopes for legacy peers. Without
@@ -261,6 +332,15 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
261
332
  close: () => Promise<void>;
262
333
  };
263
334
 
335
+ /**
336
+ * Room names the relay admits. Backends derive storage keys from the room
337
+ * name (`<prefix><room>:layers`, `<prefix><room>:fog:meta`, ...), so the
338
+ * alphabet excludes every separator a key builder uses and bounds the length
339
+ * before `authenticate` runs.
340
+ */
341
+ declare const ROOM_NAME_PATTERN: RegExp;
342
+ declare function isValidRoomName(room: unknown): room is string;
343
+
264
344
  interface HeartbeatSocket {
265
345
  ping(): void;
266
346
  terminate(): void;
@@ -275,4 +355,4 @@ interface Heartbeat {
275
355
  }
276
356
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
277
357
 
278
- export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, type ServerExtensionRegistry, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
358
+ export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type CanReadOwnerId, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type OwnerReadContext, ROOM_NAME_PATTERN, type ReadContext, type ResolveAudience, type ResolveAudienceContext, type ServerExtensionRegistry, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, isValidRoomName, readBearerToken, startHeartbeat };
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  createCurrentCapabilities,
4
4
  createLegacyCapabilities,
5
5
  parseEnvelope,
6
+ isValidEnvelope,
6
7
  isValidElement,
7
- isValidWireElement,
8
8
  isNewerLayerRecord,
9
9
  translateOpForPeer
10
10
  } from "@fieldnotes/sync";
@@ -130,6 +130,11 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
130
130
  var DEFAULT_MESSAGES_PER_SECOND = 120;
131
131
  var DEFAULT_MESSAGE_BURST = 240;
132
132
  var DEFAULT_PRESENCE_THROTTLE_MS = 50;
133
+ var DEFAULT_MAX_PRESENCE_BYTES = 4 * 1024;
134
+ var DEFAULT_BYTES_PER_SECOND = 4 * 1024 * 1024;
135
+ var DEFAULT_BYTE_BURST = 8 * 1024 * 1024;
136
+ var DEFAULT_MAX_CONNECTIONS_PER_IP = 64;
137
+ var DEFAULT_MAX_CONNECTIONS_PER_ROOM = 256;
133
138
  var DEFAULT_MAX_PRESENCE_LANES = 16;
134
139
  function hasJsonDepthAtMost(message, maxDepth) {
135
140
  let depth = 0;
@@ -162,29 +167,30 @@ var MessageRateLimiter = class {
162
167
  }
163
168
  tokens;
164
169
  updatedAt;
165
- take(now = Date.now()) {
170
+ /** Charges `cost` tokens (frames or bytes); a cost above `burst` never fits. */
171
+ take(now = Date.now(), cost = 1) {
166
172
  const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
167
173
  this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
168
174
  this.updatedAt = now;
169
- if (this.tokens < 1) return false;
170
- this.tokens -= 1;
175
+ if (this.tokens < cost) return false;
176
+ this.tokens -= cost;
171
177
  return true;
172
178
  }
173
179
  };
174
180
 
175
181
  // src/sync-hub.ts
176
182
  var HUB_FROM = "hub";
183
+ var utf8 = new TextEncoder();
184
+ function utf8ByteLength(text) {
185
+ return utf8.encode(text).byteLength;
186
+ }
177
187
  function generateInstanceId() {
178
188
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
179
189
  return crypto.randomUUID();
180
190
  return `i-${Math.random().toString(36).slice(2)}`;
181
191
  }
182
192
  function isFanoutOp(op) {
183
- if (typeof op !== "object" || op === null) return false;
184
- const o = op;
185
- if (o.kind === "upsert") return isValidWireElement(o.element);
186
- if (o.kind === "remove") return typeof o.id === "string";
187
- return o.kind === "clear";
193
+ return op.kind === "upsert" || op.kind === "remove" || op.kind === "clear";
188
194
  }
189
195
  var FALLBACK_PRESENCE_LANE = "";
190
196
  var MAX_PRESENCE_LANE_LENGTH = 64;
@@ -197,9 +203,7 @@ function presenceLaneOf(data) {
197
203
  return kind;
198
204
  }
199
205
  function isLayerOp(op) {
200
- if (typeof op !== "object" || op === null) return false;
201
- const k = op.kind;
202
- return k === "layer-upsert" || k === "layer-remove";
206
+ return op.kind === "layer-upsert" || op.kind === "layer-remove";
203
207
  }
204
208
  function layerOpToRecord(op) {
205
209
  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 };
@@ -212,13 +216,22 @@ function layerRecordToOp(record) {
212
216
  editor: record.editor
213
217
  } : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
214
218
  }
215
- function capabilityProfile(capabilities) {
216
- return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds]);
219
+ function encodingProfile(capabilities, revealOwner) {
220
+ return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds, revealOwner]);
221
+ }
222
+ function withoutOwnerId(element) {
223
+ if (element.ownerId === void 0) return element;
224
+ const rest = { ...element };
225
+ delete rest.ownerId;
226
+ return rest;
227
+ }
228
+ function stripOwnerId(op) {
229
+ if (op.kind === "upsert") return { ...op, element: withoutOwnerId(op.element) };
230
+ if (op.kind === "snapshot") return { ...op, elements: op.elements.map(withoutOwnerId) };
231
+ return op;
217
232
  }
218
233
  function isPresenceOp(op) {
219
- if (typeof op !== "object" || op === null) return false;
220
- const k = op.kind;
221
- return k === "presence" || k === "presence-leave";
234
+ return op.kind === "presence" || op.kind === "presence-leave";
222
235
  }
223
236
  var SyncHub = class {
224
237
  backend;
@@ -237,10 +250,13 @@ var SyncHub = class {
237
250
  elementRegistry;
238
251
  peerCapabilities = /* @__PURE__ */ new Map();
239
252
  canRead;
253
+ canReadOwnerId;
254
+ resolveAudience;
240
255
  memoryLayers = /* @__PURE__ */ new Map();
241
256
  maxJsonDepth;
242
257
  presenceThrottleMs;
243
258
  maxPresenceLanes;
259
+ maxPresenceBytes;
244
260
  /**
245
261
  * Presence throttle state keyed by connection, then by lane. A lane is the
246
262
  * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
@@ -260,6 +276,8 @@ var SyncHub = class {
260
276
  this.authorize = options.authorize;
261
277
  this.authorizeLayer = options.authorizeLayer;
262
278
  this.canRead = options.canRead;
279
+ this.canReadOwnerId = options.canReadOwnerId;
280
+ this.resolveAudience = options.resolveAudience;
263
281
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
264
282
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
265
283
  const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
@@ -267,6 +285,11 @@ var SyncHub = class {
267
285
  throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
268
286
  }
269
287
  this.maxPresenceLanes = Math.floor(maxPresenceLanes);
288
+ const maxPresenceBytes = options.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
289
+ if (!Number.isFinite(maxPresenceBytes) || maxPresenceBytes < 0) {
290
+ throw new RangeError("maxPresenceBytes must be a non-negative finite number");
291
+ }
292
+ this.maxPresenceBytes = maxPresenceBytes;
270
293
  this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
271
294
  }
272
295
  addConnection(conn) {
@@ -305,6 +328,7 @@ var SyncHub = class {
305
328
  * forwards the same event to other instances on a best-effort basis.
306
329
  */
307
330
  broadcastPresence(room, data) {
331
+ if (!this.isPresenceWithinLimit(data)) return 0;
308
332
  const op = { kind: "presence", data };
309
333
  const sent = this.relayToRoom(room, void 0, JSON.stringify({ from: HUB_FROM, op }));
310
334
  this.safePublish(JSON.stringify({ o: this.instanceId, room, from: HUB_FROM, op }));
@@ -325,6 +349,7 @@ var SyncHub = class {
325
349
  return Promise.resolve();
326
350
  }
327
351
  if (env.op.kind === "presence") {
352
+ if (!this.isPresenceWithinLimit(env.op.data)) return Promise.resolve();
328
353
  this.schedulePresence(conn, env.op.data);
329
354
  return Promise.resolve();
330
355
  }
@@ -384,9 +409,22 @@ var SyncHub = class {
384
409
  await this.deliverPluginResult(conn, result);
385
410
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
386
411
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
387
- const needCurrent = (this.authorize || this.canRead) && id !== void 0;
412
+ const needCurrent = (this.authorize || this.canRead || this.resolveAudience) && id !== void 0;
388
413
  const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
389
414
  const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
415
+ if (op.kind === "upsert" && this.resolveAudience) {
416
+ const audience = this.resolveAudience({
417
+ userId: conn.userId,
418
+ role: conn.role,
419
+ room: conn.room,
420
+ element: op.element,
421
+ currentElement: current
422
+ });
423
+ const element = { ...op.element };
424
+ if (audience === void 0) delete element.audience;
425
+ else element.audience = audience;
426
+ op = { kind: "upsert", element };
427
+ }
390
428
  let outboundOp = op;
391
429
  if (this.authorize) {
392
430
  const allowed = await this.authorize({
@@ -547,6 +585,10 @@ var SyncHub = class {
547
585
  }
548
586
  map.set(record.id, record);
549
587
  }
588
+ mayReadOwnerId(conn) {
589
+ if (!this.canReadOwnerId) return false;
590
+ return this.canReadOwnerId({ userId: conn.userId, role: conn.role, room: conn.room });
591
+ }
550
592
  mayRead(conn, audience) {
551
593
  if (!this.canRead) return true;
552
594
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -581,10 +623,11 @@ var SyncHub = class {
581
623
  */
582
624
  sendToConnection(conn, from, op, encoded) {
583
625
  const capabilities = this.peerCapabilities.get(conn.id) ?? createLegacyCapabilities();
584
- const profile = encoded ? capabilityProfile(capabilities) : void 0;
626
+ const revealOwner = this.mayReadOwnerId(conn);
627
+ const profile = encoded ? encodingProfile(capabilities, revealOwner) : void 0;
585
628
  let message = profile === void 0 ? void 0 : encoded?.get(profile);
586
629
  if (message === void 0) {
587
- message = this.encodeForPeer(from, op, capabilities);
630
+ message = this.encodeForPeer(from, op, capabilities, revealOwner);
588
631
  if (profile !== void 0) encoded?.set(profile, message);
589
632
  }
590
633
  if (message === null) return false;
@@ -595,11 +638,15 @@ var SyncHub = class {
595
638
  return false;
596
639
  }
597
640
  }
598
- /** Returns the wire frame for `op` translated for `capabilities`, or null when lossy. */
599
- encodeForPeer(from, op, capabilities) {
641
+ /**
642
+ * Returns the wire frame for `op` translated for `capabilities`, or null
643
+ * when lossy. The server-stamped `ownerId` is a server-side authorization
644
+ * fact: it leaves the hub only for peers `canReadOwnerId` admits.
645
+ */
646
+ encodeForPeer(from, op, capabilities, revealOwner) {
600
647
  try {
601
648
  const translated = translateOpForPeer(
602
- op,
649
+ revealOwner ? op : stripOwnerId(op),
603
650
  capabilities,
604
651
  this.elementRegistry,
605
652
  this.pluginRegistry.extensionDefinitions
@@ -779,6 +826,7 @@ var SyncHub = class {
779
826
  if (correction) this.sendToConnection(conn, HUB_FROM, correction);
780
827
  }
781
828
  onFanout(payload) {
829
+ if (!hasJsonDepthAtMost(payload, this.maxJsonDepth)) return;
782
830
  let env;
783
831
  try {
784
832
  env = JSON.parse(payload);
@@ -788,8 +836,11 @@ var SyncHub = class {
788
836
  if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
789
837
  return;
790
838
  if (env.o === this.instanceId) return;
791
- const op = env.op;
839
+ const envelope = { from: env.from, op: env.op };
840
+ if (!isValidEnvelope(envelope)) return;
841
+ const op = envelope.op;
792
842
  if (isPresenceOp(op)) {
843
+ if (op.kind === "presence" && !this.isPresenceWithinLimit(op.data)) return;
793
844
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
794
845
  return;
795
846
  }
@@ -799,8 +850,16 @@ var SyncHub = class {
799
850
  this.relayOpToRoom(env.room, void 0, env.from, op);
800
851
  return;
801
852
  }
802
- 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;
803
- if (plugin && typeof op === "object" && op !== null) {
853
+ let plugin;
854
+ if (op.kind === "extension") {
855
+ const entry = this.pluginRegistry.extension(op.extensionKind);
856
+ if (!entry || !entry.kind.codec.validate(op.payload)) return;
857
+ plugin = entry.plugin;
858
+ } else {
859
+ plugin = this.pluginRegistry.ownerOf(op.kind);
860
+ }
861
+ if (plugin) {
862
+ const owner = plugin;
804
863
  const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
805
864
  const operation = previous.then(async () => {
806
865
  const context = {
@@ -809,7 +868,7 @@ var SyncHub = class {
809
868
  backend: this.backend,
810
869
  backendPlugin: (key) => this.backend.getService?.(key)
811
870
  };
812
- const accepted = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
871
+ const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
813
872
  if (accepted) {
814
873
  this.relayOpToRoom(env.room, void 0, env.from, accepted);
815
874
  }
@@ -831,6 +890,9 @@ var SyncHub = class {
831
890
  const prevExisted = env.existed === true;
832
891
  this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
833
892
  }
893
+ isPresenceWithinLimit(data) {
894
+ return utf8ByteLength(JSON.stringify(data) ?? "") <= this.maxPresenceBytes;
895
+ }
834
896
  async applyFanoutLayerOp(room, op) {
835
897
  const record = layerOpToRecord(op);
836
898
  const current = await this.getLayerRecord(room, record.id);
@@ -846,6 +908,23 @@ var SyncHub = class {
846
908
  // src/create-sync-server.ts
847
909
  import { WebSocketServer } from "ws";
848
910
 
911
+ // src/authenticate.ts
912
+ import { readBearerSubprotocol } from "@fieldnotes/sync";
913
+ function readBearerToken(req) {
914
+ const fromProtocol = readBearerSubprotocol(headerValue(req.headers["sec-websocket-protocol"]));
915
+ if (fromProtocol) return fromProtocol;
916
+ const authorization = headerValue(req.headers["authorization"]);
917
+ if (authorization) {
918
+ const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
919
+ if (match?.[1]) return match[1];
920
+ }
921
+ const query = new URL(req.url ?? "", "http://localhost").searchParams.get("token");
922
+ return query || void 0;
923
+ }
924
+ function headerValue(value) {
925
+ return Array.isArray(value) ? value.join(",") : value;
926
+ }
927
+
849
928
  // src/heartbeat.ts
850
929
  function startHeartbeat(wss, intervalMs) {
851
930
  if (intervalMs <= 0) {
@@ -877,6 +956,9 @@ function startHeartbeat(wss, intervalMs) {
877
956
  return { track, stop: () => clearInterval(interval) };
878
957
  }
879
958
 
959
+ // src/create-sync-server.ts
960
+ import { BEARER_SUBPROTOCOL_PREFIX, SYNC_WS_SUBPROTOCOL } from "@fieldnotes/sync";
961
+
880
962
  // src/shutdown.ts
881
963
  var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
882
964
  function drainWebSocketServer(wss, graceMs) {
@@ -911,16 +993,78 @@ function drainWebSocketServer(wss, graceMs) {
911
993
  });
912
994
  }
913
995
 
996
+ // src/room-name.ts
997
+ var ROOM_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
998
+ function isValidRoomName(room) {
999
+ return typeof room === "string" && ROOM_NAME_PATTERN.test(room);
1000
+ }
1001
+
914
1002
  // src/create-sync-server.ts
1003
+ var ConcurrencyCounter = class {
1004
+ constructor(limit) {
1005
+ this.limit = limit;
1006
+ }
1007
+ counts = /* @__PURE__ */ new Map();
1008
+ /** Reserves a slot for `key`; returns a release function, or null when the cap is reached. */
1009
+ acquire(key) {
1010
+ const current = this.counts.get(key) ?? 0;
1011
+ if (current >= this.limit) return null;
1012
+ this.counts.set(key, current + 1);
1013
+ let released = false;
1014
+ return () => {
1015
+ if (released) return;
1016
+ released = true;
1017
+ const remaining = (this.counts.get(key) ?? 1) - 1;
1018
+ if (remaining <= 0) this.counts.delete(key);
1019
+ else this.counts.set(key, remaining);
1020
+ };
1021
+ }
1022
+ };
915
1023
  function rawDataByteLength(data) {
916
1024
  if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
917
1025
  return data.byteLength;
918
1026
  }
1027
+ function requirePositiveFinite(name, value) {
1028
+ if (!Number.isFinite(value) || value <= 0) {
1029
+ throw new RangeError(`${name} must be a positive finite number`);
1030
+ }
1031
+ return value;
1032
+ }
1033
+ function requirePositiveIntegerOrInfinity(name, value) {
1034
+ if (value !== Infinity && (!Number.isSafeInteger(value) || value <= 0)) {
1035
+ throw new RangeError(`${name} must be a positive safe integer or Infinity`);
1036
+ }
1037
+ return value;
1038
+ }
919
1039
  function createSyncServer(options = {}) {
920
1040
  const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
921
1041
  if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
922
1042
  throw new RangeError("shutdownGraceMs must be a non-negative finite number");
923
1043
  }
1044
+ const messagesPerSecond = requirePositiveFinite(
1045
+ "messagesPerSecond",
1046
+ options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND
1047
+ );
1048
+ const messageBurst = requirePositiveFinite(
1049
+ "messageBurst",
1050
+ options.messageBurst ?? DEFAULT_MESSAGE_BURST
1051
+ );
1052
+ const bytesPerSecond = requirePositiveFinite(
1053
+ "bytesPerSecond",
1054
+ options.bytesPerSecond ?? DEFAULT_BYTES_PER_SECOND
1055
+ );
1056
+ const byteBurst = requirePositiveFinite("byteBurst", options.byteBurst ?? DEFAULT_BYTE_BURST);
1057
+ const maxConnectionsPerIp = requirePositiveIntegerOrInfinity(
1058
+ "maxConnectionsPerIp",
1059
+ options.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP
1060
+ );
1061
+ const maxConnectionsPerRoom = requirePositiveIntegerOrInfinity(
1062
+ "maxConnectionsPerRoom",
1063
+ options.maxConnectionsPerRoom ?? DEFAULT_MAX_CONNECTIONS_PER_ROOM
1064
+ );
1065
+ if (options.authorize && !options.authenticate) {
1066
+ throw new Error("createSyncServer: `authorize` requires an `authenticate` hook");
1067
+ }
924
1068
  const hub = new SyncHub({
925
1069
  backend: options.backend,
926
1070
  fanout: options.fanout,
@@ -929,17 +1073,34 @@ function createSyncServer(options = {}) {
929
1073
  authorizeLayer: options.authorizeLayer,
930
1074
  plugins: options.plugins,
931
1075
  canRead: options.canRead,
1076
+ canReadOwnerId: options.canReadOwnerId,
1077
+ resolveAudience: options.resolveAudience,
932
1078
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
933
1079
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
934
1080
  maxPresenceLanes: options.maxPresenceLanes,
1081
+ maxPresenceBytes: options.maxPresenceBytes,
935
1082
  elementRegistry: options.elementRegistry
936
1083
  });
937
1084
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
938
- const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
1085
+ const handleProtocols = (protocols) => {
1086
+ if (protocols.has(SYNC_WS_SUBPROTOCOL)) return SYNC_WS_SUBPROTOCOL;
1087
+ for (const protocol of protocols) {
1088
+ if (!protocol.startsWith(BEARER_SUBPROTOCOL_PREFIX)) return protocol;
1089
+ }
1090
+ return false;
1091
+ };
1092
+ const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes, handleProtocols }) : new WebSocketServer({
1093
+ port: options.port ?? 0,
1094
+ maxPayload: maxMessageBytes,
1095
+ handleProtocols
1096
+ });
939
1097
  const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
940
1098
  let shuttingDown = false;
941
1099
  let closePromise;
942
1100
  let counter = 0;
1101
+ const perIp = new ConcurrencyCounter(maxConnectionsPerIp);
1102
+ const perRoom = new ConcurrencyCounter(maxConnectionsPerRoom);
1103
+ const clientAddress = options.clientAddress ?? ((req) => req.socket.remoteAddress);
943
1104
  wss.on("connection", (ws, req) => {
944
1105
  if (shuttingDown) {
945
1106
  ws.close(1001, "server shutting down");
@@ -953,6 +1114,22 @@ function createSyncServer(options = {}) {
953
1114
  ws.close(4400, "room required");
954
1115
  return;
955
1116
  }
1117
+ if (!isValidRoomName(room)) {
1118
+ ws.close(4400, "invalid room");
1119
+ return;
1120
+ }
1121
+ const address = clientAddress(req);
1122
+ const releaseIp = address ? perIp.acquire(address) : () => void 0;
1123
+ if (!releaseIp) {
1124
+ ws.close(4429, "too many connections");
1125
+ return;
1126
+ }
1127
+ const releaseRoom = perRoom.acquire(room);
1128
+ if (!releaseRoom) {
1129
+ releaseIp();
1130
+ ws.close(4429, "too many connections");
1131
+ return;
1132
+ }
956
1133
  const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
957
1134
  let state = "pending";
958
1135
  let closed = false;
@@ -961,10 +1138,8 @@ function createSyncServer(options = {}) {
961
1138
  let queuedBytes = 0;
962
1139
  const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
963
1140
  const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
964
- const limiter = new MessageRateLimiter(
965
- options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,
966
- options.messageBurst ?? DEFAULT_MESSAGE_BURST
967
- );
1141
+ const limiter = new MessageRateLimiter(messagesPerSecond, messageBurst);
1142
+ const byteLimiter = new MessageRateLimiter(bytesPerSecond, byteBurst);
968
1143
  const send = (m) => {
969
1144
  try {
970
1145
  ws.send(m);
@@ -979,7 +1154,8 @@ function createSyncServer(options = {}) {
979
1154
  ws.close(1009, "message too large");
980
1155
  return;
981
1156
  }
982
- if (!limiter.take()) {
1157
+ const now = Date.now();
1158
+ if (!limiter.take(now) || !byteLimiter.take(now, messageBytes)) {
983
1159
  state = "rejected";
984
1160
  ws.close(4408, "rate limit exceeded");
985
1161
  return;
@@ -999,9 +1175,13 @@ function createSyncServer(options = {}) {
999
1175
  });
1000
1176
  ws.on("close", () => {
1001
1177
  closed = true;
1178
+ releaseIp();
1179
+ releaseRoom();
1002
1180
  if (admitted) hub.removeConnection(connId);
1003
1181
  });
1004
- Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
1182
+ Promise.resolve(
1183
+ options.authenticate ? options.authenticate({ req, room, token: readBearerToken(req) }) : { userId: connId }
1184
+ ).then((result) => {
1005
1185
  if (closed || state === "rejected" || shuttingDown) return;
1006
1186
  if (!result) {
1007
1187
  state = "rejected";
@@ -1041,8 +1221,11 @@ function createSyncServer(options = {}) {
1041
1221
  export {
1042
1222
  InMemoryHubFanout,
1043
1223
  MemoryHubBackend,
1224
+ ROOM_NAME_PATTERN,
1044
1225
  SyncHub,
1045
1226
  createSyncServer,
1227
+ isValidRoomName,
1228
+ readBearerToken,
1046
1229
  startHeartbeat
1047
1230
  };
1048
1231
  //# sourceMappingURL=index.js.map