@meshwhisper/sdk 0.1.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.
Files changed (163) hide show
  1. package/README.md +138 -0
  2. package/dist/browser/index.d.ts +4 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +19 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/chaff/index.d.ts +91 -0
  7. package/dist/chaff/index.d.ts.map +1 -0
  8. package/dist/chaff/index.js +268 -0
  9. package/dist/chaff/index.js.map +1 -0
  10. package/dist/cluster/index.d.ts +159 -0
  11. package/dist/cluster/index.d.ts.map +1 -0
  12. package/dist/cluster/index.js +393 -0
  13. package/dist/cluster/index.js.map +1 -0
  14. package/dist/compliance/index.d.ts +129 -0
  15. package/dist/compliance/index.d.ts.map +1 -0
  16. package/dist/compliance/index.js +315 -0
  17. package/dist/compliance/index.js.map +1 -0
  18. package/dist/crypto/index.d.ts +65 -0
  19. package/dist/crypto/index.d.ts.map +1 -0
  20. package/dist/crypto/index.js +146 -0
  21. package/dist/crypto/index.js.map +1 -0
  22. package/dist/group/index.d.ts +155 -0
  23. package/dist/group/index.d.ts.map +1 -0
  24. package/dist/group/index.js +560 -0
  25. package/dist/group/index.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/namespace/index.d.ts +155 -0
  31. package/dist/namespace/index.d.ts.map +1 -0
  32. package/dist/namespace/index.js +278 -0
  33. package/dist/namespace/index.js.map +1 -0
  34. package/dist/node/index.d.ts +4 -0
  35. package/dist/node/index.d.ts.map +1 -0
  36. package/dist/node/index.js +19 -0
  37. package/dist/node/index.js.map +1 -0
  38. package/dist/packet/index.d.ts +63 -0
  39. package/dist/packet/index.d.ts.map +1 -0
  40. package/dist/packet/index.js +244 -0
  41. package/dist/packet/index.js.map +1 -0
  42. package/dist/permissions/index.d.ts +107 -0
  43. package/dist/permissions/index.d.ts.map +1 -0
  44. package/dist/permissions/index.js +282 -0
  45. package/dist/permissions/index.js.map +1 -0
  46. package/dist/persistence/idb-storage.d.ts +27 -0
  47. package/dist/persistence/idb-storage.d.ts.map +1 -0
  48. package/dist/persistence/idb-storage.js +75 -0
  49. package/dist/persistence/idb-storage.js.map +1 -0
  50. package/dist/persistence/index.d.ts +4 -0
  51. package/dist/persistence/index.d.ts.map +1 -0
  52. package/dist/persistence/index.js +3 -0
  53. package/dist/persistence/index.js.map +1 -0
  54. package/dist/persistence/node-storage.d.ts +33 -0
  55. package/dist/persistence/node-storage.d.ts.map +1 -0
  56. package/dist/persistence/node-storage.js +90 -0
  57. package/dist/persistence/node-storage.js.map +1 -0
  58. package/dist/persistence/serialization.d.ts +4 -0
  59. package/dist/persistence/serialization.d.ts.map +1 -0
  60. package/dist/persistence/serialization.js +49 -0
  61. package/dist/persistence/serialization.js.map +1 -0
  62. package/dist/persistence/types.d.ts +29 -0
  63. package/dist/persistence/types.d.ts.map +1 -0
  64. package/dist/persistence/types.js +5 -0
  65. package/dist/persistence/types.js.map +1 -0
  66. package/dist/ratchet/index.d.ts +80 -0
  67. package/dist/ratchet/index.d.ts.map +1 -0
  68. package/dist/ratchet/index.js +259 -0
  69. package/dist/ratchet/index.js.map +1 -0
  70. package/dist/reciprocity/index.d.ts +109 -0
  71. package/dist/reciprocity/index.d.ts.map +1 -0
  72. package/dist/reciprocity/index.js +311 -0
  73. package/dist/reciprocity/index.js.map +1 -0
  74. package/dist/relay/index.d.ts +87 -0
  75. package/dist/relay/index.d.ts.map +1 -0
  76. package/dist/relay/index.js +286 -0
  77. package/dist/relay/index.js.map +1 -0
  78. package/dist/routing/index.d.ts +136 -0
  79. package/dist/routing/index.d.ts.map +1 -0
  80. package/dist/routing/index.js +478 -0
  81. package/dist/routing/index.js.map +1 -0
  82. package/dist/sdk/index.d.ts +322 -0
  83. package/dist/sdk/index.d.ts.map +1 -0
  84. package/dist/sdk/index.js +1530 -0
  85. package/dist/sdk/index.js.map +1 -0
  86. package/dist/sybil/index.d.ts +123 -0
  87. package/dist/sybil/index.d.ts.map +1 -0
  88. package/dist/sybil/index.js +491 -0
  89. package/dist/sybil/index.js.map +1 -0
  90. package/dist/transport/browser/index.d.ts +34 -0
  91. package/dist/transport/browser/index.d.ts.map +1 -0
  92. package/dist/transport/browser/index.js +176 -0
  93. package/dist/transport/browser/index.js.map +1 -0
  94. package/dist/transport/local/index.d.ts +57 -0
  95. package/dist/transport/local/index.d.ts.map +1 -0
  96. package/dist/transport/local/index.js +442 -0
  97. package/dist/transport/local/index.js.map +1 -0
  98. package/dist/transport/negotiator/index.d.ts +79 -0
  99. package/dist/transport/negotiator/index.d.ts.map +1 -0
  100. package/dist/transport/negotiator/index.js +289 -0
  101. package/dist/transport/negotiator/index.js.map +1 -0
  102. package/dist/transport/node/index.d.ts +56 -0
  103. package/dist/transport/node/index.d.ts.map +1 -0
  104. package/dist/transport/node/index.js +209 -0
  105. package/dist/transport/node/index.js.map +1 -0
  106. package/dist/transport/noop/index.d.ts +11 -0
  107. package/dist/transport/noop/index.d.ts.map +1 -0
  108. package/dist/transport/noop/index.js +20 -0
  109. package/dist/transport/noop/index.js.map +1 -0
  110. package/dist/transport/p2p/index.d.ts +109 -0
  111. package/dist/transport/p2p/index.d.ts.map +1 -0
  112. package/dist/transport/p2p/index.js +237 -0
  113. package/dist/transport/p2p/index.js.map +1 -0
  114. package/dist/transport/websocket/index.d.ts +89 -0
  115. package/dist/transport/websocket/index.d.ts.map +1 -0
  116. package/dist/transport/websocket/index.js +498 -0
  117. package/dist/transport/websocket/index.js.map +1 -0
  118. package/dist/transport/websocket/serialize.d.ts +5 -0
  119. package/dist/transport/websocket/serialize.d.ts.map +1 -0
  120. package/dist/transport/websocket/serialize.js +55 -0
  121. package/dist/transport/websocket/serialize.js.map +1 -0
  122. package/dist/types.d.ts +215 -0
  123. package/dist/types.d.ts.map +1 -0
  124. package/dist/types.js +15 -0
  125. package/dist/types.js.map +1 -0
  126. package/dist/x3dh/index.d.ts +120 -0
  127. package/dist/x3dh/index.d.ts.map +1 -0
  128. package/dist/x3dh/index.js +290 -0
  129. package/dist/x3dh/index.js.map +1 -0
  130. package/package.json +59 -0
  131. package/src/browser/index.ts +19 -0
  132. package/src/chaff/index.ts +340 -0
  133. package/src/cluster/index.ts +482 -0
  134. package/src/compliance/index.ts +407 -0
  135. package/src/crypto/index.ts +193 -0
  136. package/src/group/index.ts +719 -0
  137. package/src/index.ts +87 -0
  138. package/src/lz4js.d.ts +58 -0
  139. package/src/namespace/index.ts +336 -0
  140. package/src/node/index.ts +19 -0
  141. package/src/packet/index.ts +326 -0
  142. package/src/permissions/index.ts +405 -0
  143. package/src/persistence/idb-storage.ts +83 -0
  144. package/src/persistence/index.ts +3 -0
  145. package/src/persistence/node-storage.ts +96 -0
  146. package/src/persistence/serialization.ts +75 -0
  147. package/src/persistence/types.ts +33 -0
  148. package/src/ratchet/index.ts +363 -0
  149. package/src/reciprocity/index.ts +371 -0
  150. package/src/relay/index.ts +382 -0
  151. package/src/routing/index.ts +577 -0
  152. package/src/sdk/index.ts +1994 -0
  153. package/src/sybil/index.ts +661 -0
  154. package/src/transport/browser/index.ts +201 -0
  155. package/src/transport/local/index.ts +540 -0
  156. package/src/transport/negotiator/index.ts +397 -0
  157. package/src/transport/node/index.ts +234 -0
  158. package/src/transport/noop/index.ts +22 -0
  159. package/src/transport/p2p/index.ts +345 -0
  160. package/src/transport/websocket/index.ts +660 -0
  161. package/src/transport/websocket/serialize.ts +68 -0
  162. package/src/types.ts +275 -0
  163. package/src/x3dh/index.ts +388 -0
@@ -0,0 +1,482 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Device Clustering Module
3
+ // Manages device self-clustering so a user's devices form a
4
+ // personal availability cluster (PRD §5.6). The most capable
5
+ // device acts as primary receiver; messages sync across the
6
+ // cluster over local connections when connectivity allows.
7
+ // ============================================================
8
+
9
+ import { blake3 } from '@noble/hashes/blake3';
10
+ import type { ClusterDevice, DeviceCapability, BatteryState, RelayWillingness } from '../types.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Domain separator for cluster key derivation. */
17
+ const CLUSTER_KEY_DOMAIN = 'meshwhisper-cluster-v1';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Scoring tables for primary election
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const BATTERY_SCORE: Record<BatteryState, number> = {
24
+ charging: 5,
25
+ high: 4,
26
+ medium: 3,
27
+ low: 2,
28
+ critical: 1,
29
+ };
30
+
31
+ const CONNECTIVITY_SCORE = {
32
+ internet: 3,
33
+ local_net: 2,
34
+ platform_p2p: 1,
35
+ none: 0,
36
+ } as const;
37
+
38
+ const RELAY_SCORE: Record<RelayWillingness, number> = {
39
+ eager: 4,
40
+ willing: 3,
41
+ reluctant: 2,
42
+ unavailable: 1,
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // SyncMessage
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * A message queued for synchronization across the device cluster.
51
+ */
52
+ export interface SyncMessage {
53
+ messageId: string;
54
+ encryptedPayload: Uint8Array;
55
+ receivedAt: number;
56
+ receivedBy: string;
57
+ syncedTo: Set<string>;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // ClusterStatus
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface ClusterStatus {
65
+ deviceCount: number;
66
+ primaryId: string;
67
+ syncPending: number;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // MessageSyncManager
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Manages the queue of messages waiting to be synchronized to other
76
+ * devices in the cluster.
77
+ */
78
+ export class MessageSyncManager {
79
+ private readonly messages: Map<string, SyncMessage> = new Map();
80
+
81
+ /**
82
+ * Enqueue a received message for sync to other cluster devices.
83
+ */
84
+ queueForSync(message: SyncMessage): void {
85
+ if (!message.messageId) {
86
+ throw new TypeError('SyncMessage must have a non-empty messageId');
87
+ }
88
+ // Clone syncedTo so the caller cannot mutate our internal state.
89
+ this.messages.set(message.messageId, {
90
+ ...message,
91
+ syncedTo: new Set(message.syncedTo),
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Retrieve all messages that have **not** yet been synced to `deviceId`.
97
+ */
98
+ getUnsyncedMessages(deviceId: string): SyncMessage[] {
99
+ const result: SyncMessage[] = [];
100
+ for (const msg of this.messages.values()) {
101
+ if (!msg.syncedTo.has(deviceId)) {
102
+ result.push(this.cloneMessage(msg));
103
+ }
104
+ }
105
+ return result;
106
+ }
107
+
108
+ /**
109
+ * Mark a single message as synced to a specific device.
110
+ */
111
+ markSynced(messageId: string, deviceId: string): void {
112
+ const msg = this.messages.get(messageId);
113
+ if (msg) {
114
+ msg.syncedTo.add(deviceId);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Convenience method: returns all unsynced messages for `targetDeviceId`
120
+ * and marks each one as synced to that device in a single pass.
121
+ */
122
+ sync(targetDeviceId: string): SyncMessage[] {
123
+ const unsynced = this.getUnsyncedMessages(targetDeviceId);
124
+ for (const msg of unsynced) {
125
+ this.markSynced(msg.messageId, targetDeviceId);
126
+ }
127
+ return unsynced;
128
+ }
129
+
130
+ /**
131
+ * Total count of messages that still need to be synced to at least one
132
+ * device. Used by `DeviceCluster.getClusterStatus()`.
133
+ */
134
+ getPendingCount(allDeviceIds: string[]): number {
135
+ let count = 0;
136
+ for (const msg of this.messages.values()) {
137
+ for (const id of allDeviceIds) {
138
+ if (!msg.syncedTo.has(id)) {
139
+ count++;
140
+ break; // only count each message once
141
+ }
142
+ }
143
+ }
144
+ return count;
145
+ }
146
+
147
+ /**
148
+ * Remove all tracked messages.
149
+ */
150
+ clear(): void {
151
+ this.messages.clear();
152
+ }
153
+
154
+ // -----------------------------------------------------------------------
155
+ // Private helpers
156
+ // -----------------------------------------------------------------------
157
+
158
+ private cloneMessage(msg: SyncMessage): SyncMessage {
159
+ return {
160
+ ...msg,
161
+ syncedTo: new Set(msg.syncedTo),
162
+ };
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // DeviceCluster
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Manages a personal device cluster.
172
+ *
173
+ * A cluster groups all of a single user's devices so they can:
174
+ * - elect a primary receiver (best battery/connectivity/willingness)
175
+ * - accept messages on behalf of the user from any member
176
+ * - synchronize messages to all other members when connectivity allows
177
+ */
178
+ export class DeviceCluster {
179
+ private readonly localDeviceId: string;
180
+ private readonly clusterKey: Uint8Array;
181
+ private readonly devices: Map<string, ClusterDevice> = new Map();
182
+ private primaryDeviceId: string | null = null;
183
+ private running = false;
184
+
185
+ /** Public message sync manager. */
186
+ readonly syncManager: MessageSyncManager = new MessageSyncManager();
187
+
188
+ constructor(identityKey: Uint8Array, localDeviceId: string) {
189
+ if (!localDeviceId) {
190
+ throw new TypeError('localDeviceId must be a non-empty string');
191
+ }
192
+ if (identityKey.length === 0) {
193
+ throw new TypeError('identityKey must not be empty');
194
+ }
195
+
196
+ this.localDeviceId = localDeviceId;
197
+ this.clusterKey = DeviceCluster.deriveClusterKey(identityKey);
198
+ }
199
+
200
+ // -----------------------------------------------------------------------
201
+ // Cluster key derivation
202
+ // -----------------------------------------------------------------------
203
+
204
+ /**
205
+ * Derive a cluster key from the user's identity private key.
206
+ *
207
+ * clusterKey = BLAKE3(identity_key || "meshwhisper-cluster-v1")
208
+ */
209
+ static deriveClusterKey(identityPrivateKey: Uint8Array): Uint8Array {
210
+ const domain = new TextEncoder().encode(CLUSTER_KEY_DOMAIN);
211
+ const input = new Uint8Array(identityPrivateKey.length + domain.length);
212
+ input.set(identityPrivateKey, 0);
213
+ input.set(domain, identityPrivateKey.length);
214
+ return blake3(input);
215
+ }
216
+
217
+ // -----------------------------------------------------------------------
218
+ // Device registration
219
+ // -----------------------------------------------------------------------
220
+
221
+ /**
222
+ * Register a device in the cluster.
223
+ */
224
+ addDevice(device: ClusterDevice): void {
225
+ if (!device.deviceId) {
226
+ throw new TypeError('ClusterDevice must have a non-empty deviceId');
227
+ }
228
+ this.devices.set(device.deviceId, { ...device });
229
+
230
+ if (this.running) {
231
+ this.reelectPrimary();
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Remove a device from the cluster.
237
+ */
238
+ removeDevice(deviceId: string): void {
239
+ this.devices.delete(deviceId);
240
+
241
+ if (this.primaryDeviceId === deviceId) {
242
+ this.primaryDeviceId = null;
243
+ }
244
+
245
+ if (this.running && this.devices.size > 0) {
246
+ this.reelectPrimary();
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Return a snapshot of all devices currently in the cluster.
252
+ */
253
+ getDevices(): ClusterDevice[] {
254
+ return Array.from(this.devices.values()).map((d) => ({ ...d }));
255
+ }
256
+
257
+ /**
258
+ * Return the local device entry.
259
+ *
260
+ * @throws if the local device has not been added to the cluster.
261
+ */
262
+ getLocalDevice(): ClusterDevice {
263
+ const local = this.devices.get(this.localDeviceId);
264
+ if (!local) {
265
+ throw new Error(
266
+ `Local device "${this.localDeviceId}" has not been added to the cluster`,
267
+ );
268
+ }
269
+ return { ...local };
270
+ }
271
+
272
+ /**
273
+ * Check whether a device ID belongs to this cluster.
274
+ */
275
+ isClusterMember(deviceId: string): boolean {
276
+ return this.devices.has(deviceId);
277
+ }
278
+
279
+ // -----------------------------------------------------------------------
280
+ // Primary receiver election
281
+ // -----------------------------------------------------------------------
282
+
283
+ /**
284
+ * Elect the most available device as primary receiver.
285
+ *
286
+ * Scoring criteria (in priority order):
287
+ * 1. Battery state (charging > high > medium > low > critical)
288
+ * 2. Connectivity (internet > local_net > platform_p2p)
289
+ * 3. Relay willingness (eager > willing > reluctant > unavailable)
290
+ * 4. Inbound connectable (true preferred)
291
+ *
292
+ * Returns the elected primary device, or throws if the cluster is empty.
293
+ */
294
+ electPrimary(): ClusterDevice {
295
+ if (this.devices.size === 0) {
296
+ throw new Error('Cannot elect primary: cluster has no devices');
297
+ }
298
+
299
+ let best: ClusterDevice | null = null;
300
+ let bestScore = -1;
301
+
302
+ for (const device of this.devices.values()) {
303
+ const score = DeviceCluster.scoreDevice(device);
304
+ if (score > bestScore) {
305
+ bestScore = score;
306
+ best = device;
307
+ }
308
+ }
309
+
310
+ // best is guaranteed non-null because devices.size > 0
311
+ const elected = best!;
312
+
313
+ // Update isPrimary flags
314
+ for (const device of this.devices.values()) {
315
+ device.isPrimary = device.deviceId === elected.deviceId;
316
+ }
317
+
318
+ this.primaryDeviceId = elected.deviceId;
319
+ return { ...elected, isPrimary: true };
320
+ }
321
+
322
+ /**
323
+ * Return the current primary device, or `null` if no election has run.
324
+ */
325
+ getPrimary(): ClusterDevice | null {
326
+ if (this.primaryDeviceId === null) {
327
+ return null;
328
+ }
329
+ const device = this.devices.get(this.primaryDeviceId);
330
+ return device ? { ...device } : null;
331
+ }
332
+
333
+ /**
334
+ * Whether the local device is currently the primary receiver.
335
+ */
336
+ isPrimary(): boolean {
337
+ return this.primaryDeviceId === this.localDeviceId;
338
+ }
339
+
340
+ // -----------------------------------------------------------------------
341
+ // Capability updates
342
+ // -----------------------------------------------------------------------
343
+
344
+ /**
345
+ * Update the local device's capabilities and re-elect primary if the
346
+ * cluster is running.
347
+ */
348
+ updateLocalCapabilities(capabilities: DeviceCapability): void {
349
+ const local = this.devices.get(this.localDeviceId);
350
+ if (!local) {
351
+ throw new Error(
352
+ `Local device "${this.localDeviceId}" has not been added to the cluster`,
353
+ );
354
+ }
355
+ local.capabilities = { ...capabilities };
356
+
357
+ if (this.running) {
358
+ this.reelectPrimary();
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Handle a capability update broadcast from a remote device.
364
+ */
365
+ handleCapabilityUpdate(deviceId: string, capabilities: DeviceCapability): void {
366
+ const device = this.devices.get(deviceId);
367
+ if (!device) {
368
+ throw new Error(`Device "${deviceId}" is not a member of this cluster`);
369
+ }
370
+ device.capabilities = { ...capabilities };
371
+
372
+ if (this.running) {
373
+ this.reelectPrimary();
374
+ }
375
+ }
376
+
377
+ // -----------------------------------------------------------------------
378
+ // Cluster lifecycle
379
+ // -----------------------------------------------------------------------
380
+
381
+ /**
382
+ * Start cluster operations. Triggers an initial primary election.
383
+ */
384
+ start(): void {
385
+ if (this.running) {
386
+ return;
387
+ }
388
+ this.running = true;
389
+
390
+ if (this.devices.size > 0) {
391
+ this.reelectPrimary();
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Stop cluster operations gracefully.
397
+ */
398
+ stop(): void {
399
+ if (!this.running) {
400
+ return;
401
+ }
402
+ this.running = false;
403
+ this.primaryDeviceId = null;
404
+
405
+ // Reset isPrimary on all devices
406
+ for (const device of this.devices.values()) {
407
+ device.isPrimary = false;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Return a summary of the current cluster state.
413
+ */
414
+ getClusterStatus(): ClusterStatus {
415
+ const deviceIds = Array.from(this.devices.keys());
416
+ return {
417
+ deviceCount: this.devices.size,
418
+ primaryId: this.primaryDeviceId ?? '',
419
+ syncPending: this.syncManager.getPendingCount(deviceIds),
420
+ };
421
+ }
422
+
423
+ // -----------------------------------------------------------------------
424
+ // Accessors
425
+ // -----------------------------------------------------------------------
426
+
427
+ /** The derived cluster key shared by all devices. */
428
+ getClusterKey(): Uint8Array {
429
+ return new Uint8Array(this.clusterKey);
430
+ }
431
+
432
+ /** Whether the cluster has been started. */
433
+ isRunning(): boolean {
434
+ return this.running;
435
+ }
436
+
437
+ // -----------------------------------------------------------------------
438
+ // Static scoring helpers
439
+ // -----------------------------------------------------------------------
440
+
441
+ /**
442
+ * Compute an availability score for a device.
443
+ *
444
+ * Higher score = more suitable as primary receiver.
445
+ * The score is a composite of battery, connectivity, relay willingness,
446
+ * and inbound-connectable flag, weighted so battery dominates.
447
+ */
448
+ static scoreDevice(device: ClusterDevice): number {
449
+ const cap = device.capabilities;
450
+
451
+ const battery = BATTERY_SCORE[cap.batteryState];
452
+ const connectivity = DeviceCluster.connectivityScore(cap);
453
+ const relay = RELAY_SCORE[cap.relayWillingness];
454
+ const inbound = cap.inboundConnectable ? 1 : 0;
455
+
456
+ // Weight battery most heavily so charging/plugged-in devices are
457
+ // strongly preferred, then connectivity, relay, inbound.
458
+ return battery * 1000 + connectivity * 100 + relay * 10 + inbound;
459
+ }
460
+
461
+ // -----------------------------------------------------------------------
462
+ // Private helpers
463
+ // -----------------------------------------------------------------------
464
+
465
+ /**
466
+ * Compute a connectivity score from capability flags.
467
+ * A device can have multiple bearers; we use the best available.
468
+ */
469
+ private static connectivityScore(cap: DeviceCapability): number {
470
+ if (cap.bearerInternet) return CONNECTIVITY_SCORE.internet;
471
+ if (cap.bearerLocalNet) return CONNECTIVITY_SCORE.local_net;
472
+ if (cap.bearerPlatformP2P) return CONNECTIVITY_SCORE.platform_p2p;
473
+ return CONNECTIVITY_SCORE.none;
474
+ }
475
+
476
+ /**
477
+ * Run a primary election and update internal state.
478
+ */
479
+ private reelectPrimary(): void {
480
+ this.electPrimary();
481
+ }
482
+ }