@zapo-js/fake-server 1.0.0 → 1.2.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 (73) hide show
  1. package/README.md +180 -33
  2. package/dist/api/FakeMobilePrimary.d.ts +55 -0
  3. package/dist/api/FakeMobilePrimary.js +106 -0
  4. package/dist/api/FakePairingDriver.js +1 -3
  5. package/dist/api/FakePeer.js +7 -1
  6. package/dist/api/FakeServerSession.d.ts +100 -0
  7. package/dist/api/FakeServerSession.js +355 -0
  8. package/dist/api/FakeWaServer.d.ts +145 -37
  9. package/dist/api/FakeWaServer.js +345 -270
  10. package/dist/api/ServerRegistries.d.ts +7 -0
  11. package/dist/api/ServerRegistries.js +20 -5
  12. package/dist/api/iq-handlers.d.ts +20 -1
  13. package/dist/api/iq-handlers.js +119 -1
  14. package/dist/bin.js +69 -21
  15. package/dist/esm/api/FakeMobilePrimary.js +102 -0
  16. package/dist/esm/api/FakePairingDriver.js +2 -4
  17. package/dist/esm/api/FakePeer.js +7 -1
  18. package/dist/esm/api/FakeServerSession.js +349 -0
  19. package/dist/esm/api/FakeWaServer.js +344 -270
  20. package/dist/esm/api/ServerRegistries.js +20 -5
  21. package/dist/esm/api/iq-handlers.js +119 -1
  22. package/dist/esm/bin.js +67 -19
  23. package/dist/esm/index.js +9 -1
  24. package/dist/esm/infra/WaFakeConnection.js +8 -22
  25. package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
  26. package/dist/esm/infra/WaFakeTcpServer.js +69 -0
  27. package/dist/esm/infra/WaFakeWsServer.js +2 -1
  28. package/dist/esm/infra/socket-adapters.js +79 -0
  29. package/dist/esm/protocol/auth/cert-chain.js +13 -2
  30. package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
  31. package/dist/esm/protocol/auth/pair-device.js +16 -1
  32. package/dist/esm/protocol/iq/companion-host.js +104 -0
  33. package/dist/esm/protocol/iq/link-code.js +90 -0
  34. package/dist/esm/protocol/iq/router.js +9 -3
  35. package/dist/esm/protocol/push/mobile-notification.js +97 -0
  36. package/dist/esm/state/fake-companion-host.js +158 -0
  37. package/dist/esm/transport/auth.js +7 -0
  38. package/dist/esm/transport/crypto.js +1 -1
  39. package/dist/esm/transport/util.js +2 -1
  40. package/dist/index.d.ts +19 -4
  41. package/dist/index.js +29 -1
  42. package/dist/infra/WaFakeConnection.d.ts +21 -3
  43. package/dist/infra/WaFakeConnection.js +8 -22
  44. package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
  45. package/dist/infra/WaFakeConnectionPipeline.js +38 -14
  46. package/dist/infra/WaFakeTcpServer.d.ts +29 -0
  47. package/dist/infra/WaFakeTcpServer.js +73 -0
  48. package/dist/infra/WaFakeWsServer.js +2 -1
  49. package/dist/infra/socket-adapters.d.ts +18 -0
  50. package/dist/infra/socket-adapters.js +83 -0
  51. package/dist/protocol/auth/cert-chain.d.ts +4 -0
  52. package/dist/protocol/auth/cert-chain.js +13 -2
  53. package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
  54. package/dist/protocol/auth/client-payload-validate.js +77 -1
  55. package/dist/protocol/auth/pair-device.d.ts +15 -1
  56. package/dist/protocol/auth/pair-device.js +16 -0
  57. package/dist/protocol/iq/companion-host.d.ts +52 -0
  58. package/dist/protocol/iq/companion-host.js +109 -0
  59. package/dist/protocol/iq/link-code.d.ts +46 -0
  60. package/dist/protocol/iq/link-code.js +95 -0
  61. package/dist/protocol/iq/router.d.ts +21 -2
  62. package/dist/protocol/iq/router.js +9 -3
  63. package/dist/protocol/push/mobile-notification.d.ts +58 -0
  64. package/dist/protocol/push/mobile-notification.js +102 -0
  65. package/dist/state/fake-companion-host.d.ts +73 -0
  66. package/dist/state/fake-companion-host.js +163 -0
  67. package/dist/transport/auth.d.ts +8 -0
  68. package/dist/transport/auth.js +8 -0
  69. package/dist/transport/crypto.d.ts +1 -1
  70. package/dist/transport/crypto.js +2 -1
  71. package/dist/transport/util.d.ts +2 -1
  72. package/dist/transport/util.js +3 -1
  73. package/package.json +6 -1
@@ -1,127 +1,133 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FakeWaServer = void 0;
3
+ exports.FakeWaServer = exports.FakeServerSession = void 0;
4
4
  const node_https_1 = require("node:https");
5
5
  const WaFakeConnectionPipeline_1 = require("../infra/WaFakeConnectionPipeline");
6
6
  const WaFakeMediaHttpsServer_1 = require("../infra/WaFakeMediaHttpsServer");
7
+ const WaFakeTcpServer_1 = require("../infra/WaFakeTcpServer");
7
8
  const WaFakeWsServer_1 = require("../infra/WaFakeWsServer");
8
9
  const cert_chain_1 = require("../protocol/auth/cert-chain");
10
+ const pair_device_1 = require("../protocol/auth/pair-device");
11
+ const link_code_1 = require("../protocol/iq/link-code");
9
12
  const router_1 = require("../protocol/iq/router");
13
+ const mobile_notification_1 = require("../protocol/push/mobile-notification");
10
14
  const fake_media_store_1 = require("../state/fake-media-store");
11
15
  const crypto_1 = require("../transport/crypto");
12
- const AppStateSyncManager_1 = require("./AppStateSyncManager");
13
16
  const FakePairingDriver_1 = require("./FakePairingDriver");
14
17
  const FakePeer_1 = require("./FakePeer");
15
- const iq_handlers_1 = require("./iq-handlers");
16
- const PreKeyDispenser_1 = require("./PreKeyDispenser");
18
+ const FakeServerSession_1 = require("./FakeServerSession");
17
19
  const Scenario_1 = require("./Scenario");
18
- const ServerRegistries_1 = require("./ServerRegistries");
20
+ const HOST_DOMAIN = 's.whatsapp.net';
21
+ const MOBILE_PRIMARY_PLATFORM = 'android';
22
+ /**
23
+ * Builds the mobile TCP listener when the option asks for one. Defaults its
24
+ * host to the WebSocket host so both listeners answer on the same interface.
25
+ */
26
+ function createTcpServer(options) {
27
+ if (!options.tcp) {
28
+ return null;
29
+ }
30
+ const config = options.tcp === true ? {} : options.tcp;
31
+ return new WaFakeTcpServer_1.WaFakeTcpServer({
32
+ host: config.host ?? options.host,
33
+ port: config.port
34
+ });
35
+ }
36
+ var FakeServerSession_2 = require("./FakeServerSession");
37
+ Object.defineProperty(exports, "FakeServerSession", { enumerable: true, get: function () { return FakeServerSession_2.FakeServerSession; } });
19
38
  class FakeWaServer {
20
39
  constructor(options = {}) {
21
- this.registries = new ServerRegistries_1.ServerRegistries();
22
- this.preKeyDispenser = new PreKeyDispenser_1.PreKeyDispenser();
23
- this.appStateSync = new AppStateSyncManager_1.AppStateSyncManager();
24
40
  this.pipelines = new Set();
25
- this.iqRouter = new router_1.WaFakeIqRouter();
26
- this.capturedStanzas = [];
27
- this.pendingIqExpectations = new Set();
28
- this.pendingStanzaExpectations = new Set();
41
+ this.pipelineSessions = new WeakMap();
29
42
  this.authenticatedListeners = new Set();
30
- this.inboundStanzaListeners = new Set();
31
- this.capturedStanzaListeners = new Set();
43
+ this.sessions = new Map();
44
+ this.globalIqHandlers = new Set();
32
45
  this.rootCa = null;
33
46
  this.serverStaticKeyPair = null;
34
47
  this.listenInfo = null;
35
- this.pipelineListener = null;
48
+ this.tcpListenInfo = null;
49
+ this.pendingCompanionOffers = new Map();
50
+ this.mobilePrimaryPipelines = new Map();
51
+ this.linkCodeSessions = new Map();
52
+ this.pipelineListeners = new Set();
36
53
  this.rejectMode = null;
37
54
  this.mediaStore = new fake_media_store_1.FakeMediaStore();
38
55
  this.mediaHttpsServer = new WaFakeMediaHttpsServer_1.WaFakeMediaHttpsServer();
39
56
  this.capturedMediaUploads = [];
40
57
  this.nextUploadCounter = 0;
41
58
  this.cachedMediaProxyAgent = null;
59
+ this.options = options;
42
60
  this.wsServer = new WaFakeWsServer_1.WaFakeWsServer(options);
43
61
  this.wsServer.onConnection((connection) => this.handleConnection(connection));
44
- (0, iq_handlers_1.registerDefaultIqHandlers)(this.iqRouter, this.buildIqHandlerDeps());
62
+ this.tcpServer = createTcpServer(options);
63
+ this.tcpServer?.onConnection((connection) => this.handleConnection(connection));
64
+ this.defaultSession = this.createSession('__default__');
45
65
  }
46
- buildIqHandlerDeps() {
47
- const reg = this.registries;
48
- const preKey = this.preKeyDispenser;
49
- const appState = this.appStateSync;
50
- return {
51
- get peerRegistry() {
52
- return reg.peerRegistry;
53
- },
54
- get groupRegistry() {
55
- return reg.groupRegistry;
56
- },
57
- get privacySettings() {
58
- return reg.privacySettings;
59
- },
60
- get blocklistJids() {
61
- return reg.blocklistJids;
62
- },
63
- get profilePicturesByJid() {
64
- return reg.profilePicturesByJid;
65
- },
66
- get businessProfilesByJid() {
67
- return reg.businessProfilesByJid;
68
- },
69
- get abPropsInput() {
70
- return reg.abPropsInput;
71
- },
72
- get issuedPrivacyTokens() {
73
- return reg.issuedPrivacyTokens;
74
- },
75
- get latestStatusText() {
76
- return reg.latestStatusText;
77
- },
78
- setLatestStatusText: (text) => {
79
- reg.latestStatusText = text;
80
- },
81
- lookupDeviceIdsForUser: (userJid) => reg.lookupDeviceIdsForUser(userJid),
82
- notifyGroupOp: (op) => reg.notifyGroupOp(op),
83
- mutatePrivacySettings: (category, value) => reg.mutatePrivacySettings(category, value),
84
- mutateBlocklist: (action, jid) => reg.mutateBlocklist(action, jid),
85
- notifyProfilePictureSet: (op) => reg.notifyProfilePictureSet(op),
86
- handleProfilePictureSet: (targetJid, newId) => reg.handleProfilePictureSet(targetJid, newId),
87
- notifyStatusSet: (text) => reg.notifyStatusSet(text),
88
- notifyLogout: () => reg.notifyLogout(),
89
- notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
90
- notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
91
- notifyPrivacySet: (change) => {
92
- for (const listener of reg.privacySetListeners) {
93
- try {
94
- listener(change);
95
- }
96
- catch (error) {
97
- void error;
98
- }
99
- }
100
- },
101
- notifyBlocklistChange: (change) => {
102
- for (const listener of reg.blocklistChangeListeners) {
103
- try {
104
- listener(change);
105
- }
106
- catch (error) {
107
- void error;
108
- }
109
- }
110
- },
111
- capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
112
- consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
113
- get appStateCollectionProviders() {
114
- return appState.appStateCollectionProviders;
115
- },
116
- requireMediaHttpsInfo: () => this.requireMediaHttpsInfo()
117
- };
66
+ /** State of the single default session (the only session for one client). */
67
+ get registries() {
68
+ return this.defaultSession.registries;
69
+ }
70
+ /** Companion-host account state of the default session (linked devices, key-index list). */
71
+ get companionHost() {
72
+ return this.defaultSession.companionHost;
73
+ }
74
+ get preKeyDispenser() {
75
+ return this.defaultSession.preKeyDispenser;
76
+ }
77
+ get appStateSync() {
78
+ return this.defaultSession.appStateSync;
79
+ }
80
+ /**
81
+ * Returns the isolated session for `id`, creating it on first use. Every
82
+ * session gets its own registries, prekey dispenser, app-state, IQ router,
83
+ * and stanza capture. Use with the `sessionKey` option to run several
84
+ * `WaClient`s against one server without them sharing any state.
85
+ */
86
+ session(id) {
87
+ const existing = this.sessions.get(id);
88
+ if (existing) {
89
+ return existing;
90
+ }
91
+ return this.createSession(id);
92
+ }
93
+ /** The session a connection is bound to (default before authentication). */
94
+ sessionFor(pipeline) {
95
+ return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
96
+ }
97
+ createSession(id) {
98
+ const session = new FakeServerSession_1.FakeServerSession(id, {
99
+ requireMediaHttpsInfo: () => this.requireMediaHttpsInfo(),
100
+ completeCompanionPairing: (input) => this.completeCompanionPairing(input),
101
+ relayLinkCodeStage: (iq, context) => this.handleLinkCodeStage(iq, context)
102
+ }, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
103
+ this.sessions.set(id, session);
104
+ // Server-level custom handlers apply to every session, present and future.
105
+ for (const handler of this.globalIqHandlers) {
106
+ handler.unregisterBySession.set(session, session.registerIqHandler(handler.matcher, handler.respond, handler.label));
107
+ }
108
+ return session;
118
109
  }
119
110
  registerIqHandler(matcher, respond, label) {
120
- const handler = { matcher, respond, label };
121
- return this.iqRouter.register(handler, { priority: 'high' });
111
+ const entry = {
112
+ matcher,
113
+ respond,
114
+ label,
115
+ unregisterBySession: new Map()
116
+ };
117
+ for (const session of this.sessions.values()) {
118
+ entry.unregisterBySession.set(session, session.registerIqHandler(matcher, respond, label));
119
+ }
120
+ this.globalIqHandlers.add(entry);
121
+ return () => {
122
+ for (const unregister of entry.unregisterBySession.values()) {
123
+ unregister();
124
+ }
125
+ entry.unregisterBySession.clear();
126
+ this.globalIqHandlers.delete(entry);
127
+ };
122
128
  }
123
129
  async routeIqForTest(iq) {
124
- return this.iqRouter.route(iq);
130
+ return this.defaultSession.routeIq(iq);
125
131
  }
126
132
  onOutboundGroupOp(listener) {
127
133
  return this.registries.onOutboundGroupOp(listener);
@@ -178,7 +184,7 @@ class FakeWaServer {
178
184
  return this.registries.createFakeGroup(input);
179
185
  }
180
186
  async triggerPreKeyUpload(pipeline, options = {}) {
181
- return this.preKeyDispenser.triggerPreKeyUpload(pipeline, options);
187
+ return this.sessionFor(pipeline).preKeyDispenser.triggerPreKeyUpload(pipeline, options);
182
188
  }
183
189
  awaitPreKeyBundle(timeoutMs = 15000) {
184
190
  return this.preKeyDispenser.awaitPreKeyBundle(timeoutMs);
@@ -208,7 +214,28 @@ class FakeWaServer {
208
214
  return this.appStateSync.provideAppStateCollection(name, provider);
209
215
  }
210
216
  async pushServerSyncNotification(pipeline, input) {
211
- return this.appStateSync.pushServerSyncNotification(pipeline, input);
217
+ return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
218
+ }
219
+ /**
220
+ * Pushes the account's device set to a mobile primary as `account_sync`.
221
+ * Defaults to what this session tracks - the primary itself plus every
222
+ * companion it linked - so passing a shorter list is how a test tells the
223
+ * primary that a device disappeared while it was offline.
224
+ */
225
+ async pushAccountSyncDevices(pipeline, options = {}) {
226
+ const session = this.sessionFor(pipeline);
227
+ const primary = session.companionHost.primary;
228
+ if (!primary && !options.devices) {
229
+ throw new Error('cannot push account_sync: this session has no mobile primary');
230
+ }
231
+ const devices = options.devices ?? [
232
+ { deviceJid: primary.jid, keyIndex: 0 },
233
+ ...session.companionHost.linkedCompanions().map((companion) => ({
234
+ deviceJid: companion.deviceJid,
235
+ keyIndex: companion.keyIndex
236
+ }))
237
+ ];
238
+ await pipeline.sendStanza((0, mobile_notification_1.buildAccountSyncDevicesNotification)({ devices }));
212
239
  }
213
240
  onAuthenticatedPipeline(listener) {
214
241
  this.authenticatedListeners.add(listener);
@@ -218,78 +245,24 @@ class FakeWaServer {
218
245
  configure(new Scenario_1.Scenario(this));
219
246
  }
220
247
  expectIq(matcher, options = {}) {
221
- const timeoutMs = options.timeoutMs ?? 2000;
222
- for (const captured of this.capturedStanzas) {
223
- if (matchesIq(captured, matcher)) {
224
- return Promise.resolve(captured);
225
- }
226
- }
227
- return new Promise((resolve, reject) => {
228
- const expectation = {
229
- matcher,
230
- resolve: (iq) => {
231
- this.pendingIqExpectations.delete(expectation);
232
- clearTimeout(expectation.timer);
233
- resolve(iq);
234
- },
235
- reject: (error) => {
236
- this.pendingIqExpectations.delete(expectation);
237
- clearTimeout(expectation.timer);
238
- reject(error);
239
- },
240
- timer: setTimeout(() => {
241
- this.pendingIqExpectations.delete(expectation);
242
- reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
243
- }, timeoutMs)
244
- };
245
- this.pendingIqExpectations.add(expectation);
246
- });
248
+ return this.defaultSession.expectIq(matcher, options);
247
249
  }
248
250
  capturedStanzaSnapshot() {
249
- return this.capturedStanzas.slice();
251
+ return this.defaultSession.capturedStanzaSnapshot();
250
252
  }
251
253
  /**
252
- * Subscribes to every stanza captured from the client side (the lib).
253
- * The listener is called synchronously for each new stanza as it
254
- * arrives, in addition to `capturedStanzas` being appended. Returns
255
- * an unsubscribe function. Useful for benches that need to count or
256
- * react to a stream of receipts/messages without paying the O(N²)
257
- * cost of polling `capturedStanzaSnapshot()` or queuing one
258
- * `expectStanza(...)` per iteration.
254
+ * Subscribes to every stanza captured from the client side (the lib) of
255
+ * the default session. The listener is called synchronously for each new
256
+ * stanza as it arrives. Returns an unsubscribe function. Useful for
257
+ * benches that need to count or react to a stream of receipts/messages
258
+ * without paying the O(N²) cost of polling `capturedStanzaSnapshot()` or
259
+ * queuing one `expectStanza(...)` per iteration.
259
260
  */
260
261
  onCapturedStanza(listener) {
261
- this.capturedStanzaListeners.add(listener);
262
- return () => {
263
- this.capturedStanzaListeners.delete(listener);
264
- };
262
+ return this.defaultSession.onCapturedStanza(listener);
265
263
  }
266
264
  expectStanza(matcher, options = {}) {
267
- const timeoutMs = options.timeoutMs ?? 2000;
268
- for (const captured of this.capturedStanzas) {
269
- if (matchesStanza(captured, matcher)) {
270
- return Promise.resolve(captured);
271
- }
272
- }
273
- return new Promise((resolve, reject) => {
274
- const expectation = {
275
- matcher,
276
- resolve: (node) => {
277
- this.pendingStanzaExpectations.delete(expectation);
278
- clearTimeout(expectation.timer);
279
- resolve(node);
280
- },
281
- reject: (error) => {
282
- this.pendingStanzaExpectations.delete(expectation);
283
- clearTimeout(expectation.timer);
284
- reject(error);
285
- },
286
- timer: setTimeout(() => {
287
- this.pendingStanzaExpectations.delete(expectation);
288
- reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
289
- }, timeoutMs)
290
- };
291
- this.pendingStanzaExpectations.add(expectation);
292
- });
265
+ return this.defaultSession.expectStanza(matcher, options);
293
266
  }
294
267
  async broadcastStanza(node) {
295
268
  const tasks = [];
@@ -351,8 +324,9 @@ class FakeWaServer {
351
324
  return this.capturedMediaUploads.slice();
352
325
  }
353
326
  async createFakePeer(options, pipeline) {
354
- const peer = await FakePeer_1.FakePeer.create(options, this.buildFakePeerDeps(pipeline));
355
- this.registries.registerPeer(peer);
327
+ const session = this.sessionFor(pipeline);
328
+ const peer = await FakePeer_1.FakePeer.create(options, this.buildFakePeerDeps(pipeline, session));
329
+ session.registries.registerPeer(peer);
356
330
  return peer;
357
331
  }
358
332
  async createFakePeerWithDevices(input, pipeline) {
@@ -365,6 +339,7 @@ class FakeWaServer {
365
339
  }
366
340
  const userPart = input.userJid.slice(0, atIdx);
367
341
  const server = input.userJid.slice(atIdx + 1);
342
+ const session = this.sessionFor(pipeline);
368
343
  const peers = [];
369
344
  for (const deviceId of input.deviceIds) {
370
345
  const deviceJid = deviceId === 0 ? input.userJid : `${userPart}:${deviceId}@${server}`;
@@ -372,36 +347,27 @@ class FakeWaServer {
372
347
  jid: deviceJid,
373
348
  displayName: input.displayName,
374
349
  skipOneTimePreKey: input.skipOneTimePreKey
375
- }, this.buildFakePeerDeps(pipeline));
376
- this.registries.registerPeer(peer);
350
+ }, this.buildFakePeerDeps(pipeline, session));
351
+ session.registries.registerPeer(peer);
377
352
  peers.push(peer);
378
353
  }
379
354
  return peers;
380
355
  }
381
- buildFakePeerDeps(pipeline) {
356
+ buildFakePeerDeps(pipeline, session) {
382
357
  return {
383
- bundleResolver: () => this.preKeyDispenser.awaitPreKeyBundle(),
384
- reserveOneTimePreKey: () => this.preKeyDispenser.dispenseOneTimePreKey(),
358
+ bundleResolver: () => session.preKeyDispenser.awaitPreKeyBundle(),
359
+ reserveOneTimePreKey: () => session.preKeyDispenser.dispenseOneTimePreKey(),
385
360
  pushStanza: (stanza) => pipeline.sendStanza(stanza),
386
- subscribeInboundMessages: (listener) => {
387
- const wrapped = (node) => {
388
- if (node.tag !== 'message')
389
- return;
390
- listener(node);
391
- };
392
- this.inboundStanzaListeners.add(wrapped);
393
- return () => {
394
- this.inboundStanzaListeners.delete(wrapped);
395
- };
396
- }
361
+ subscribeInboundMessages: (listener) => session.subscribeInboundMessages(listener)
397
362
  };
398
363
  }
399
364
  async runPairing(pipeline, options, companionMaterialResolver) {
365
+ const session = this.sessionFor(pipeline);
400
366
  const driver = new FakePairingDriver_1.FakePairingDriver(options, {
401
367
  pipeline,
402
368
  companionMaterialResolver,
403
369
  waitForPairDeviceAck: async (pairDeviceIqId) => {
404
- await this.expectIq({
370
+ await session.expectIq({
405
371
  id: pairDeviceIqId
406
372
  }, {
407
373
  timeoutMs: 15000
@@ -434,12 +400,31 @@ class FakeWaServer {
434
400
  get port() {
435
401
  return this.requireListening().port;
436
402
  }
403
+ /**
404
+ * `tcp://host:port` of the mobile listener, for the client's
405
+ * `mobileTransport.tcpUrl`.
406
+ *
407
+ * @throws when the server was started without the `tcp` option.
408
+ */
409
+ get tcpUrl() {
410
+ if (!this.tcpListenInfo) {
411
+ throw new Error('fake server has no mobile listener; start it with { tcp: true } to serve the mobile transport');
412
+ }
413
+ return this.tcpListenInfo.url;
414
+ }
437
415
  get noiseRootCa() {
438
416
  const root = this.requireRootCa();
439
417
  return { publicKey: root.publicKey, serial: root.serial };
440
418
  }
419
+ /**
420
+ * Subscribes to every new connection pipeline (pre-auth). Listeners
421
+ * fan out; returns an unsubscribe function.
422
+ */
441
423
  onPipeline(listener) {
442
- this.pipelineListener = listener;
424
+ this.pipelineListeners.add(listener);
425
+ return () => {
426
+ this.pipelineListeners.delete(listener);
427
+ };
443
428
  }
444
429
  async listen() {
445
430
  if (this.listenInfo) {
@@ -454,11 +439,35 @@ class FakeWaServer {
454
439
  this.wsServer.setHttpRequestHandler(mediaHandler);
455
440
  this.mediaHttpsServer.setRequestHandler(mediaHandler);
456
441
  this.listenInfo = await this.wsServer.listen();
457
- await this.mediaHttpsServer.listen('127.0.0.1');
442
+ try {
443
+ if (this.tcpServer) {
444
+ this.tcpListenInfo = await this.tcpServer.listen();
445
+ }
446
+ await this.mediaHttpsServer.listen('127.0.0.1');
447
+ }
448
+ catch (error) {
449
+ // A half-open server is worse than none: the listeners that did
450
+ // come up would stay bound and `listenInfo` would make a retry a
451
+ // no-op. Roll everything back so the caller can start over.
452
+ await this.rollbackFailedListen();
453
+ throw error;
454
+ }
455
+ }
456
+ async rollbackFailedListen() {
457
+ const ignore = () => undefined;
458
+ await this.wsServer.close().catch(ignore);
459
+ await this.tcpServer?.close().catch(ignore);
460
+ await this.mediaHttpsServer.close().catch(ignore);
461
+ this.listenInfo = null;
462
+ this.tcpListenInfo = null;
463
+ this.rootCa = null;
464
+ this.serverStaticKeyPair = null;
458
465
  }
459
466
  async stop() {
460
467
  this.pipelines.clear();
461
468
  await this.wsServer.close();
469
+ await this.tcpServer?.close();
470
+ this.tcpListenInfo = null;
462
471
  await this.mediaHttpsServer.close();
463
472
  if (this.cachedMediaProxyAgent) {
464
473
  this.cachedMediaProxyAgent.destroy();
@@ -481,53 +490,185 @@ class FakeWaServer {
481
490
  connection,
482
491
  rootCa: this.rootCa,
483
492
  serverStaticKeyPair: this.serverStaticKeyPair,
484
- iqRouter: this.iqRouter
493
+ routeIq: (node) => this.sessionFor(pipeline).routeIq(node, { connection: pipeline }),
494
+ ...(this.options.successNodeAttributes !== undefined
495
+ ? { successNodeAttributes: this.options.successNodeAttributes }
496
+ : {})
485
497
  });
486
498
  this.pipelines.add(pipeline);
499
+ this.pipelineSessions.set(pipeline, this.defaultSession);
487
500
  pipeline.setEvents({
488
501
  onAuthenticated: () => {
502
+ this.bindPipelineSession(pipeline);
503
+ this.bindMobilePrimary(pipeline);
489
504
  for (const listener of this.authenticatedListeners) {
490
- void listener(pipeline);
505
+ try {
506
+ void Promise.resolve(listener(pipeline)).catch(() => undefined);
507
+ }
508
+ catch {
509
+ /* ignore */
510
+ }
491
511
  }
492
512
  },
493
- onStanza: (node) => this.handleCapturedStanza(node),
494
- onClose: () => this.pipelines.delete(pipeline)
513
+ onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
514
+ onClose: () => this.forgetPipeline(pipeline)
495
515
  });
496
- this.pipelineListener?.(pipeline);
497
- }
498
- handleCapturedStanza(node) {
499
- this.capturedStanzas.push(node);
500
- for (const listener of this.capturedStanzaListeners) {
516
+ for (const listener of this.pipelineListeners) {
501
517
  try {
502
- listener(node);
518
+ void Promise.resolve(listener(pipeline)).catch(() => undefined);
503
519
  }
504
- catch (error) {
505
- void error;
520
+ catch {
521
+ /* ignore */
506
522
  }
507
523
  }
508
- for (const listener of this.inboundStanzaListeners) {
509
- try {
510
- listener(node);
524
+ }
525
+ /**
526
+ * Rebinds a connection to its keyed session once authenticated. Runs
527
+ * before user `onAuthenticated` listeners and before any client stanza, so
528
+ * captures and IQ routing land in the right session from the first frame.
529
+ * No-op without a `sessionKey` resolver (single default session).
530
+ */
531
+ bindPipelineSession(pipeline) {
532
+ const resolveKey = this.options.sessionKey;
533
+ const clientPayload = pipeline.clientPayload;
534
+ if (!resolveKey || !clientPayload) {
535
+ return;
536
+ }
537
+ const id = resolveKey({ clientPayload, pipeline });
538
+ this.pipelineSessions.set(pipeline, this.session(id));
539
+ }
540
+ /** Drops every reference to a closed connection so a relay never targets it. */
541
+ forgetPipeline(pipeline) {
542
+ this.pipelines.delete(pipeline);
543
+ for (const [ref, offered] of this.pendingCompanionOffers) {
544
+ if (offered === pipeline) {
545
+ this.pendingCompanionOffers.delete(ref);
511
546
  }
512
- catch (error) {
513
- void error;
547
+ }
548
+ for (const [jid, primary] of this.mobilePrimaryPipelines) {
549
+ if (primary === pipeline) {
550
+ this.mobilePrimaryPipelines.delete(jid);
514
551
  }
515
552
  }
516
- for (const expectation of this.pendingStanzaExpectations) {
517
- if (matchesStanza(node, expectation.matcher)) {
518
- expectation.resolve(node);
519
- break;
553
+ for (const [ref, session] of this.linkCodeSessions) {
554
+ if (session.companion === pipeline || session.primary === pipeline) {
555
+ this.linkCodeSessions.delete(ref);
520
556
  }
521
557
  }
522
- if (node.tag !== 'iq') {
558
+ }
559
+ /**
560
+ * Records a phone login as the session's account owner. Everything on the
561
+ * companion-host path keys off it: device jids are minted under its number,
562
+ * and `remove-companion-device` is read as a revoke rather than a logout.
563
+ */
564
+ bindMobilePrimary(pipeline) {
565
+ const clientPayload = pipeline.clientPayload;
566
+ if (clientPayload?.kind !== 'login' || clientPayload.flavor !== 'mobile') {
523
567
  return;
524
568
  }
525
- for (const expectation of this.pendingIqExpectations) {
526
- if (matchesIq(node, expectation.matcher)) {
527
- expectation.resolve(node);
528
- return;
569
+ const session = this.sessionFor(pipeline);
570
+ const jid = `${clientPayload.username}@${HOST_DOMAIN}`;
571
+ session.companionHost.bindPrimary({ username: clientPayload.username, jid });
572
+ if (session.companionHost.primary?.jid !== jid) {
573
+ // The session already belongs to another number. Wiring this login
574
+ // in anyway would let a link be relayed to this connection and then
575
+ // minted under the account that owns the session.
576
+ return;
577
+ }
578
+ session.registries.registerDeviceId(jid, 0);
579
+ // A reconnect of the same account replaces its stale connection here.
580
+ this.mobilePrimaryPipelines.set(jid, pipeline);
581
+ }
582
+ /**
583
+ * Offers a companion connection the refs for a primary-driven link: pushes
584
+ * the `pair-device` IQ it turns into a QR, and remembers each ref so the
585
+ * primary's upload can be routed back to this connection. Returns the refs.
586
+ *
587
+ * This is the counterpart of {@link runPairing}, where the server itself
588
+ * plays the primary. Here a real mobile-primary client signs the identity
589
+ * and the server only relays.
590
+ */
591
+ async offerCompanionPairing(pipeline, options = {}) {
592
+ const refs = await (0, pair_device_1.mintPairingRefs)(options.refCount);
593
+ // Send before registering: the builder rejects a bad ref count and the
594
+ // send can fail, and either would leave refs in the map that no
595
+ // connection will ever consume.
596
+ await pipeline.sendStanza((0, pair_device_1.buildPairDeviceIq)({ refs }));
597
+ for (const ref of refs) {
598
+ this.pendingCompanionOffers.set(ref, pipeline);
599
+ }
600
+ return refs;
601
+ }
602
+ /**
603
+ * Relays one link-code stage between the two clients running the pairing
604
+ * handshake. `companion_hello` mints the ref that ties the stages together
605
+ * and registers it as a pairing offer, so the primary's later `pair-device`
606
+ * upload lands on the same companion connection as the QR flow.
607
+ */
608
+ async handleLinkCodeStage(iq, context) {
609
+ const parsed = (0, link_code_1.parseLinkCodeStanza)(iq);
610
+ if (!parsed || !context) {
611
+ return null;
612
+ }
613
+ const sender = context.connection;
614
+ if (parsed.stage === 'companion_hello') {
615
+ const primary = parsed.phoneJid
616
+ ? this.mobilePrimaryPipelines.get(parsed.phoneJid)
617
+ : undefined;
618
+ if (!primary) {
619
+ return (0, router_1.buildIqError)(iq, { code: 404, text: 'primary-not-connected' });
529
620
  }
621
+ const [ref] = await (0, pair_device_1.mintPairingRefs)(1);
622
+ this.linkCodeSessions.set(ref, { companion: sender, primary });
623
+ this.pendingCompanionOffers.set(ref, sender);
624
+ await primary.sendStanza((0, link_code_1.buildLinkCodeNotification)({
625
+ stage: 'companion_hello',
626
+ ref,
627
+ children: parsed.children
628
+ }));
629
+ return (0, router_1.buildIqResult)(iq, { content: (0, link_code_1.buildCompanionHelloResultContent)(ref) });
530
630
  }
631
+ const session = parsed.ref ? this.linkCodeSessions.get(parsed.ref) : undefined;
632
+ if (!session || !parsed.ref) {
633
+ return (0, router_1.buildIqError)(iq, { code: 404, text: 'unknown-pairing-ref' });
634
+ }
635
+ const target = parsed.stage === 'primary_hello' ? session.companion : session.primary;
636
+ await target.sendStanza((0, link_code_1.buildLinkCodeNotification)({
637
+ stage: parsed.stage,
638
+ ref: parsed.ref,
639
+ children: parsed.children
640
+ }));
641
+ return (0, router_1.buildIqResult)(iq);
642
+ }
643
+ /**
644
+ * Host side of a companion link: hands the primary-signed identity to the
645
+ * connection that owns `ref` and reports what that companion advertised at
646
+ * registration. Resolves `null` for an unknown or already-consumed ref.
647
+ */
648
+ async completeCompanionPairing(input) {
649
+ const pipeline = this.pendingCompanionOffers.get(input.ref);
650
+ if (!pipeline) {
651
+ return null;
652
+ }
653
+ const registration = pipeline.clientPayload;
654
+ const companionPropsBytes = registration?.kind === 'registration'
655
+ ? (registration.devicePairingData.deviceProps ?? null)
656
+ : null;
657
+ // `<platform>` describes the primary that signed the link, not the
658
+ // companion. A zapo mobile session always logs in as ANDROID, so this
659
+ // is the only value a primary-driven link can carry here.
660
+ const platform = MOBILE_PRIMARY_PLATFORM;
661
+ await pipeline.sendStanza((0, pair_device_1.buildPairSuccessIq)({
662
+ deviceJid: input.deviceJid,
663
+ platform,
664
+ deviceIdentityBytes: input.deviceIdentityBytes
665
+ }));
666
+ for (const [ref, offered] of this.pendingCompanionOffers) {
667
+ if (offered === pipeline) {
668
+ this.pendingCompanionOffers.delete(ref);
669
+ }
670
+ }
671
+ return { companionPropsBytes, platform };
531
672
  }
532
673
  buildMediaRequestHandler() {
533
674
  return (req, res) => {
@@ -616,69 +757,3 @@ function parseQueryParam(query, name) {
616
757
  }
617
758
  return undefined;
618
759
  }
619
- function matchesIq(iq, matcher) {
620
- if (iq.tag !== 'iq')
621
- return false;
622
- if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
623
- return false;
624
- if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
625
- return false;
626
- if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
627
- return false;
628
- if (matcher.childTag !== undefined) {
629
- const children = Array.isArray(iq.content) ? iq.content : null;
630
- if (!children || children.length === 0)
631
- return false;
632
- if (children[0].tag !== matcher.childTag)
633
- return false;
634
- }
635
- return true;
636
- }
637
- function describeMatcher(matcher) {
638
- const parts = [];
639
- if (matcher.id !== undefined)
640
- parts.push(`id=${matcher.id}`);
641
- if (matcher.type !== undefined)
642
- parts.push(`type=${matcher.type}`);
643
- if (matcher.xmlns !== undefined)
644
- parts.push(`xmlns=${matcher.xmlns}`);
645
- if (matcher.childTag !== undefined)
646
- parts.push(`childTag=${matcher.childTag}`);
647
- return parts.length > 0 ? parts.join(', ') : 'any iq';
648
- }
649
- function matchesStanza(node, matcher) {
650
- if (matcher.tag !== undefined && node.tag !== matcher.tag)
651
- return false;
652
- if (matcher.type !== undefined && node.attrs.type !== matcher.type)
653
- return false;
654
- if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
655
- return false;
656
- if (matcher.from !== undefined && node.attrs.from !== matcher.from)
657
- return false;
658
- if (matcher.to !== undefined && node.attrs.to !== matcher.to)
659
- return false;
660
- if (matcher.childTag !== undefined) {
661
- const children = Array.isArray(node.content) ? node.content : null;
662
- if (!children || children.length === 0)
663
- return false;
664
- if (children[0].tag !== matcher.childTag)
665
- return false;
666
- }
667
- return true;
668
- }
669
- function describeStanzaMatcher(matcher) {
670
- const parts = [];
671
- if (matcher.tag !== undefined)
672
- parts.push(`tag=${matcher.tag}`);
673
- if (matcher.type !== undefined)
674
- parts.push(`type=${matcher.type}`);
675
- if (matcher.xmlns !== undefined)
676
- parts.push(`xmlns=${matcher.xmlns}`);
677
- if (matcher.from !== undefined)
678
- parts.push(`from=${matcher.from}`);
679
- if (matcher.to !== undefined)
680
- parts.push(`to=${matcher.to}`);
681
- if (matcher.childTag !== undefined)
682
- parts.push(`childTag=${matcher.childTag}`);
683
- return parts.length > 0 ? parts.join(', ') : 'any stanza';
684
- }