@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,1994 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Public API Surface
3
+ // The main entry point developers interact with. Wires together
4
+ // all 17 internal modules into a cohesive, ergonomic interface.
5
+ // ============================================================
6
+
7
+ import type {
8
+ BearerType,
9
+ ChaffRate,
10
+ ClusterDevice,
11
+ DeviceCapability,
12
+ Group,
13
+ KeyPair,
14
+ Message,
15
+ MessageUrgency,
16
+ Packet,
17
+ PermissionModel,
18
+ PreKeyBundle,
19
+ PresenceStatus,
20
+ MeshWhisperConfig,
21
+ RelayWillingness,
22
+ StorageBackend,
23
+ StoredMessage,
24
+ Transport as MWTransport,
25
+ } from '../types.js';
26
+ import { PacketFlags } from '../types.js';
27
+ import {
28
+ serializeRatchetState,
29
+ deserializeRatchetState,
30
+ } from '../persistence/serialization.js';
31
+
32
+ // --- Internal module imports ---
33
+
34
+ import {
35
+ encrypt,
36
+ decrypt,
37
+ randomBytes,
38
+ deriveDestHash,
39
+ getCurrentEpochHour,
40
+ concat,
41
+ generateKeyPair,
42
+ kdf,
43
+ } from '../crypto/index.js';
44
+ import {
45
+ generatePreKeyBundle,
46
+ initiateKeyExchange,
47
+ completeKeyExchange,
48
+ serializePreKeyBundle,
49
+ deserializePreKeyBundle,
50
+ } from '../x3dh/index.js';
51
+ import type { RatchetState, RatchetHeader } from '../ratchet/index.js';
52
+ import {
53
+ initSender,
54
+ initReceiver,
55
+ ratchetEncrypt,
56
+ ratchetDecrypt,
57
+ } from '../ratchet/index.js';
58
+ import {
59
+ encodePacket,
60
+ decodePacket,
61
+ createDataPacket,
62
+ createHandshakePacket,
63
+ compressPayload,
64
+ decompressPayload,
65
+ PROTOCOL_VERSION,
66
+ } from '../packet/index.js';
67
+ import {
68
+ PlatformP2PTransport,
69
+ registerPlatformBridge,
70
+ } from '../transport/p2p/index.js';
71
+ import type { PlatformP2PBridge } from '../transport/p2p/index.js';
72
+ import { BearerNegotiator } from '../transport/negotiator/index.js';
73
+ import {
74
+ SocialGraphRouter,
75
+ PeerProximityTable,
76
+ } from '../routing/index.js';
77
+ import { RelayStore, StoreAndForwardManager } from '../relay/index.js';
78
+ import { RelayLedger } from '../reciprocity/index.js';
79
+ import {
80
+ NamespaceManager,
81
+ LocalIdentity,
82
+ PeerIdentityCache,
83
+ } from '../namespace/index.js';
84
+ import { PermissionManager } from '../permissions/index.js';
85
+ import type { ContactContext } from '../permissions/index.js';
86
+ import { DeviceCluster } from '../cluster/index.js';
87
+ import { GroupManager } from '../group/index.js';
88
+ import { ChaffGenerator } from '../chaff/index.js';
89
+ import { EntropyChallenger, ZKRelayReputation } from '../sybil/index.js';
90
+
91
+ // ============================================================
92
+ // Public option/event types
93
+ // ============================================================
94
+
95
+ export interface SendOptions {
96
+ /** Message urgency: background | normal | urgent | critical. */
97
+ urgency?: MessageUrgency;
98
+ /** Message expiry in seconds from now. */
99
+ expiry?: number;
100
+ }
101
+
102
+ export interface MediaSendOptions extends SendOptions {
103
+ /** MIME type of the media (e.g. "image/jpeg", "audio/mp4"). */
104
+ mimeType?: string;
105
+ /** Custom upload handler. If provided, overrides the Node media endpoint. */
106
+ upload?: (encryptedData: Uint8Array) => Promise<string>;
107
+ }
108
+
109
+ export interface MediaMessage {
110
+ /** URL to the encrypted media blob. */
111
+ url: string;
112
+ /** Base64-encoded AES-256-GCM key for decrypting the blob. */
113
+ key: string;
114
+ /** Optional MIME type. */
115
+ mimeType?: string;
116
+ }
117
+
118
+ export interface CreateGroupOptions {
119
+ name: string;
120
+ members: string[];
121
+ permissionModel?: PermissionModel;
122
+ }
123
+
124
+ export interface TransportChangedEvent {
125
+ type: BearerType;
126
+ available: boolean;
127
+ }
128
+
129
+ // ============================================================
130
+ // GroupHandle — returned by createGroup / getGroup
131
+ // ============================================================
132
+
133
+ /**
134
+ * A handle to a group that provides a `send()` method, mirroring
135
+ * the PRD's `group.send(payload)` API.
136
+ */
137
+ export class GroupHandle {
138
+ /** The underlying group metadata. */
139
+ readonly group: Group;
140
+
141
+ private readonly sdk: MeshWhisper;
142
+
143
+ /** @internal */
144
+ constructor(group: Group, sdk: MeshWhisper) {
145
+ this.group = group;
146
+ this.sdk = sdk;
147
+ }
148
+
149
+ /** The group's unique ID. */
150
+ get id(): string {
151
+ return this.group.id;
152
+ }
153
+
154
+ /** The group's display name. */
155
+ get name(): string {
156
+ return this.group.name;
157
+ }
158
+
159
+ /** List of member IDs. */
160
+ get members(): string[] {
161
+ return Array.from(this.group.members.keys());
162
+ }
163
+
164
+ /**
165
+ * Send a message to all group members.
166
+ * The payload is encrypted with the local peer's sender key and
167
+ * relayed through the group's dynamic relay tree.
168
+ */
169
+ async send(payload: Uint8Array): Promise<void> {
170
+ await this.sdk.sendToGroup(this.group.id, payload);
171
+ }
172
+
173
+ /** Add a member to the group. */
174
+ addMember(peerId: string): void {
175
+ this.sdk['groupManager'].addMember(this.group.id, peerId);
176
+ }
177
+
178
+ /** Remove a member from the group. */
179
+ removeMember(peerId: string): void {
180
+ this.sdk['groupManager'].removeMember(this.group.id, peerId);
181
+ }
182
+ }
183
+
184
+ // ============================================================
185
+ // Internal message envelope (serialized inside encrypted payload)
186
+ // ============================================================
187
+
188
+ interface MessageEnvelope {
189
+ id: string;
190
+ senderId: string;
191
+ recipientId: string;
192
+ payload: number[]; // serialized Uint8Array
193
+ timestamp: number;
194
+ urgency: MessageUrgency;
195
+ expiry?: number;
196
+ /** Group message metadata, if present. */
197
+ group?: {
198
+ groupId: string;
199
+ senderId: string;
200
+ };
201
+ /** Ratchet header for pairwise sessions. */
202
+ ratchetHeader?: {
203
+ dhPublicKey: number[];
204
+ previousChainLength: number;
205
+ messageNumber: number;
206
+ };
207
+ }
208
+
209
+ // ============================================================
210
+ // Handshake envelope (serialized inside HANDSHAKE packets)
211
+ // ============================================================
212
+
213
+ interface HandshakeEnvelope {
214
+ type: 'x3dh_init' | 'x3dh_response' | 'prekey_bundle';
215
+ senderId: string;
216
+ preKeyBundle?: number[]; // serialized PreKeyBundle
217
+ ephemeralPublicKey?: number[];
218
+ identityKey?: number[];
219
+ }
220
+
221
+ // ============================================================
222
+ // Presence tracking
223
+ // ============================================================
224
+
225
+ interface PeerPresenceRecord {
226
+ peerId: string;
227
+ status: PresenceStatus;
228
+ lastSeen: number;
229
+ }
230
+
231
+ // ============================================================
232
+ // MeshWhisper — Main SDK Class
233
+ // ============================================================
234
+
235
+ /**
236
+ * MeshWhisper is the primary API surface for the serverless P2P E2EE
237
+ * messaging SDK. Instantiate via `MeshWhisper.init(config)`, then use
238
+ * the returned instance (also accessible via `MeshWhisper.instance`)
239
+ * for all messaging operations.
240
+ *
241
+ * Static convenience methods delegate to the singleton instance.
242
+ */
243
+ export class MeshWhisper {
244
+ // --- Singleton ---
245
+ private static _instance: MeshWhisper | null = null;
246
+
247
+ // --- Configuration ---
248
+ private readonly config: MeshWhisperConfig;
249
+
250
+ // --- Subsystem instances ---
251
+ private readonly identity: LocalIdentity;
252
+ private readonly namespaceManager: NamespaceManager;
253
+ private readonly peerCache: PeerIdentityCache;
254
+ private readonly permissionManager: PermissionManager;
255
+ private readonly negotiator: BearerNegotiator;
256
+ private readonly router: SocialGraphRouter;
257
+ private readonly relayStore: RelayStore;
258
+ private readonly relayManager: StoreAndForwardManager;
259
+ private readonly reciprocityLedger: RelayLedger;
260
+ private readonly groupManager: GroupManager;
261
+ private readonly chaffGenerator: ChaffGenerator;
262
+ private readonly entropyChallenger: EntropyChallenger;
263
+ private readonly zkReputation: ZKRelayReputation;
264
+ private cluster: DeviceCluster | null = null;
265
+
266
+ // --- Transports ---
267
+ private readonly wsTransport: MWTransport;
268
+ private readonly localTransport: MWTransport;
269
+ private readonly p2pTransport: PlatformP2PTransport;
270
+
271
+ // --- Node/relay transport ---
272
+ private nodeTransport: MWTransport | null = null;
273
+
274
+ // --- Pre-key pair storage (required for X3DH responder side) ---
275
+ private signedPreKeyPair: KeyPair | null = null;
276
+
277
+ // --- Persistence ---
278
+ private readonly storage: StorageBackend | null;
279
+
280
+ // --- Session state ---
281
+ private readonly sessions: Map<string, RatchetState> = new Map();
282
+ private readonly peerPreKeyBundles: Map<string, PreKeyBundle> = new Map();
283
+ private readonly pendingHandshakes: Map<string, { resolve: () => void }> = new Map();
284
+
285
+ // --- Deduplication ---
286
+ /** Rolling set of seen message IDs to prevent duplicates. */
287
+ private readonly seenMessageIds: Map<string, number> = new Map(); // id → timestamp
288
+
289
+ // --- Presence ---
290
+ private readonly presenceRecords: Map<string, PeerPresenceRecord> = new Map();
291
+
292
+ // --- Event handlers ---
293
+ private onMessageHandler: ((message: Message) => void) | null = null;
294
+ private onPresenceHandler: ((peerId: string, status: PresenceStatus) => void) | null = null;
295
+ private readonly transportChangedHandlers: Set<(event: TransportChangedEvent) => void> = new Set();
296
+
297
+ // --- Lifecycle ---
298
+ private running = false;
299
+ private ephemeralRotationTimer: ReturnType<typeof setInterval> | null = null;
300
+
301
+ // ================================================================
302
+ // Constructor (private — use MeshWhisper.init())
303
+ // ================================================================
304
+
305
+ private constructor(
306
+ config: MeshWhisperConfig,
307
+ identity: LocalIdentity,
308
+ storage: StorageBackend | null,
309
+ wsTransport: MWTransport,
310
+ localTransport: MWTransport,
311
+ nodeTransport: MWTransport,
312
+ ) {
313
+ this.config = config;
314
+ this.storage = storage;
315
+
316
+ // --- Identity ---
317
+ this.identity = identity;
318
+ const developerKeyBytes = config.developerKey
319
+ ? base64ToUint8Array(config.developerKey)
320
+ : randomBytes(32); // random key for dev/single-tenant use
321
+ const namespaceSalt = randomBytes(32);
322
+ this.namespaceManager = new NamespaceManager({
323
+ appBundleId: config.namespace,
324
+ developerPublicKey: developerKeyBytes,
325
+ salt: namespaceSalt,
326
+ });
327
+ this.peerCache = new PeerIdentityCache();
328
+
329
+ // --- Permissions ---
330
+ this.permissionManager = new PermissionManager(config.permissionModel ?? 'open');
331
+
332
+ // --- Transports ---
333
+ this.wsTransport = wsTransport;
334
+ this.localTransport = localTransport;
335
+ this.nodeTransport = nodeTransport;
336
+ this.p2pTransport = new PlatformP2PTransport(config.namespace);
337
+
338
+ this.negotiator = new BearerNegotiator([
339
+ this.p2pTransport,
340
+ this.localTransport,
341
+ this.nodeTransport,
342
+ this.wsTransport,
343
+ ]);
344
+
345
+ // --- Routing ---
346
+ const localPeerId = this.getLocalPeerId();
347
+ const proximityTable = new PeerProximityTable();
348
+ this.router = new SocialGraphRouter(localPeerId, proximityTable);
349
+
350
+ // --- Relay (store-and-forward) ---
351
+ const storeTTL = config.config?.storeTTL ?? 72;
352
+ this.relayStore = new RelayStore({ defaultTTLHours: storeTTL });
353
+ this.relayManager = new StoreAndForwardManager(this.relayStore);
354
+
355
+ // --- Reciprocity ---
356
+ this.reciprocityLedger = new RelayLedger();
357
+ this.reciprocityLedger.registerDevice(localPeerId);
358
+
359
+ // --- Groups ---
360
+ this.groupManager = new GroupManager(localPeerId);
361
+
362
+ // --- Chaff ---
363
+ const chaffRate = config.config?.chaffRate ?? 'normal';
364
+ this.chaffGenerator = new ChaffGenerator({ rate: chaffRate });
365
+
366
+ // --- Sybil resistance ---
367
+ this.entropyChallenger = new EntropyChallenger();
368
+ this.zkReputation = new ZKRelayReputation(localPeerId);
369
+
370
+ // --- Cluster (optional) ---
371
+ if (config.config?.clusterEnabled !== false) {
372
+ this.cluster = new DeviceCluster(
373
+ this.identity.getPublicKey(),
374
+ localPeerId,
375
+ );
376
+ }
377
+
378
+ // --- Event handlers from config ---
379
+ this.onMessageHandler = config.onMessage ?? null;
380
+ this.onPresenceHandler = config.onPresence ?? null;
381
+ }
382
+
383
+ // ================================================================
384
+ // Initialization — static entry point
385
+ // ================================================================
386
+
387
+ /**
388
+ * Initialize the MeshWhisper SDK with the given configuration.
389
+ *
390
+ * ```ts
391
+ * const mw = await MeshWhisper.init({
392
+ * namespace: "com.example.fitnessapp",
393
+ * developerKey: "base64-encoded-public-key",
394
+ * permissionModel: "mutual",
395
+ * onMessage: (message) => { ... },
396
+ * onPresence: (peer, status) => { ... },
397
+ * config: {
398
+ * relayWillingness: "auto",
399
+ * chaffRate: "normal",
400
+ * storeTTL: 72,
401
+ * clusterEnabled: true,
402
+ * },
403
+ * });
404
+ * ```
405
+ */
406
+ static async init(config: MeshWhisperConfig): Promise<MeshWhisper> {
407
+ if (MeshWhisper._instance) {
408
+ await MeshWhisper._instance.shutdown();
409
+ }
410
+
411
+ // ---- Environment detection ----
412
+ const isBrowser =
413
+ typeof window !== 'undefined' && typeof indexedDB !== 'undefined';
414
+
415
+ // ---- Storage ----
416
+ let storage: StorageBackend | null = config.storage ?? null;
417
+ if (!storage && isBrowser) {
418
+ const { IDBStorage } = await import('../persistence/idb-storage.js');
419
+ storage = new IDBStorage(config.namespace);
420
+ }
421
+
422
+ // ---- Identity ----
423
+ // Loaded from storage so the peer ID stays stable across page reloads /
424
+ // process restarts. Falls back to a fresh ephemeral identity when storage
425
+ // is unavailable (development / anonymous use).
426
+ let identity: LocalIdentity;
427
+ if (storage) {
428
+ const savedKey = await storage.get('identity');
429
+ if (savedKey) {
430
+ identity = LocalIdentity.fromPrivateKey(
431
+ hexToUint8Array(savedKey),
432
+ );
433
+ } else {
434
+ identity = LocalIdentity.create();
435
+ await storage.set('identity', uint8ArrayToHex(identity.getEdPrivateKey()));
436
+ }
437
+ } else {
438
+ identity = LocalIdentity.create();
439
+ }
440
+
441
+ // ---- Relay URL ----
442
+ const nodeConfig = config.node ?? 'mesh';
443
+ const nodeUrls = Array.isArray(nodeConfig) ? nodeConfig : [nodeConfig];
444
+ const primaryNodeUrl = nodeUrls[0];
445
+
446
+ // ---- Transports (platform-appropriate) ----
447
+ let wsTransport: MWTransport;
448
+ let localTransport: MWTransport;
449
+ let nodeTransport: MWTransport;
450
+
451
+ // Declare instance early so the transport closures can capture it by
452
+ // reference. The getDestHashes callback is only ever invoked after
453
+ // instance.start() is awaited below, so the assignment is safe.
454
+ let instance!: MeshWhisper;
455
+ const getDestHashes = (): string[] => instance.getCurrentDestHashes();
456
+
457
+ if (isBrowser) {
458
+ // In a browser/PWA: relay via BrowserTransport, stub out Node.js-only
459
+ // transports (WebSocketTransport for P2P, LocalTransport for LAN).
460
+ const [{ NoOpTransport }, { BrowserTransport }] = await Promise.all([
461
+ import('../transport/noop/index.js'),
462
+ import('../transport/browser/index.js'),
463
+ ]);
464
+ wsTransport = new NoOpTransport('internet');
465
+ localTransport = new NoOpTransport('local_net');
466
+ nodeTransport = new BrowserTransport(primaryNodeUrl, getDestHashes, config.push);
467
+ } else {
468
+ // In Node.js: full transport stack.
469
+ const [{ WebSocketTransport }, { LocalTransport }, { NodeTransport }] =
470
+ await Promise.all([
471
+ import('../transport/websocket/index.js'),
472
+ import('../transport/local/index.js'),
473
+ import('../transport/node/index.js'),
474
+ ]);
475
+ const deviceId = randomBytes(16);
476
+ wsTransport = new WebSocketTransport();
477
+ localTransport = new LocalTransport(deviceId);
478
+ nodeTransport = new NodeTransport(primaryNodeUrl, getDestHashes, config.push);
479
+ }
480
+
481
+ instance = new MeshWhisper(
482
+ config,
483
+ identity,
484
+ storage,
485
+ wsTransport,
486
+ localTransport,
487
+ nodeTransport,
488
+ );
489
+ MeshWhisper._instance = instance;
490
+ await instance.start();
491
+ return instance;
492
+ }
493
+
494
+ /**
495
+ * Returns the active MeshWhisper instance, or throws if not initialized.
496
+ */
497
+ static get instance(): MeshWhisper {
498
+ if (!MeshWhisper._instance) {
499
+ throw new Error(
500
+ 'MeshWhisper has not been initialized. Call MeshWhisper.init() first.',
501
+ );
502
+ }
503
+ return MeshWhisper._instance;
504
+ }
505
+
506
+ // ================================================================
507
+ // Lifecycle
508
+ // ================================================================
509
+
510
+ private async start(): Promise<void> {
511
+ if (this.running) return;
512
+ this.running = true;
513
+
514
+ // --- Restore persisted state ---
515
+ if (this.storage) {
516
+ await this.loadPersistedState();
517
+
518
+ // If we have contacts but no sessions, the session state was lost
519
+ // (storage wipe, new device with same identity key). Re-initiate X3DH
520
+ // with every contact we have a saved prekey bundle for.
521
+ if (this.sessions.size === 0 && this.permissionManager.getContacts().length > 0) {
522
+ this.reinitiateSessionsOnStartup().catch(() => {});
523
+ }
524
+ }
525
+
526
+ // Wire up the unified receive handler across all transports
527
+ this.negotiator.onReceive(
528
+ (packet: Packet, source: string, bearer: BearerType) => {
529
+ this.handleIncomingPacket(packet, source, bearer);
530
+ },
531
+ );
532
+
533
+ // Generate pre-key bundle on startup so the private keys are available
534
+ // when an X3DH handshake arrives. Store the signed pre-key pair here.
535
+ const edKeyPair = {
536
+ publicKey: this.identity.getEdPublicKey(),
537
+ privateKey: this.identity['edPrivateKey'] as Uint8Array,
538
+ };
539
+ const { signedPreKeyPair } = generatePreKeyBundle(edKeyPair);
540
+ this.signedPreKeyPair = signedPreKeyPair;
541
+
542
+ // Start transports (best-effort; some may not be available)
543
+ const startResults = await Promise.allSettled([
544
+ this.wsTransport.start(),
545
+ this.localTransport.start(),
546
+ this.p2pTransport.start(),
547
+ this.nodeTransport?.start() ?? Promise.resolve(),
548
+ ]);
549
+
550
+ // Emit transport availability events (node transport doesn't map to a distinct bearer type)
551
+ const transportTypes: BearerType[] = ['internet', 'local_net', 'platform_p2p', 'internet'];
552
+ for (let i = 0; i < startResults.length; i++) {
553
+ const available = startResults[i].status === 'fulfilled';
554
+ for (const handler of this.transportChangedHandlers) {
555
+ try {
556
+ handler({ type: transportTypes[i], available });
557
+ } catch {
558
+ // Swallow handler errors
559
+ }
560
+ }
561
+ }
562
+
563
+ // Start chaff generator — wire output into the negotiator
564
+ this.chaffGenerator.onChaffGenerated((packet: Packet) => {
565
+ this.negotiator.broadcast(packet).catch(() => {
566
+ // Best effort for chaff
567
+ });
568
+ });
569
+ this.chaffGenerator.start();
570
+
571
+ // Start relay store pruning
572
+ this.relayStore.startPruneInterval();
573
+
574
+ // Start device cluster
575
+ if (this.cluster) {
576
+ const localDevice: ClusterDevice = {
577
+ deviceId: this.getLocalPeerId(),
578
+ clusterKey: this.cluster.getClusterKey(),
579
+ capabilities: await this.negotiator.probeAvailability(),
580
+ isPrimary: false,
581
+ lastSync: Date.now(),
582
+ };
583
+ this.cluster.addDevice(localDevice);
584
+ this.cluster.start();
585
+ }
586
+
587
+ // Rotate ephemeral sender ID every 10 minutes
588
+ this.ephemeralRotationTimer = setInterval(() => {
589
+ this.identity.rotateEphemeralId();
590
+ }, 10 * 60 * 1000);
591
+ if (typeof this.ephemeralRotationTimer === 'object' && 'unref' in this.ephemeralRotationTimer) {
592
+ (this.ephemeralRotationTimer as NodeJS.Timeout).unref();
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Shut down the SDK, stopping all transports, timers, and subsystems.
598
+ * After calling `shutdown()`, you must call `MeshWhisper.init()` again
599
+ * to resume operation.
600
+ */
601
+ async shutdown(): Promise<void> {
602
+ if (!this.running) return;
603
+ this.running = false;
604
+
605
+ // Persist state before stopping (sessions are also saved incrementally,
606
+ // but contacts and peers are only saved here and on mutations)
607
+ if (this.storage) {
608
+ await this.persistContacts();
609
+ await this.persistPeers();
610
+ await this.persistSeenIds();
611
+ }
612
+
613
+ // Stop chaff
614
+ this.chaffGenerator.stop();
615
+
616
+ // Stop relay pruning
617
+ this.relayStore.stopPruneInterval();
618
+
619
+ // Stop cluster
620
+ if (this.cluster) {
621
+ this.cluster.stop();
622
+ }
623
+
624
+ // Stop ephemeral rotation
625
+ if (this.ephemeralRotationTimer) {
626
+ clearInterval(this.ephemeralRotationTimer);
627
+ this.ephemeralRotationTimer = null;
628
+ }
629
+
630
+ // Stop transports
631
+ await Promise.allSettled([
632
+ this.wsTransport.stop(),
633
+ this.localTransport.stop(),
634
+ this.p2pTransport.stop(),
635
+ ]);
636
+
637
+ MeshWhisper._instance = null;
638
+ }
639
+
640
+ // ================================================================
641
+ // Public API — Messaging
642
+ // ================================================================
643
+
644
+ /**
645
+ * Send an encrypted message to a recipient.
646
+ *
647
+ * If no session exists with the recipient, an X3DH handshake is
648
+ * automatically initiated. Messages are compressed, encrypted via
649
+ * the Double Ratchet, assembled into a packet, and routed through
650
+ * the best available transport.
651
+ *
652
+ * ```ts
653
+ * await MeshWhisper.send(recipientId, payload, {
654
+ * urgency: "normal",
655
+ * expiry: 3600,
656
+ * });
657
+ * ```
658
+ */
659
+ static async send(
660
+ recipientId: string,
661
+ payload: Uint8Array,
662
+ options?: SendOptions,
663
+ ): Promise<void> {
664
+ return MeshWhisper.instance.sendMessage(recipientId, payload, options);
665
+ }
666
+
667
+ async sendMessage(
668
+ recipientId: string,
669
+ payload: Uint8Array,
670
+ options?: SendOptions,
671
+ ): Promise<void> {
672
+ this.assertRunning();
673
+
674
+ // Check permissions
675
+ const canSend = await this.permissionManager.canSendTo(recipientId);
676
+ if (!canSend) {
677
+ throw new Error(`Permission denied: cannot send to ${recipientId}`);
678
+ }
679
+
680
+ // Ensure we have a ratchet session with this peer
681
+ await this.ensureSession(recipientId);
682
+
683
+ const session = this.sessions.get(recipientId);
684
+ if (!session) {
685
+ throw new Error(`Failed to establish session with ${recipientId}`);
686
+ }
687
+
688
+ // Build message envelope
689
+ const messageId = generateMessageId();
690
+ const envelope: MessageEnvelope = {
691
+ id: messageId,
692
+ senderId: this.getLocalPeerId(),
693
+ recipientId,
694
+ payload: Array.from(payload),
695
+ timestamp: Date.now(),
696
+ urgency: options?.urgency ?? 'normal',
697
+ expiry: options?.expiry,
698
+ };
699
+
700
+ // Serialize, compress, encrypt
701
+ const envelopeBytes = new TextEncoder().encode(JSON.stringify(envelope));
702
+ const compressed = compressPayload(envelopeBytes);
703
+ const { state: newState, header, ciphertext } = ratchetEncrypt(session, compressed);
704
+ this.sessions.set(recipientId, newState);
705
+
706
+ // Embed ratchet header into the ciphertext for the receiver
707
+ const headerBytes = serializeRatchetHeader(header);
708
+ const fullPayload = concat(headerBytes, ciphertext);
709
+
710
+ // Build packet
711
+ const recipientPublicKey = this.peerCache.getPeerPublicKey(recipientId);
712
+ if (!recipientPublicKey) {
713
+ throw new Error(`No public key for recipient ${recipientId}`);
714
+ }
715
+ const destHash = deriveDestHash(recipientPublicKey, getCurrentEpochHour());
716
+ const senderEphId = this.identity.generateEphemeralId();
717
+ const packet = createDataPacket(destHash, senderEphId, fullPayload);
718
+
719
+ // Camouflage with chaff
720
+ const burst = this.chaffGenerator.camouflageRealMessage(packet);
721
+
722
+ // Route and send each packet in the burst
723
+ for (const p of burst) {
724
+ await this.routeAndSend(p, recipientId);
725
+ }
726
+
727
+ // Persist the outbound message (skip internal control messages)
728
+ const isControl = isControlPayload(payload);
729
+ if (!isControl) {
730
+ await this.saveMessage({
731
+ id: messageId,
732
+ conversationId: recipientId,
733
+ senderId: this.getLocalPeerId(),
734
+ recipientId,
735
+ payload: Array.from(payload),
736
+ timestamp: envelope.timestamp,
737
+ direction: 'outbound',
738
+ status: 'sent',
739
+ });
740
+ }
741
+ }
742
+
743
+ // ================================================================
744
+ // Public API — Media
745
+ // ================================================================
746
+
747
+ /**
748
+ * Send media to a recipient using the two-part flow:
749
+ * 1. Encrypt locally with a random AES-256-GCM key.
750
+ * 2. Upload the ciphertext to the Node (or a custom handler).
751
+ * 3. Send the URL + key through the normal encrypted message channel.
752
+ *
753
+ * The Node never receives the decryption key.
754
+ *
755
+ * ```ts
756
+ * await MeshWhisper.sendMedia(recipientId, imageBytes, { mimeType: 'image/jpeg' });
757
+ * ```
758
+ */
759
+ static async sendMedia(
760
+ recipientId: string,
761
+ data: Uint8Array,
762
+ options?: MediaSendOptions,
763
+ ): Promise<void> {
764
+ return MeshWhisper.instance.sendMediaMessage(recipientId, data, options);
765
+ }
766
+
767
+ async sendMediaMessage(
768
+ recipientId: string,
769
+ data: Uint8Array,
770
+ options?: MediaSendOptions,
771
+ ): Promise<void> {
772
+ this.assertRunning();
773
+
774
+ // 1. Encrypt the media locally
775
+ const mediaKey = randomBytes(32);
776
+ const { ciphertext, nonce, tag } = encrypt(data, mediaKey);
777
+ const encryptedBlob = concat(nonce, tag, ciphertext);
778
+
779
+ // 2. Upload — use custom handler if provided, else POST to Node
780
+ let url: string;
781
+ if (options?.upload) {
782
+ url = await options.upload(encryptedBlob);
783
+ } else {
784
+ url = await this.uploadMediaToNode(encryptedBlob);
785
+ }
786
+
787
+ // 3. Send pointer message through normal encrypted channel
788
+ const mediaMsg: MediaMessage = {
789
+ url,
790
+ key: uint8ArrayToBase64(mediaKey),
791
+ ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
792
+ };
793
+ const pointer = new TextEncoder().encode(
794
+ JSON.stringify({ __mw_media: true, ...mediaMsg }),
795
+ );
796
+ await this.sendMessage(recipientId, pointer, options);
797
+ }
798
+
799
+ /**
800
+ * Download and decrypt a media message received via `onMessage`.
801
+ * Detects messages produced by `sendMedia` automatically.
802
+ *
803
+ * ```ts
804
+ * const bytes = await MeshWhisper.downloadMedia(message);
805
+ * ```
806
+ */
807
+ static async downloadMedia(message: Message): Promise<Uint8Array | null> {
808
+ return MeshWhisper.instance.downloadMediaMessage(message);
809
+ }
810
+
811
+ async downloadMediaMessage(message: Message): Promise<Uint8Array | null> {
812
+ let parsed: { __mw_media?: boolean; url?: string; key?: string; mimeType?: string };
813
+ try {
814
+ const text = new TextDecoder().decode(new Uint8Array(message.payload));
815
+ parsed = JSON.parse(text);
816
+ } catch {
817
+ return null;
818
+ }
819
+ if (!parsed.__mw_media || !parsed.url || !parsed.key) return null;
820
+
821
+ const mediaKey = base64ToUint8Array(parsed.key);
822
+
823
+ // Fetch encrypted blob
824
+ const response = await fetch(parsed.url);
825
+ if (!response.ok) throw new Error(`Media fetch failed: ${response.status}`);
826
+ const blob = new Uint8Array(await response.arrayBuffer());
827
+
828
+ // Unpack nonce (12) + tag (16) + ciphertext
829
+ const nonce = blob.slice(0, 12);
830
+ const tag = blob.slice(12, 28);
831
+ const ciphertext = blob.slice(28);
832
+ return decrypt({ nonce, tag, ciphertext }, mediaKey);
833
+ }
834
+
835
+ private async uploadMediaToNode(encryptedBlob: Uint8Array): Promise<string> {
836
+ const nodeConfig = this.config.node ?? 'mesh';
837
+ const nodeUrl = Array.isArray(nodeConfig) ? nodeConfig[0] : nodeConfig;
838
+
839
+ // Convert WebSocket URL to HTTP URL
840
+ const httpUrl = nodeUrl === 'mesh'
841
+ ? 'https://relay.meshwhisper.io/media'
842
+ : nodeUrl.replace(/^wss?:\/\//, (m) => m === 'wss://' ? 'https://' : 'http://') + '/media';
843
+
844
+ const response = await fetch(httpUrl, {
845
+ method: 'POST',
846
+ headers: { 'Content-Type': 'application/octet-stream' },
847
+ body: encryptedBlob.buffer as ArrayBuffer,
848
+ });
849
+ if (!response.ok) {
850
+ throw new Error(`Media upload failed: ${response.status}`);
851
+ }
852
+ const json = await response.json() as { url?: string };
853
+ if (!json.url) throw new Error('Media upload: Node returned no URL');
854
+ return json.url;
855
+ }
856
+
857
+ // ================================================================
858
+ // Public API — Groups
859
+ // ================================================================
860
+
861
+ /**
862
+ * Create a new group.
863
+ *
864
+ * ```ts
865
+ * const group = MeshWhisper.createGroup({
866
+ * name: "Team Chat",
867
+ * members: [id1, id2, id3],
868
+ * permissionModel: "open",
869
+ * });
870
+ * group.send(payload);
871
+ * ```
872
+ */
873
+ static createGroup(options: CreateGroupOptions): GroupHandle {
874
+ return MeshWhisper.instance.createGroupInstance(options);
875
+ }
876
+
877
+ createGroupInstance(options: CreateGroupOptions): GroupHandle {
878
+ this.assertRunning();
879
+
880
+ const group = this.groupManager.createGroup(
881
+ options.name,
882
+ options.members,
883
+ options.permissionModel ?? 'open',
884
+ );
885
+
886
+ return new GroupHandle(group, this);
887
+ }
888
+
889
+ /**
890
+ * Retrieve a group handle by ID.
891
+ * Returns null if the group is not found.
892
+ */
893
+ getGroup(groupId: string): GroupHandle | null {
894
+ const group = this.groupManager.getGroup(groupId);
895
+ if (!group) return null;
896
+ return new GroupHandle(group, this);
897
+ }
898
+
899
+ /**
900
+ * List all groups the local peer is participating in.
901
+ */
902
+ getGroups(): GroupHandle[] {
903
+ return this.groupManager.getGroups().map(g => new GroupHandle(g, this));
904
+ }
905
+
906
+ /**
907
+ * Send a message to all members of a group.
908
+ * @internal — use GroupHandle.send() instead.
909
+ */
910
+ async sendToGroup(groupId: string, payload: Uint8Array): Promise<void> {
911
+ this.assertRunning();
912
+
913
+ const { ciphertext, senderId } = this.groupManager.encryptForGroup(groupId, payload);
914
+ const targets = this.groupManager.routeGroupMessage(groupId, ciphertext, senderId);
915
+
916
+ const sendPromises = targets.map(async (target) => {
917
+ try {
918
+ const recipientPublicKey = this.peerCache.getPeerPublicKey(target.peerId);
919
+ if (!recipientPublicKey) return;
920
+
921
+ const destHash = deriveDestHash(recipientPublicKey, getCurrentEpochHour());
922
+ const senderEphId = this.identity.generateEphemeralId();
923
+ const packet = createDataPacket(destHash, senderEphId, target.data);
924
+ await this.routeAndSend(packet, target.peerId);
925
+ } catch {
926
+ // Best effort per member
927
+ }
928
+ });
929
+
930
+ await Promise.allSettled(sendPromises);
931
+ }
932
+
933
+ // ================================================================
934
+ // Public API — Contacts & Identity
935
+ // ================================================================
936
+
937
+ /**
938
+ * Generate a QR code payload for first contact. Contains the
939
+ * local peer's identity public key and pre-key bundle so a
940
+ * scanner can initiate an X3DH handshake.
941
+ *
942
+ * Returns a base64-encoded string suitable for embedding in a QR code.
943
+ */
944
+ static generateContactQR(): string {
945
+ return MeshWhisper.instance.generateContactQRInstance();
946
+ }
947
+
948
+ generateContactQRInstance(): string {
949
+ this.assertRunning();
950
+
951
+ const edKeyPair = {
952
+ publicKey: this.identity.getEdPublicKey(),
953
+ privateKey: this.identity['edPrivateKey'] as Uint8Array,
954
+ };
955
+ const { bundle, signedPreKeyPair } = generatePreKeyBundle(edKeyPair);
956
+ this.signedPreKeyPair = signedPreKeyPair;
957
+ const serialized = serializePreKeyBundle(bundle);
958
+
959
+ // Encode as: peerId-length(2) + peerId-bytes + bundle-bytes
960
+ const peerIdBytes = new TextEncoder().encode(this.getLocalPeerId());
961
+ const lenBuf = new Uint8Array(2);
962
+ new DataView(lenBuf.buffer).setUint16(0, peerIdBytes.length, false);
963
+
964
+ const qrPayload = concat(lenBuf, peerIdBytes, serialized);
965
+ return uint8ArrayToBase64(qrPayload);
966
+ }
967
+
968
+ /**
969
+ * Accept a contact from scanned QR data. Parses the peer's
970
+ * pre-key bundle and initiates an X3DH handshake.
971
+ */
972
+ static async acceptContact(scannedQRData: string): Promise<void> {
973
+ return MeshWhisper.instance.acceptContactInstance(scannedQRData);
974
+ }
975
+
976
+ async acceptContactInstance(scannedQRData: string): Promise<void> {
977
+ this.assertRunning();
978
+
979
+ const raw = base64ToUint8Array(scannedQRData);
980
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
981
+ const peerIdLen = view.getUint16(0, false);
982
+ const peerIdBytes = raw.slice(2, 2 + peerIdLen);
983
+ const peerId = new TextDecoder().decode(peerIdBytes);
984
+ const bundleBytes = raw.slice(2 + peerIdLen);
985
+ const bundle = deserializePreKeyBundle(bundleBytes);
986
+
987
+ // Store and persist the peer's prekey bundle
988
+ this.peerPreKeyBundles.set(peerId, bundle);
989
+ this.persistPreKeyBundle(peerId, bundle).catch(() => {});
990
+
991
+ // For mutual model, register the contact request
992
+ if (this.config.permissionModel === 'mutual') {
993
+ this.permissionManager.confirmMutualContact(peerId);
994
+ } else {
995
+ this.permissionManager.addContact(peerId);
996
+ }
997
+
998
+ // Cache the peer's public key (convert Ed25519 identity key to X25519)
999
+ // The bundle identity key is Ed25519; the signed pre-key is X25519.
1000
+ // For dest_hash we need the X25519 key derived from the identity key.
1001
+ // Store the signed pre-key as the peer's X25519 public key for routing.
1002
+ this.peerCache.addPeer(peerId, bundle.signedPreKey);
1003
+
1004
+ // Initiate X3DH session
1005
+ await this.initiateHandshake(peerId, bundle);
1006
+ }
1007
+
1008
+ /**
1009
+ * Introduce two contacts to each other.
1010
+ * Both peers must already be contacts of the local peer.
1011
+ */
1012
+ static async introduceContacts(peerA: string, peerB: string): Promise<void> {
1013
+ return MeshWhisper.instance.introduceContactsInstance(peerA, peerB);
1014
+ }
1015
+
1016
+ async introduceContactsInstance(peerA: string, peerB: string): Promise<void> {
1017
+ this.assertRunning();
1018
+
1019
+ if (!this.permissionManager.isContact(peerA)) {
1020
+ throw new Error(`${peerA} is not a contact`);
1021
+ }
1022
+ if (!this.permissionManager.isContact(peerB)) {
1023
+ throw new Error(`${peerB} is not a contact`);
1024
+ }
1025
+
1026
+ // Send each peer the other's pre-key bundle so they can establish
1027
+ // a session directly.
1028
+ const pubKeyA = this.peerCache.getPeerPublicKey(peerA);
1029
+ const pubKeyB = this.peerCache.getPeerPublicKey(peerB);
1030
+
1031
+ if (pubKeyA && pubKeyB) {
1032
+ // Craft introduction messages containing the peer's public key
1033
+ const introForA = new TextEncoder().encode(JSON.stringify({
1034
+ type: 'introduction',
1035
+ introducedPeerId: peerB,
1036
+ introducedPublicKey: Array.from(pubKeyB),
1037
+ introducedBy: this.getLocalPeerId(),
1038
+ }));
1039
+ const introForB = new TextEncoder().encode(JSON.stringify({
1040
+ type: 'introduction',
1041
+ introducedPeerId: peerA,
1042
+ introducedPublicKey: Array.from(pubKeyA),
1043
+ introducedBy: this.getLocalPeerId(),
1044
+ }));
1045
+
1046
+ await Promise.allSettled([
1047
+ this.sendMessage(peerA, introForA),
1048
+ this.sendMessage(peerB, introForB),
1049
+ ]);
1050
+ }
1051
+ }
1052
+
1053
+ // ================================================================
1054
+ // Public API — Presence
1055
+ // ================================================================
1056
+
1057
+ /**
1058
+ * Get the current presence status of a peer.
1059
+ */
1060
+ static getPresence(peerId: string): PresenceStatus {
1061
+ return MeshWhisper.instance.getPresenceInstance(peerId);
1062
+ }
1063
+
1064
+ getPresenceInstance(peerId: string): PresenceStatus {
1065
+ const record = this.presenceRecords.get(peerId);
1066
+ if (!record) return 'unknown';
1067
+
1068
+ const elapsed = Date.now() - record.lastSeen;
1069
+ if (elapsed < 5 * 60 * 1000) return 'online';
1070
+ if (elapsed < 60 * 60 * 1000) return 'recently_seen';
1071
+ return 'offline';
1072
+ }
1073
+
1074
+ // ================================================================
1075
+ // Public API — Transport Events
1076
+ // ================================================================
1077
+
1078
+ /**
1079
+ * Register a callback that fires when transport availability changes.
1080
+ */
1081
+ static onTransportChanged(handler: (event: TransportChangedEvent) => void): void {
1082
+ MeshWhisper.instance.onTransportChangedInstance(handler);
1083
+ }
1084
+
1085
+ onTransportChangedInstance(handler: (event: TransportChangedEvent) => void): void {
1086
+ this.transportChangedHandlers.add(handler);
1087
+ }
1088
+
1089
+ /**
1090
+ * Unregister a transport-changed handler.
1091
+ */
1092
+ offTransportChanged(handler: (event: TransportChangedEvent) => void): void {
1093
+ this.transportChangedHandlers.delete(handler);
1094
+ }
1095
+
1096
+ // ================================================================
1097
+ // Public API — Accessors
1098
+ // ================================================================
1099
+
1100
+ /**
1101
+ * Returns the local peer's public identity string (hex-encoded X25519 public key).
1102
+ */
1103
+ getLocalPeerId(): string {
1104
+ return uint8ArrayToHex(this.identity.getPublicKey());
1105
+ }
1106
+
1107
+ /**
1108
+ * Returns the device's current and previous epoch-hour destination hashes
1109
+ * as hex strings. Used by NodeTransport to register with the Node.
1110
+ */
1111
+ private getCurrentDestHashes(): string[] {
1112
+ const xPub = this.identity.getPublicKey();
1113
+ const hour = getCurrentEpochHour();
1114
+ return [
1115
+ uint8ArrayToHex(deriveDestHash(xPub, hour)),
1116
+ uint8ArrayToHex(deriveDestHash(xPub, hour - 1)),
1117
+ ];
1118
+ }
1119
+
1120
+ /**
1121
+ * Returns the local peer's X25519 public key bytes.
1122
+ */
1123
+ getPublicKey(): Uint8Array {
1124
+ return this.identity.getPublicKey();
1125
+ }
1126
+
1127
+ /**
1128
+ * Returns the namespace ID for this SDK instance.
1129
+ */
1130
+ getNamespaceId(): Uint8Array {
1131
+ return this.namespaceManager.getNamespaceId();
1132
+ }
1133
+
1134
+ /**
1135
+ * Whether the SDK is currently running.
1136
+ */
1137
+ isRunning(): boolean {
1138
+ return this.running;
1139
+ }
1140
+
1141
+ /**
1142
+ * Register a native P2P bridge (e.g., Apple Multipeer Connectivity
1143
+ * or Google Nearby Connections). Call before `init()` for the bridge
1144
+ * to be available at startup.
1145
+ */
1146
+ static registerPlatformBridge(bridge: PlatformP2PBridge): void {
1147
+ registerPlatformBridge(bridge);
1148
+ }
1149
+
1150
+ // ================================================================
1151
+ // Internal — Incoming Packet Handling
1152
+ // ================================================================
1153
+
1154
+ private handleIncomingPacket(
1155
+ packet: Packet,
1156
+ source: string,
1157
+ bearer: BearerType,
1158
+ ): void {
1159
+ // Update presence for the source
1160
+ this.updatePresence(source, 'online');
1161
+
1162
+ // Track reciprocity for relayed packets
1163
+ this.reciprocityLedger.recordPeerRelayedForUs(source, packet.encryptedPayload.length);
1164
+
1165
+ // Drop chaff packets silently
1166
+ if (packet.flags === PacketFlags.CHAFF) {
1167
+ return;
1168
+ }
1169
+
1170
+ // Check if the packet is destined for us
1171
+ const isForUs = this.identity.matchesDestHash(packet.destHash);
1172
+
1173
+ if (isForUs) {
1174
+ this.processLocalPacket(packet, source);
1175
+ } else {
1176
+ // Not for us — consider relaying
1177
+ this.maybeRelay(packet, source);
1178
+ }
1179
+ }
1180
+
1181
+ private processLocalPacket(packet: Packet, source: string): void {
1182
+ switch (packet.flags) {
1183
+ case PacketFlags.HANDSHAKE:
1184
+ this.handleHandshakePacket(packet, source);
1185
+ break;
1186
+
1187
+ case PacketFlags.DATA:
1188
+ this.handleDataPacket(packet, source);
1189
+ break;
1190
+
1191
+ case PacketFlags.ACK:
1192
+ // ACKs are currently handled implicitly by the ratchet
1193
+ break;
1194
+
1195
+ case PacketFlags.ROUTE_REQUEST:
1196
+ this.handleRouteRequestPacket(packet, source);
1197
+ break;
1198
+
1199
+ case PacketFlags.ROUTE_OFFER:
1200
+ this.handleRouteOfferPacket(packet, source);
1201
+ break;
1202
+
1203
+ default:
1204
+ // Unknown flag — drop silently
1205
+ break;
1206
+ }
1207
+ }
1208
+
1209
+ private handleDataPacket(packet: Packet, source: string): void {
1210
+ try {
1211
+ const { header, ciphertextBody } = deserializeRatchetHeader(packet.encryptedPayload);
1212
+
1213
+ let decrypted: Uint8Array | null = null;
1214
+ let matchedPeerId: string | null = null;
1215
+ let newState: RatchetState | null = null;
1216
+
1217
+ for (const [peerId, session] of this.sessions) {
1218
+ try {
1219
+ const result = ratchetDecrypt(session, header, ciphertextBody);
1220
+ newState = result.state;
1221
+ decrypted = result.plaintext;
1222
+ matchedPeerId = peerId;
1223
+ break;
1224
+ } catch {
1225
+ continue;
1226
+ }
1227
+ }
1228
+
1229
+ if (!decrypted || !matchedPeerId || !newState) return;
1230
+
1231
+ // Persist the advanced ratchet state immediately
1232
+ this.sessions.set(matchedPeerId, newState);
1233
+ this.persistSession(matchedPeerId, newState).catch(() => {});
1234
+
1235
+ // Decompress and parse envelope
1236
+ const decompressed = decompressPayload(decrypted);
1237
+ const envelope: MessageEnvelope = JSON.parse(new TextDecoder().decode(decompressed));
1238
+
1239
+ // --- Deduplication ---
1240
+ if (this.seenMessageIds.has(envelope.id)) return;
1241
+ this.seenMessageIds.set(envelope.id, envelope.timestamp);
1242
+ this.pruneSeenIds();
1243
+
1244
+ // --- Internal control messages (delivery receipts etc.) ---
1245
+ const payloadBytes = new Uint8Array(envelope.payload);
1246
+ const ctrl = tryParseControl(payloadBytes);
1247
+ if (ctrl) {
1248
+ this.handleControlMessage(ctrl, matchedPeerId);
1249
+ return;
1250
+ }
1251
+
1252
+ // --- Expiry check ---
1253
+ if (envelope.expiry) {
1254
+ if (Date.now() > envelope.timestamp + envelope.expiry * 1000) return;
1255
+ }
1256
+
1257
+ const message: Message = {
1258
+ id: envelope.id,
1259
+ senderId: envelope.senderId,
1260
+ recipientId: envelope.recipientId,
1261
+ payload: payloadBytes,
1262
+ timestamp: envelope.timestamp,
1263
+ urgency: envelope.urgency,
1264
+ expiry: envelope.expiry,
1265
+ };
1266
+
1267
+ // --- Persist inbound message ---
1268
+ this.saveMessage({
1269
+ id: message.id,
1270
+ conversationId: matchedPeerId,
1271
+ senderId: message.senderId,
1272
+ recipientId: message.recipientId,
1273
+ payload: Array.from(payloadBytes),
1274
+ timestamp: message.timestamp,
1275
+ direction: 'inbound',
1276
+ status: 'delivered',
1277
+ }).catch(() => {});
1278
+
1279
+ // --- Surface to app ---
1280
+ if (this.onMessageHandler) {
1281
+ this.onMessageHandler(message);
1282
+ }
1283
+
1284
+ // --- Send DELIVERED receipt ---
1285
+ this.sendControl(matchedPeerId, { __mw_ctrl: 'delivered', messageId: message.id });
1286
+
1287
+ // --- Cluster sync ---
1288
+ if (this.cluster) {
1289
+ this.cluster.syncManager.queueForSync({
1290
+ messageId: message.id,
1291
+ encryptedPayload: packet.encryptedPayload,
1292
+ receivedAt: Date.now(),
1293
+ receivedBy: this.getLocalPeerId(),
1294
+ syncedTo: new Set([this.getLocalPeerId()]),
1295
+ });
1296
+ }
1297
+ } catch {
1298
+ // Malformed packet — drop silently
1299
+ }
1300
+ }
1301
+
1302
+ private handleHandshakePacket(packet: Packet, source: string): void {
1303
+ try {
1304
+ const envelopeStr = new TextDecoder().decode(packet.encryptedPayload);
1305
+ const envelope: HandshakeEnvelope = JSON.parse(envelopeStr);
1306
+
1307
+ switch (envelope.type) {
1308
+ case 'prekey_bundle': {
1309
+ if (envelope.preKeyBundle) {
1310
+ const bundleBytes = new Uint8Array(envelope.preKeyBundle);
1311
+ const bundle = deserializePreKeyBundle(bundleBytes);
1312
+ this.peerPreKeyBundles.set(envelope.senderId, bundle);
1313
+ this.peerCache.addPeer(envelope.senderId, bundle.signedPreKey);
1314
+ this.persistPreKeyBundle(envelope.senderId, bundle).catch(() => {});
1315
+ }
1316
+ break;
1317
+ }
1318
+
1319
+ case 'x3dh_init': {
1320
+ // Responder side: complete the X3DH exchange
1321
+ if (envelope.ephemeralPublicKey && envelope.identityKey) {
1322
+ this.completeIncomingHandshake(envelope);
1323
+ }
1324
+ break;
1325
+ }
1326
+
1327
+ case 'x3dh_response': {
1328
+ // Resolve any pending handshake
1329
+ const pending = this.pendingHandshakes.get(envelope.senderId);
1330
+ if (pending) {
1331
+ pending.resolve();
1332
+ this.pendingHandshakes.delete(envelope.senderId);
1333
+ }
1334
+ break;
1335
+ }
1336
+ }
1337
+ } catch {
1338
+ // Malformed handshake — drop
1339
+ }
1340
+ }
1341
+
1342
+ private handleRouteRequestPacket(packet: Packet, source: string): void {
1343
+ // Attempt to parse the route request from the payload
1344
+ try {
1345
+ const requestStr = new TextDecoder().decode(packet.encryptedPayload);
1346
+ const request = JSON.parse(requestStr);
1347
+ const offer = this.router.handleRouteRequest(
1348
+ {
1349
+ destHash: new Uint8Array(request.destHash),
1350
+ requestId: new Uint8Array(request.requestId),
1351
+ ttl: request.ttl,
1352
+ timestamp: request.timestamp,
1353
+ },
1354
+ source,
1355
+ );
1356
+
1357
+ if (offer) {
1358
+ // Send route offer back to the requesting peer
1359
+ const offerPayload = new TextEncoder().encode(JSON.stringify(offer));
1360
+ const destHash = packet.destHash; // Reply to the requester's hash
1361
+ const senderEphId = this.identity.generateEphemeralId();
1362
+ const offerPacket: Packet = {
1363
+ version: PROTOCOL_VERSION,
1364
+ flags: PacketFlags.ROUTE_OFFER,
1365
+ destHash,
1366
+ senderEphemeralId: senderEphId,
1367
+ ttl: packet.ttl,
1368
+ payloadLength: offerPayload.length,
1369
+ encryptedPayload: offerPayload,
1370
+ };
1371
+
1372
+ this.negotiator.send(offerPacket, source).catch(() => {
1373
+ // Best effort
1374
+ });
1375
+ }
1376
+ } catch {
1377
+ // Malformed route request — drop
1378
+ }
1379
+ }
1380
+
1381
+ private handleRouteOfferPacket(packet: Packet, _source: string): void {
1382
+ try {
1383
+ const offerStr = new TextDecoder().decode(packet.encryptedPayload);
1384
+ const offer = JSON.parse(offerStr);
1385
+ this.router.handleRouteOffer({
1386
+ requestId: new Uint8Array(offer.requestId),
1387
+ hopCount: offer.hopCount,
1388
+ estimatedLatency: offer.estimatedLatency,
1389
+ offeredBy: offer.offeredBy,
1390
+ });
1391
+ } catch {
1392
+ // Malformed route offer — drop
1393
+ }
1394
+ }
1395
+
1396
+ // ================================================================
1397
+ // Internal — Relay Logic
1398
+ // ================================================================
1399
+
1400
+ private maybeRelay(packet: Packet, source: string): void {
1401
+ // Check if the router thinks we should relay
1402
+ if (!this.router.shouldRelay(packet)) {
1403
+ return;
1404
+ }
1405
+
1406
+ // Check reciprocity: should we relay for this peer?
1407
+ if (!this.reciprocityLedger.shouldRelay(source)) {
1408
+ return;
1409
+ }
1410
+
1411
+ // Decrement TTL and forward
1412
+ const forwarded = this.router.decrementTTL(packet);
1413
+
1414
+ // Track reciprocity: we are relaying for this peer
1415
+ this.reciprocityLedger.recordRelayedForPeer(source, packet.encryptedPayload.length);
1416
+
1417
+ // Try to find a next hop
1418
+ const nextHop = this.router.getNextHop(packet.destHash);
1419
+ if (nextHop) {
1420
+ this.negotiator.send(forwarded, nextHop).catch(() => {
1421
+ // If direct send fails, try store-and-forward
1422
+ this.relayManager.storeForDelivery(
1423
+ packet.destHash,
1424
+ packet.encryptedPayload,
1425
+ this.config.config?.storeTTL ?? 72,
1426
+ );
1427
+ });
1428
+ } else {
1429
+ // No route known — store for later delivery
1430
+ this.relayManager.storeForDelivery(
1431
+ packet.destHash,
1432
+ packet.encryptedPayload,
1433
+ this.config.config?.storeTTL ?? 72,
1434
+ );
1435
+ }
1436
+ }
1437
+
1438
+ // ================================================================
1439
+ // Internal — Session Management (X3DH + Double Ratchet)
1440
+ // ================================================================
1441
+
1442
+ private async ensureSession(recipientId: string): Promise<void> {
1443
+ if (this.sessions.has(recipientId)) {
1444
+ return;
1445
+ }
1446
+
1447
+ // Check if we have a pre-key bundle for this peer
1448
+ const bundle = this.peerPreKeyBundles.get(recipientId);
1449
+ if (bundle) {
1450
+ await this.initiateHandshake(recipientId, bundle);
1451
+ return;
1452
+ }
1453
+
1454
+ // No bundle available — request one via route discovery
1455
+ throw new Error(
1456
+ `No pre-key bundle for ${recipientId}. ` +
1457
+ `Use acceptContact() or acceptContact(scannedQR) to establish first contact.`,
1458
+ );
1459
+ }
1460
+
1461
+ private async initiateHandshake(
1462
+ peerId: string,
1463
+ bundle: PreKeyBundle,
1464
+ ): Promise<void> {
1465
+ // Perform X3DH as the initiator
1466
+ const aliceIdentity = {
1467
+ publicKey: this.identity.getEdPublicKey(),
1468
+ privateKey: this.identity['edPrivateKey'] as Uint8Array,
1469
+ };
1470
+
1471
+ const result = initiateKeyExchange(aliceIdentity, bundle);
1472
+
1473
+ // Initialize the Double Ratchet as sender
1474
+ const ratchetState = initSender(result.sharedSecret, bundle.signedPreKey);
1475
+ this.sessions.set(peerId, ratchetState);
1476
+ this.persistSession(peerId, ratchetState).catch(() => {});
1477
+
1478
+ // Send handshake packet to the peer so they can complete their side
1479
+ const handshakeEnvelope: HandshakeEnvelope = {
1480
+ type: 'x3dh_init',
1481
+ senderId: this.getLocalPeerId(),
1482
+ ephemeralPublicKey: Array.from(result.ephemeralPublicKey),
1483
+ identityKey: Array.from(this.identity.getEdPublicKey()),
1484
+ };
1485
+
1486
+ const envelopeBytes = new TextEncoder().encode(JSON.stringify(handshakeEnvelope));
1487
+ const recipientPublicKey = this.peerCache.getPeerPublicKey(peerId);
1488
+ if (!recipientPublicKey) return;
1489
+
1490
+ const destHash = deriveDestHash(recipientPublicKey, getCurrentEpochHour());
1491
+ const senderEphId = this.identity.generateEphemeralId();
1492
+ const handshakePacket = createHandshakePacket(destHash, senderEphId, envelopeBytes);
1493
+
1494
+ await this.routeAndSend(handshakePacket, peerId);
1495
+ }
1496
+
1497
+ private completeIncomingHandshake(envelope: HandshakeEnvelope): void {
1498
+ if (!envelope.ephemeralPublicKey || !envelope.identityKey) return;
1499
+
1500
+ const aliceEphemeralKey = new Uint8Array(envelope.ephemeralPublicKey);
1501
+ const aliceIdentityKey = new Uint8Array(envelope.identityKey);
1502
+
1503
+ // Use the stored signed pre-key pair. Falls back to the X25519 identity key
1504
+ // pair only if no bundle has been generated yet (should not happen in practice
1505
+ // since generatePreKeyBundle is called on start()).
1506
+ const bobSignedPreKey = this.signedPreKeyPair ?? {
1507
+ publicKey: this.identity.getPublicKey(),
1508
+ privateKey: this.identity.getPrivateKey(),
1509
+ };
1510
+
1511
+ const bobIdentity = {
1512
+ publicKey: this.identity.getEdPublicKey(),
1513
+ privateKey: this.identity['edPrivateKey'] as Uint8Array,
1514
+ };
1515
+
1516
+ const sharedSecret = completeKeyExchange(
1517
+ bobIdentity,
1518
+ bobSignedPreKey,
1519
+ null, // No one-time pre-key for now
1520
+ aliceIdentityKey,
1521
+ aliceEphemeralKey,
1522
+ );
1523
+
1524
+ // Initialize the Double Ratchet as receiver
1525
+ const ratchetState = initReceiver(sharedSecret, bobSignedPreKey);
1526
+ this.sessions.set(envelope.senderId, ratchetState);
1527
+ this.persistSession(envelope.senderId, ratchetState).catch(() => {});
1528
+
1529
+ // Cache and persist the peer's public key
1530
+ this.peerCache.addPeer(envelope.senderId, new Uint8Array(envelope.identityKey));
1531
+ this.storage?.set(
1532
+ `peers/${envelope.senderId}`,
1533
+ uint8ArrayToHex(new Uint8Array(envelope.identityKey)),
1534
+ ).catch(() => {});
1535
+
1536
+ // Register the peer as a contact
1537
+ this.permissionManager.addContact(envelope.senderId);
1538
+ this.storage?.set('contacts', JSON.stringify(this.permissionManager.getContacts()))
1539
+ .catch(() => {});
1540
+
1541
+ // Send handshake response
1542
+ const response: HandshakeEnvelope = {
1543
+ type: 'x3dh_response',
1544
+ senderId: this.getLocalPeerId(),
1545
+ };
1546
+
1547
+ const responseBytes = new TextEncoder().encode(JSON.stringify(response));
1548
+ const peerPublicKey = this.peerCache.getPeerPublicKey(envelope.senderId);
1549
+ if (!peerPublicKey) return;
1550
+
1551
+ const destHash = deriveDestHash(peerPublicKey, getCurrentEpochHour());
1552
+ const senderEphId = this.identity.generateEphemeralId();
1553
+ const responsePacket = createHandshakePacket(destHash, senderEphId, responseBytes);
1554
+
1555
+ this.routeAndSend(responsePacket, envelope.senderId).catch(() => {
1556
+ // Best effort
1557
+ });
1558
+ }
1559
+
1560
+ // ================================================================
1561
+ // Internal — Routing & Sending
1562
+ // ================================================================
1563
+
1564
+ private async routeAndSend(packet: Packet, recipientId: string): Promise<void> {
1565
+ // Try direct send via the negotiator
1566
+ try {
1567
+ await this.negotiator.send(packet, recipientId);
1568
+ return;
1569
+ } catch {
1570
+ // Direct send failed — try routing
1571
+ }
1572
+
1573
+ // Try next hop via the social graph router
1574
+ const nextHop = this.router.getNextHop(packet.destHash);
1575
+ if (nextHop) {
1576
+ try {
1577
+ await this.negotiator.send(packet, nextHop);
1578
+ return;
1579
+ } catch {
1580
+ // Next hop also failed
1581
+ }
1582
+ }
1583
+
1584
+ // Fall back to store-and-forward
1585
+ this.relayManager.storeForDelivery(
1586
+ packet.destHash,
1587
+ packet.encryptedPayload,
1588
+ this.config.config?.storeTTL ?? 72,
1589
+ );
1590
+ }
1591
+
1592
+ // ================================================================
1593
+ // Internal — Presence
1594
+ // ================================================================
1595
+
1596
+ private updatePresence(peerId: string, status: PresenceStatus): void {
1597
+ const now = Date.now();
1598
+ const previous = this.presenceRecords.get(peerId);
1599
+ const previousStatus = previous?.status ?? 'unknown';
1600
+
1601
+ this.presenceRecords.set(peerId, {
1602
+ peerId,
1603
+ status,
1604
+ lastSeen: now,
1605
+ });
1606
+
1607
+ // Deliver any stored blobs for this peer coming online
1608
+ const peerPublicKey = this.peerCache.getPeerPublicKey(peerId);
1609
+ if (peerPublicKey) {
1610
+ const destHash = deriveDestHash(peerPublicKey, getCurrentEpochHour());
1611
+ const storedBlobs = this.relayManager.deliverStored(destHash);
1612
+ for (const blob of storedBlobs) {
1613
+ // Re-inject stored blobs as incoming packets
1614
+ const storedPacket: Packet = {
1615
+ version: PROTOCOL_VERSION,
1616
+ flags: PacketFlags.DATA,
1617
+ destHash: blob.destHash,
1618
+ senderEphemeralId: new Uint8Array(16), // Unknown sender eph ID
1619
+ ttl: 0,
1620
+ payloadLength: blob.encryptedPayload.length,
1621
+ encryptedPayload: blob.encryptedPayload,
1622
+ };
1623
+
1624
+ this.negotiator.send(storedPacket, peerId).catch(() => {
1625
+ // Best effort
1626
+ });
1627
+ }
1628
+ }
1629
+
1630
+ // Notify the app if status changed
1631
+ if (status !== previousStatus && this.onPresenceHandler) {
1632
+ this.onPresenceHandler(peerId, status);
1633
+ }
1634
+ }
1635
+
1636
+ // ================================================================
1637
+ // Public API — Message History
1638
+ // ================================================================
1639
+
1640
+ /**
1641
+ * Returns stored messages for a conversation, most recent last.
1642
+ * Requires a `storage` backend in the config; returns [] without one.
1643
+ *
1644
+ * ```ts
1645
+ * const messages = await MeshWhisper.getMessages(peerId, { limit: 50 });
1646
+ * ```
1647
+ */
1648
+ static async getMessages(
1649
+ peerId: string,
1650
+ options?: { limit?: number; before?: number },
1651
+ ): Promise<StoredMessage[]> {
1652
+ return MeshWhisper.instance.getMessagesInstance(peerId, options);
1653
+ }
1654
+
1655
+ async getMessagesInstance(
1656
+ peerId: string,
1657
+ options?: { limit?: number; before?: number },
1658
+ ): Promise<StoredMessage[]> {
1659
+ if (!this.storage) return [];
1660
+ const raw = await this.storage.get(`messages/${peerId}`);
1661
+ if (!raw) return [];
1662
+ let messages: StoredMessage[] = JSON.parse(raw);
1663
+ if (options?.before !== undefined) {
1664
+ messages = messages.filter((m) => m.timestamp < options.before!);
1665
+ }
1666
+ if (options?.limit !== undefined) {
1667
+ messages = messages.slice(-options.limit);
1668
+ }
1669
+ return messages;
1670
+ }
1671
+
1672
+ /**
1673
+ * Mark a received message as read and send a read receipt to the sender.
1674
+ * Call this when the user views the conversation.
1675
+ *
1676
+ * ```ts
1677
+ * await MeshWhisper.markRead(message.id, message.senderId);
1678
+ * ```
1679
+ */
1680
+ static async markRead(messageId: string, peerId: string): Promise<void> {
1681
+ return MeshWhisper.instance.markReadInstance(messageId, peerId);
1682
+ }
1683
+
1684
+ async markReadInstance(messageId: string, peerId: string): Promise<void> {
1685
+ this.assertRunning();
1686
+ await this.updateMessageStatus(messageId, peerId, 'read');
1687
+ this.sendControl(peerId, { __mw_ctrl: 'read', messageId });
1688
+ }
1689
+
1690
+ // ================================================================
1691
+ // Internal — Persistence helpers
1692
+ // ================================================================
1693
+
1694
+ private async loadPersistedState(): Promise<void> {
1695
+ if (!this.storage) return;
1696
+
1697
+ // Sessions
1698
+ const sessionKeys = await this.storage.keys('sessions/');
1699
+ for (const key of sessionKeys) {
1700
+ const data = await this.storage.get(key);
1701
+ if (!data) continue;
1702
+ const peerId = key.replace(/^sessions\//, '');
1703
+ try {
1704
+ this.sessions.set(peerId, deserializeRatchetState(data));
1705
+ } catch {
1706
+ // Corrupted session — skip and let re-establishment handle it
1707
+ }
1708
+ }
1709
+
1710
+ // Peer public keys
1711
+ const peerKeys = await this.storage.keys('peers/');
1712
+ for (const key of peerKeys) {
1713
+ const hex = await this.storage.get(key);
1714
+ if (!hex) continue;
1715
+ const peerId = key.replace(/^peers\//, '');
1716
+ this.peerCache.addPeer(peerId, hexToUint8Array(hex));
1717
+ }
1718
+
1719
+ // Peer prekey bundles (needed for session re-establishment)
1720
+ const prekeyKeys = await this.storage.keys('prekeys/');
1721
+ for (const key of prekeyKeys) {
1722
+ const b64 = await this.storage.get(key);
1723
+ if (!b64) continue;
1724
+ const peerId = key.replace(/^prekeys\//, '');
1725
+ try {
1726
+ const bundle = deserializePreKeyBundle(base64ToUint8Array(b64));
1727
+ this.peerPreKeyBundles.set(peerId, bundle);
1728
+ } catch {
1729
+ // Corrupted bundle — skip
1730
+ }
1731
+ }
1732
+
1733
+ // Contacts
1734
+ const contactsRaw = await this.storage.get('contacts');
1735
+ if (contactsRaw) {
1736
+ this.permissionManager.loadContacts(JSON.parse(contactsRaw) as string[]);
1737
+ }
1738
+
1739
+ // Seen message IDs (deduplication — rolling 24h window)
1740
+ const seenRaw = await this.storage.get('seen_ids');
1741
+ if (seenRaw) {
1742
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1743
+ const entries = JSON.parse(seenRaw) as Array<[string, number]>;
1744
+ for (const [id, ts] of entries) {
1745
+ if (ts > cutoff) this.seenMessageIds.set(id, ts);
1746
+ }
1747
+ }
1748
+ }
1749
+
1750
+ /**
1751
+ * Re-initiates X3DH sessions with all contacts whose prekey bundles are saved.
1752
+ * Called on startup when session state is missing but contacts exist — handles
1753
+ * storage wipe and new-device-with-same-identity-key scenarios.
1754
+ */
1755
+ private async reinitiateSessionsOnStartup(): Promise<void> {
1756
+ const contacts = this.permissionManager.getContacts();
1757
+ for (const contactId of contacts) {
1758
+ const bundle = this.peerPreKeyBundles.get(contactId);
1759
+ if (!bundle) continue; // no saved bundle — will recover when they next send us a message
1760
+ try {
1761
+ await this.initiateHandshake(contactId, bundle);
1762
+ } catch {
1763
+ // Best effort — peer may be offline, session will establish when they reconnect
1764
+ }
1765
+ }
1766
+ }
1767
+
1768
+ private async persistSession(peerId: string, state: RatchetState): Promise<void> {
1769
+ await this.storage?.set(`sessions/${peerId}`, serializeRatchetState(state));
1770
+ }
1771
+
1772
+ private async persistPreKeyBundle(peerId: string, bundle: PreKeyBundle): Promise<void> {
1773
+ await this.storage?.set(
1774
+ `prekeys/${peerId}`,
1775
+ uint8ArrayToBase64(serializePreKeyBundle(bundle)),
1776
+ );
1777
+ }
1778
+
1779
+ private async persistContacts(): Promise<void> {
1780
+ await this.storage?.set('contacts', JSON.stringify(this.permissionManager.getContacts()));
1781
+ }
1782
+
1783
+ private async persistPeers(): Promise<void> {
1784
+ if (!this.storage) return;
1785
+ // Save any peers not yet written (incremental saves happen in completeIncomingHandshake)
1786
+ // This is a belt-and-suspenders flush on shutdown
1787
+ for (const [peerId, pubKey] of (this.peerCache as any).peers as Map<string, Uint8Array>) {
1788
+ await this.storage.set(`peers/${peerId}`, uint8ArrayToHex(pubKey));
1789
+ }
1790
+ }
1791
+
1792
+ private async persistSeenIds(): Promise<void> {
1793
+ if (!this.storage) return;
1794
+ const entries = [...this.seenMessageIds.entries()];
1795
+ await this.storage.set('seen_ids', JSON.stringify(entries));
1796
+ }
1797
+
1798
+ private pruneSeenIds(): void {
1799
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1800
+ for (const [id, ts] of this.seenMessageIds) {
1801
+ if (ts < cutoff) this.seenMessageIds.delete(id);
1802
+ }
1803
+ }
1804
+
1805
+ private async saveMessage(message: StoredMessage): Promise<void> {
1806
+ if (!this.storage) return;
1807
+ const key = `messages/${message.conversationId}`;
1808
+ const raw = await this.storage.get(key);
1809
+ const messages: StoredMessage[] = raw ? JSON.parse(raw) : [];
1810
+ const existing = messages.findIndex((m) => m.id === message.id);
1811
+ if (existing >= 0) {
1812
+ messages[existing] = message; // update (e.g. status change)
1813
+ } else {
1814
+ messages.push(message);
1815
+ }
1816
+ await this.storage.set(key, JSON.stringify(messages));
1817
+ }
1818
+
1819
+ private async updateMessageStatus(
1820
+ messageId: string,
1821
+ conversationId: string,
1822
+ status: StoredMessage['status'],
1823
+ ): Promise<void> {
1824
+ if (!this.storage) return;
1825
+ const key = `messages/${conversationId}`;
1826
+ const raw = await this.storage.get(key);
1827
+ if (!raw) return;
1828
+ const messages: StoredMessage[] = JSON.parse(raw);
1829
+ const msg = messages.find((m) => m.id === messageId);
1830
+ if (!msg) return;
1831
+ msg.status = status;
1832
+ await this.storage.set(key, JSON.stringify(messages));
1833
+ if (this.config.onMessageStatus) {
1834
+ this.config.onMessageStatus(messageId, status);
1835
+ }
1836
+ }
1837
+
1838
+ // ================================================================
1839
+ // Internal — Control messages (delivery receipts, typing, etc.)
1840
+ // ================================================================
1841
+
1842
+ private sendControl(
1843
+ peerId: string,
1844
+ payload: Record<string, unknown>,
1845
+ ): void {
1846
+ const bytes = new TextEncoder().encode(JSON.stringify(payload));
1847
+ this.sendMessage(peerId, bytes, { urgency: 'background' }).catch(() => {});
1848
+ }
1849
+
1850
+ private handleControlMessage(
1851
+ ctrl: ControlMessage,
1852
+ fromPeerId: string,
1853
+ ): void {
1854
+ switch (ctrl.__mw_ctrl) {
1855
+ case 'delivered':
1856
+ if (ctrl.messageId) {
1857
+ this.updateMessageStatus(ctrl.messageId, fromPeerId, 'delivered').catch(() => {});
1858
+ }
1859
+ break;
1860
+ case 'read':
1861
+ if (ctrl.messageId) {
1862
+ this.updateMessageStatus(ctrl.messageId, fromPeerId, 'read').catch(() => {});
1863
+ }
1864
+ break;
1865
+ }
1866
+ }
1867
+
1868
+ // ================================================================
1869
+ // Internal — Assertions
1870
+ // ================================================================
1871
+
1872
+ private assertRunning(): void {
1873
+ if (!this.running) {
1874
+ throw new Error(
1875
+ 'MeshWhisper is not running. Call MeshWhisper.init() first.',
1876
+ );
1877
+ }
1878
+ }
1879
+ }
1880
+
1881
+ // ============================================================
1882
+ // Control message helpers
1883
+ // ============================================================
1884
+
1885
+ interface ControlMessage {
1886
+ __mw_ctrl: 'delivered' | 'read' | 'typing_start' | 'typing_stop';
1887
+ messageId?: string;
1888
+ }
1889
+
1890
+ function tryParseControl(payload: Uint8Array): ControlMessage | null {
1891
+ try {
1892
+ const text = new TextDecoder().decode(payload);
1893
+ if (!text.startsWith('{')) return null;
1894
+ const obj = JSON.parse(text) as Record<string, unknown>;
1895
+ if (typeof obj.__mw_ctrl !== 'string') return null;
1896
+ return obj as unknown as ControlMessage;
1897
+ } catch {
1898
+ return null;
1899
+ }
1900
+ }
1901
+
1902
+ function isControlPayload(payload: Uint8Array): boolean {
1903
+ return tryParseControl(payload) !== null;
1904
+ }
1905
+
1906
+ // ============================================================
1907
+ // Utility Functions
1908
+ // ============================================================
1909
+
1910
+ function uint8ArrayToHex(arr: Uint8Array): string {
1911
+ let hex = '';
1912
+ for (let i = 0; i < arr.length; i++) {
1913
+ hex += arr[i].toString(16).padStart(2, '0');
1914
+ }
1915
+ return hex;
1916
+ }
1917
+
1918
+ function hexToUint8Array(hex: string): Uint8Array {
1919
+ const len = hex.length >>> 1;
1920
+ const arr = new Uint8Array(len);
1921
+ for (let i = 0; i < len; i++) {
1922
+ arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
1923
+ }
1924
+ return arr;
1925
+ }
1926
+
1927
+ function uint8ArrayToBase64(arr: Uint8Array): string {
1928
+ // Works in both Node.js and browser environments
1929
+ if (typeof Buffer !== 'undefined') {
1930
+ return Buffer.from(arr).toString('base64');
1931
+ }
1932
+ let binary = '';
1933
+ for (let i = 0; i < arr.length; i++) {
1934
+ binary += String.fromCharCode(arr[i]);
1935
+ }
1936
+ return btoa(binary);
1937
+ }
1938
+
1939
+ function base64ToUint8Array(b64: string): Uint8Array {
1940
+ if (typeof Buffer !== 'undefined') {
1941
+ return new Uint8Array(Buffer.from(b64, 'base64'));
1942
+ }
1943
+ const binary = atob(b64);
1944
+ const bytes = new Uint8Array(binary.length);
1945
+ for (let i = 0; i < binary.length; i++) {
1946
+ bytes[i] = binary.charCodeAt(i);
1947
+ }
1948
+ return bytes;
1949
+ }
1950
+
1951
+ function generateMessageId(): string {
1952
+ const bytes = randomBytes(16);
1953
+ return uint8ArrayToHex(bytes);
1954
+ }
1955
+
1956
+ // ============================================================
1957
+ // Ratchet Header Serialization
1958
+ //
1959
+ // Wire format:
1960
+ // [32 bytes dhPublicKey] [4 bytes previousChainLength BE] [4 bytes messageNumber BE]
1961
+ // ============================================================
1962
+
1963
+ const RATCHET_HEADER_SIZE = 40;
1964
+
1965
+ function serializeRatchetHeader(header: RatchetHeader): Uint8Array {
1966
+ const buf = new Uint8Array(RATCHET_HEADER_SIZE);
1967
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
1968
+
1969
+ buf.set(header.dhPublicKey, 0);
1970
+ view.setUint32(32, header.previousChainLength, false);
1971
+ view.setUint32(36, header.messageNumber, false);
1972
+
1973
+ return buf;
1974
+ }
1975
+
1976
+ function deserializeRatchetHeader(
1977
+ data: Uint8Array,
1978
+ ): { header: RatchetHeader; ciphertextBody: Uint8Array } {
1979
+ if (data.length < RATCHET_HEADER_SIZE) {
1980
+ throw new Error('Data too short for ratchet header');
1981
+ }
1982
+
1983
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1984
+
1985
+ const header: RatchetHeader = {
1986
+ dhPublicKey: data.slice(0, 32),
1987
+ previousChainLength: view.getUint32(32, false),
1988
+ messageNumber: view.getUint32(36, false),
1989
+ };
1990
+
1991
+ const ciphertextBody = data.slice(RATCHET_HEADER_SIZE);
1992
+
1993
+ return { header, ciphertextBody };
1994
+ }