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