@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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { CanvasElement, ServiceKey } from '@fieldnotes/core';
2
- import { SyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
1
+ import { ServiceKey, ElementRegistry } from '@fieldnotes/core';
2
+ import { WireSyncElement, WireSyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
3
3
  export { PluginSnapshot } from '@fieldnotes/sync';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { IncomingMessage, Server } from 'http';
@@ -18,24 +18,21 @@ declare class InMemoryHubFanout implements HubFanout {
18
18
  interface HubBackend {
19
19
  /** True when every hub instance addresses the same atomic backing state (for example Redis). */
20
20
  readonly sharedAcrossInstances?: boolean;
21
- snapshot(room: string): Promise<CanvasElement[]>;
22
- get(room: string, id: string): Promise<CanvasElement | undefined>;
23
- apply(room: string, op: SyncOp): Promise<void>;
21
+ snapshot(room: string): Promise<WireSyncElement[]>;
22
+ get(room: string, id: string): Promise<WireSyncElement | undefined>;
23
+ apply(room: string, op: WireSyncOp): Promise<void>;
24
24
  layerRecords?(room: string): Promise<LayerRecord[]>;
25
25
  getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
26
26
  applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
27
27
  getService?<T>(key: ServiceKey<T>): T | undefined;
28
28
  }
29
29
 
30
- type OwnedElement = CanvasElement & {
31
- ownerId?: string;
32
- audience?: string;
33
- };
30
+ type OwnedElement = WireSyncElement;
34
31
  interface AuthorizeContext {
35
32
  userId?: string;
36
33
  role?: string;
37
34
  room: string;
38
- op: SyncOp;
35
+ op: WireSyncOp;
39
36
  currentElement?: OwnedElement;
40
37
  }
41
38
  type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
@@ -43,7 +40,7 @@ interface AuthorizeLayerContext {
43
40
  userId?: string;
44
41
  role?: string;
45
42
  room: string;
46
- op: Extract<SyncOp, {
43
+ op: Extract<WireSyncOp, {
47
44
  kind: 'layer-upsert' | 'layer-remove';
48
45
  }>;
49
46
  /** The hub's current record for the target layer, tombstones included. */
@@ -63,11 +60,39 @@ interface ReadContext {
63
60
  audience: string | undefined;
64
61
  }
65
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;
66
91
 
67
92
  interface ApplyResult {
68
- readonly accepted: SyncOp | null;
69
- readonly corrections: SyncOp[];
70
- readonly broadcast?: SyncOp[];
93
+ readonly accepted: WireSyncOp | null;
94
+ readonly corrections: WireSyncOp[];
95
+ readonly broadcast?: WireSyncOp[];
71
96
  readonly locality?: 'shared' | 'local';
72
97
  }
73
98
  interface ServerOpContext {
@@ -78,7 +103,7 @@ interface ServerOpContext {
78
103
  readonly backend: HubBackend;
79
104
  backendPlugin<T>(key: ServiceKey<T>): T | undefined;
80
105
  }
81
- type ServerNext = (op: SyncOp, context: ServerOpContext) => Promise<ApplyResult>;
106
+ type ServerNext = (op: WireSyncOp, context: ServerOpContext) => Promise<ApplyResult>;
82
107
  interface ServerExtensionRegistry {
83
108
  register<TPayload>(kind: ExtensionKind<TPayload>, handler: (op: TypedExtensionOp<TPayload>, context: ServerOpContext) => Promise<ApplyResult>): void;
84
109
  }
@@ -86,8 +111,8 @@ interface ServerSyncPlugin {
86
111
  readonly name: string;
87
112
  readonly ownedLegacyKinds?: readonly string[];
88
113
  readonly legacySnapshotKey?: string;
89
- process?(op: SyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
90
- applyFanout?(op: SyncOp, context: ServerOpContext): Promise<SyncOp | null>;
114
+ process?(op: WireSyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
115
+ applyFanout?(op: WireSyncOp, context: ServerOpContext): Promise<WireSyncOp | null>;
91
116
  registerExtensionKinds?(registry: ServerExtensionRegistry): void;
92
117
  snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot | undefined>;
93
118
  filterSnapshot?(snapshot: PluginSnapshot, viewer: {
@@ -111,9 +136,15 @@ interface SyncHubOptions {
111
136
  authorizeLayer?: AuthorizeLayer;
112
137
  plugins?: readonly ServerSyncPlugin[];
113
138
  canRead?: CanRead;
139
+ canReadOwnerId?: CanReadOwnerId;
140
+ resolveAudience?: ResolveAudience;
114
141
  maxJsonDepth?: number;
115
142
  presenceThrottleMs?: number;
116
143
  maxPresenceLanes?: number;
144
+ /** Largest `presence.data` payload relayed, in UTF-8 bytes of its JSON encoding. */
145
+ maxPresenceBytes?: number;
146
+ /** Registry used to translate extension elements for legacy peers. */
147
+ elementRegistry?: ElementRegistry;
117
148
  }
118
149
  declare class SyncHub {
119
150
  private readonly backend;
@@ -127,11 +158,16 @@ declare class SyncHub {
127
158
  private readonly authorize?;
128
159
  private readonly authorizeLayer?;
129
160
  private readonly pluginRegistry;
161
+ private readonly elementRegistry;
162
+ private readonly peerCapabilities;
130
163
  private readonly canRead?;
164
+ private readonly canReadOwnerId?;
165
+ private readonly resolveAudience?;
131
166
  private readonly memoryLayers;
132
167
  private readonly maxJsonDepth;
133
168
  private readonly presenceThrottleMs;
134
169
  private readonly maxPresenceLanes;
170
+ private readonly maxPresenceBytes;
135
171
  /**
136
172
  * Presence throttle state keyed by connection, then by lane. A lane is the
137
173
  * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
@@ -170,9 +206,32 @@ declare class SyncHub {
170
206
  private getLayerRecords;
171
207
  private getLayerRecord;
172
208
  private applyLayerRecord;
209
+ private mayReadOwnerId;
173
210
  private mayRead;
174
211
  private safePublish;
175
212
  private relayToRoom;
213
+ /**
214
+ * Translates `op` for the peer and sends it. Returns false when the op is
215
+ * lossy for this peer (no legacy encoding) or the socket throws; neither
216
+ * may reject the room operation that produced it.
217
+ */
218
+ private sendToConnection;
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
+ */
224
+ private encodeForPeer;
225
+ /**
226
+ * Normalize registered legacy wire elements before authorization, storage,
227
+ * and relay. A legacy type this hub has no adapter for is forwarded and
228
+ * stored verbatim — the hub is a relay, and the peers decide whether they
229
+ * understand it — so a hub deployed without domain adapters never erases
230
+ * the room's existing elements. Only a malformed registered element is dropped.
231
+ */
232
+ private normalizeElement;
233
+ private normalizeElements;
234
+ private relayOpToRoom;
176
235
  private broadcastClientPresence;
177
236
  private clearPresenceLanes;
178
237
  private schedulePresence;
@@ -181,6 +240,7 @@ declare class SyncHub {
181
240
  private deliverToRoom;
182
241
  private sendCorrection;
183
242
  private onFanout;
243
+ private isPresenceWithinLimit;
184
244
  private applyFanoutLayerOp;
185
245
  close(): void;
186
246
  }
@@ -190,9 +250,9 @@ declare class MemoryHubBackend implements HubBackend {
190
250
  private roomLayers;
191
251
  private room;
192
252
  private layers;
193
- snapshot(room: string): Promise<CanvasElement[]>;
194
- get(room: string, id: string): Promise<CanvasElement | undefined>;
195
- apply(room: string, op: SyncOp): Promise<void>;
253
+ snapshot(room: string): Promise<WireSyncElement[]>;
254
+ get(room: string, id: string): Promise<WireSyncElement | undefined>;
255
+ apply(room: string, op: WireSyncOp): Promise<void>;
196
256
  layerRecords(room: string): Promise<LayerRecord[]>;
197
257
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
198
258
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
@@ -201,12 +261,25 @@ declare class MemoryHubBackend implements HubBackend {
201
261
  interface AuthInfo {
202
262
  req: IncomingMessage;
203
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;
204
271
  }
205
272
  interface AuthResult {
206
273
  userId: string;
207
274
  role?: string;
208
275
  }
209
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;
210
283
 
211
284
  interface CreateSyncServerOptions {
212
285
  port?: number;
@@ -219,6 +292,8 @@ interface CreateSyncServerOptions {
219
292
  authorizeLayer?: AuthorizeLayer;
220
293
  plugins?: readonly ServerSyncPlugin[];
221
294
  canRead?: CanRead;
295
+ canReadOwnerId?: CanReadOwnerId;
296
+ resolveAudience?: ResolveAudience;
222
297
  heartbeatIntervalMs?: number;
223
298
  maxMessageBytes?: number;
224
299
  maxJsonDepth?: number;
@@ -226,9 +301,30 @@ interface CreateSyncServerOptions {
226
301
  maxPendingAuthBytes?: number;
227
302
  messagesPerSecond?: number;
228
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;
229
308
  presenceThrottleMs?: number;
230
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;
231
321
  shutdownGraceMs?: number;
322
+ /**
323
+ * Registry used to translate extension envelopes for legacy peers. Without
324
+ * it the hub relays unknown legacy element types verbatim and cannot encode
325
+ * envelopes for pre-envelope clients.
326
+ */
327
+ elementRegistry?: ElementRegistry;
232
328
  }
233
329
  declare function createSyncServer(options?: CreateSyncServerOptions): {
234
330
  hub: SyncHub;
@@ -236,6 +332,15 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
236
332
  close: () => Promise<void>;
237
333
  };
238
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
+
239
344
  interface HeartbeatSocket {
240
345
  ping(): void;
241
346
  terminate(): void;
@@ -250,4 +355,4 @@ interface Heartbeat {
250
355
  }
251
356
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
252
357
 
253
- 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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { CanvasElement, ServiceKey } from '@fieldnotes/core';
2
- import { SyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
1
+ import { ServiceKey, ElementRegistry } from '@fieldnotes/core';
2
+ import { WireSyncElement, WireSyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
3
3
  export { PluginSnapshot } from '@fieldnotes/sync';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { IncomingMessage, Server } from 'http';
@@ -18,24 +18,21 @@ declare class InMemoryHubFanout implements HubFanout {
18
18
  interface HubBackend {
19
19
  /** True when every hub instance addresses the same atomic backing state (for example Redis). */
20
20
  readonly sharedAcrossInstances?: boolean;
21
- snapshot(room: string): Promise<CanvasElement[]>;
22
- get(room: string, id: string): Promise<CanvasElement | undefined>;
23
- apply(room: string, op: SyncOp): Promise<void>;
21
+ snapshot(room: string): Promise<WireSyncElement[]>;
22
+ get(room: string, id: string): Promise<WireSyncElement | undefined>;
23
+ apply(room: string, op: WireSyncOp): Promise<void>;
24
24
  layerRecords?(room: string): Promise<LayerRecord[]>;
25
25
  getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
26
26
  applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
27
27
  getService?<T>(key: ServiceKey<T>): T | undefined;
28
28
  }
29
29
 
30
- type OwnedElement = CanvasElement & {
31
- ownerId?: string;
32
- audience?: string;
33
- };
30
+ type OwnedElement = WireSyncElement;
34
31
  interface AuthorizeContext {
35
32
  userId?: string;
36
33
  role?: string;
37
34
  room: string;
38
- op: SyncOp;
35
+ op: WireSyncOp;
39
36
  currentElement?: OwnedElement;
40
37
  }
41
38
  type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
@@ -43,7 +40,7 @@ interface AuthorizeLayerContext {
43
40
  userId?: string;
44
41
  role?: string;
45
42
  room: string;
46
- op: Extract<SyncOp, {
43
+ op: Extract<WireSyncOp, {
47
44
  kind: 'layer-upsert' | 'layer-remove';
48
45
  }>;
49
46
  /** The hub's current record for the target layer, tombstones included. */
@@ -63,11 +60,39 @@ interface ReadContext {
63
60
  audience: string | undefined;
64
61
  }
65
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;
66
91
 
67
92
  interface ApplyResult {
68
- readonly accepted: SyncOp | null;
69
- readonly corrections: SyncOp[];
70
- readonly broadcast?: SyncOp[];
93
+ readonly accepted: WireSyncOp | null;
94
+ readonly corrections: WireSyncOp[];
95
+ readonly broadcast?: WireSyncOp[];
71
96
  readonly locality?: 'shared' | 'local';
72
97
  }
73
98
  interface ServerOpContext {
@@ -78,7 +103,7 @@ interface ServerOpContext {
78
103
  readonly backend: HubBackend;
79
104
  backendPlugin<T>(key: ServiceKey<T>): T | undefined;
80
105
  }
81
- type ServerNext = (op: SyncOp, context: ServerOpContext) => Promise<ApplyResult>;
106
+ type ServerNext = (op: WireSyncOp, context: ServerOpContext) => Promise<ApplyResult>;
82
107
  interface ServerExtensionRegistry {
83
108
  register<TPayload>(kind: ExtensionKind<TPayload>, handler: (op: TypedExtensionOp<TPayload>, context: ServerOpContext) => Promise<ApplyResult>): void;
84
109
  }
@@ -86,8 +111,8 @@ interface ServerSyncPlugin {
86
111
  readonly name: string;
87
112
  readonly ownedLegacyKinds?: readonly string[];
88
113
  readonly legacySnapshotKey?: string;
89
- process?(op: SyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
90
- applyFanout?(op: SyncOp, context: ServerOpContext): Promise<SyncOp | null>;
114
+ process?(op: WireSyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
115
+ applyFanout?(op: WireSyncOp, context: ServerOpContext): Promise<WireSyncOp | null>;
91
116
  registerExtensionKinds?(registry: ServerExtensionRegistry): void;
92
117
  snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot | undefined>;
93
118
  filterSnapshot?(snapshot: PluginSnapshot, viewer: {
@@ -111,9 +136,15 @@ interface SyncHubOptions {
111
136
  authorizeLayer?: AuthorizeLayer;
112
137
  plugins?: readonly ServerSyncPlugin[];
113
138
  canRead?: CanRead;
139
+ canReadOwnerId?: CanReadOwnerId;
140
+ resolveAudience?: ResolveAudience;
114
141
  maxJsonDepth?: number;
115
142
  presenceThrottleMs?: number;
116
143
  maxPresenceLanes?: number;
144
+ /** Largest `presence.data` payload relayed, in UTF-8 bytes of its JSON encoding. */
145
+ maxPresenceBytes?: number;
146
+ /** Registry used to translate extension elements for legacy peers. */
147
+ elementRegistry?: ElementRegistry;
117
148
  }
118
149
  declare class SyncHub {
119
150
  private readonly backend;
@@ -127,11 +158,16 @@ declare class SyncHub {
127
158
  private readonly authorize?;
128
159
  private readonly authorizeLayer?;
129
160
  private readonly pluginRegistry;
161
+ private readonly elementRegistry;
162
+ private readonly peerCapabilities;
130
163
  private readonly canRead?;
164
+ private readonly canReadOwnerId?;
165
+ private readonly resolveAudience?;
131
166
  private readonly memoryLayers;
132
167
  private readonly maxJsonDepth;
133
168
  private readonly presenceThrottleMs;
134
169
  private readonly maxPresenceLanes;
170
+ private readonly maxPresenceBytes;
135
171
  /**
136
172
  * Presence throttle state keyed by connection, then by lane. A lane is the
137
173
  * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
@@ -170,9 +206,32 @@ declare class SyncHub {
170
206
  private getLayerRecords;
171
207
  private getLayerRecord;
172
208
  private applyLayerRecord;
209
+ private mayReadOwnerId;
173
210
  private mayRead;
174
211
  private safePublish;
175
212
  private relayToRoom;
213
+ /**
214
+ * Translates `op` for the peer and sends it. Returns false when the op is
215
+ * lossy for this peer (no legacy encoding) or the socket throws; neither
216
+ * may reject the room operation that produced it.
217
+ */
218
+ private sendToConnection;
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
+ */
224
+ private encodeForPeer;
225
+ /**
226
+ * Normalize registered legacy wire elements before authorization, storage,
227
+ * and relay. A legacy type this hub has no adapter for is forwarded and
228
+ * stored verbatim — the hub is a relay, and the peers decide whether they
229
+ * understand it — so a hub deployed without domain adapters never erases
230
+ * the room's existing elements. Only a malformed registered element is dropped.
231
+ */
232
+ private normalizeElement;
233
+ private normalizeElements;
234
+ private relayOpToRoom;
176
235
  private broadcastClientPresence;
177
236
  private clearPresenceLanes;
178
237
  private schedulePresence;
@@ -181,6 +240,7 @@ declare class SyncHub {
181
240
  private deliverToRoom;
182
241
  private sendCorrection;
183
242
  private onFanout;
243
+ private isPresenceWithinLimit;
184
244
  private applyFanoutLayerOp;
185
245
  close(): void;
186
246
  }
@@ -190,9 +250,9 @@ declare class MemoryHubBackend implements HubBackend {
190
250
  private roomLayers;
191
251
  private room;
192
252
  private layers;
193
- snapshot(room: string): Promise<CanvasElement[]>;
194
- get(room: string, id: string): Promise<CanvasElement | undefined>;
195
- apply(room: string, op: SyncOp): Promise<void>;
253
+ snapshot(room: string): Promise<WireSyncElement[]>;
254
+ get(room: string, id: string): Promise<WireSyncElement | undefined>;
255
+ apply(room: string, op: WireSyncOp): Promise<void>;
196
256
  layerRecords(room: string): Promise<LayerRecord[]>;
197
257
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
198
258
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
@@ -201,12 +261,25 @@ declare class MemoryHubBackend implements HubBackend {
201
261
  interface AuthInfo {
202
262
  req: IncomingMessage;
203
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;
204
271
  }
205
272
  interface AuthResult {
206
273
  userId: string;
207
274
  role?: string;
208
275
  }
209
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;
210
283
 
211
284
  interface CreateSyncServerOptions {
212
285
  port?: number;
@@ -219,6 +292,8 @@ interface CreateSyncServerOptions {
219
292
  authorizeLayer?: AuthorizeLayer;
220
293
  plugins?: readonly ServerSyncPlugin[];
221
294
  canRead?: CanRead;
295
+ canReadOwnerId?: CanReadOwnerId;
296
+ resolveAudience?: ResolveAudience;
222
297
  heartbeatIntervalMs?: number;
223
298
  maxMessageBytes?: number;
224
299
  maxJsonDepth?: number;
@@ -226,9 +301,30 @@ interface CreateSyncServerOptions {
226
301
  maxPendingAuthBytes?: number;
227
302
  messagesPerSecond?: number;
228
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;
229
308
  presenceThrottleMs?: number;
230
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;
231
321
  shutdownGraceMs?: number;
322
+ /**
323
+ * Registry used to translate extension envelopes for legacy peers. Without
324
+ * it the hub relays unknown legacy element types verbatim and cannot encode
325
+ * envelopes for pre-envelope clients.
326
+ */
327
+ elementRegistry?: ElementRegistry;
232
328
  }
233
329
  declare function createSyncServer(options?: CreateSyncServerOptions): {
234
330
  hub: SyncHub;
@@ -236,6 +332,15 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
236
332
  close: () => Promise<void>;
237
333
  };
238
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
+
239
344
  interface HeartbeatSocket {
240
345
  ping(): void;
241
346
  terminate(): void;
@@ -250,4 +355,4 @@ interface Heartbeat {
250
355
  }
251
356
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
252
357
 
253
- 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 };